diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index 011b0c8c14..7820225840 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "planning", - "version": "0.49.4", + "version": "0.50.0", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 96b500b422..51e303b934 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `planning` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.50.0] - 2026-09-30 + +### Added + +- **The surface nudges when an answer note ends mid-sentence.** Beside the Save button, a note that stops on a word like "and" or "the" shows "This note looks cut off. Finish it before saving?" It does not block saving, and a note ending in `?` keeps the Ask Claude nudge instead ([#5454](https://github.com/melodic-software/claude-code-plugins/issues/5454)). + ## [0.49.4] - 2026-09-30 ### Added diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 0baeef96a6..c090aee14c 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -137,7 +137,7 @@ An accept whose note conditions the acceptance ("before we lock it in") is recor An accept (or a reconfirmed accept) and an `own` answer carry the recommendation's commitments; an `alt` withdraws them and a `defer` carries none (its open row covers them). Unticked commitments of an accepted or `own` question reach the Brief as named risks. When the user confirms commitments in the terminal, record them with `confirm-commitments` (`reason` says how, such as "confirmed in the terminal"); the page and `export-brief` count them as confirmed, like a page `confirm`. The summary's To confirm list holds only unconfirmed commitments; commitments confirmed this way are named below it with their reasons. -An `own` answer that is really a question, or that holds a condition ("yes, but explain X before I lock it"), is not closed: record the decision, answer it with a `reply`, and post `wait` with `"by": "user"` on the question (`waitsOn` such as "your answer after the explanation"). The earlier decision is set aside. When the user answers again, clear the hold; that new decision counts. The page nudges an own answer ending in `?` toward Ask Claude before it is saved. +An `own` answer that is really a question, or that holds a condition ("yes, but explain X before I lock it"), is not closed: record the decision, answer it with a `reply`, and post `wait` with `"by": "user"` on the question (`waitsOn` such as "your answer after the explanation"). The earlier decision is set aside. When the user answers again, clear the hold; that new decision counts. The page nudges an own answer ending in `?` toward Ask Claude before it is saved, and a note that ends mid-sentence gets a "looks cut off" nudge; neither blocks saving. A challenge to a commitment arrives as an `ask` whose text leads with the commitment. A changed answer marks its direct dependents `stale` and their descendants `upstream-pending`; triage each stale dependent: a small impact gets a proposed answer the user reconfirms with `a`, a large one is re-asked or archived and replaced. Nothing carries over silently. diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index cecb4191b4..40167326d1 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -970,6 +970,8 @@

Interview

if (done < n || lsGet("cpDismissed", 0) >= n) return ""; return ''; } + const DANGLING = /(?:[,\-(]|\b(?:and|but|or|so|the|a|an|to|of|with|for|that|which|because|if|then|like|in|on|at|as|is|are|was|we|i|it|my|our|your|this|these|about|from|into|than|when|where|while|just|maybe))$/i; + const midSentence = s => { s = String(s || "").trim(); return s.length >= 12 && !/[.!?)\]"'”’`]$/.test(s) && DANGLING.test(s); }; function renderAnswer(){ const q = Q[S.sel], note = $("note"); if (!q) { $("cur").innerHTML = ""; $("choices").innerHTML = ""; $("decideRow").innerHTML = ""; $("talkRow").innerHTML = ""; note.disabled = true; renderToast(); return; } @@ -992,9 +994,10 @@

Interview

'' + c.n + '' + esc(c.label) + " " + esc(c.text) + "").join(""); const needText = armed && armed.kind === "own" && keptText(armed) === undefined && !text, frozen = wrapFreeze() > 0; const label = armed ? (st === "wait" ? "Answer anyway: " : "Save: ") + armed.label + (withNote(q, armed) ? " with note" : "") : "Save"; - const asks = armed && armed.kind === "own" && /\?$/.test(text); + const asks = armed && armed.kind === "own" && /\?$/.test(text), cut = !asks && midSentence(text); $("decideRow").innerHTML = '' + (frozen ? "Wrapping up" : needText ? "Own answer needs a note" : "Ctrl+Enter") + "" + (asks ? 'This reads as a question. Ask Claude instead?" : "") + + (cut ? 'This note looks cut off. Finish it before saving?' : "") + (d && d.decision && !d.aside ? '' : ""); $("talkRow").innerHTML = '' + ''; @@ -1047,8 +1050,6 @@

Interview

} // ---------- summary / completion ---------- - const DANGLING = /(?:[,\-(]|\b(?:and|but|or|so|the|a|an|to|of|with|for|that|which|because|if|then|like|in|on|at|as|is|are|was|we|i|it|my|our|your|this|these|about|from|into|than|when|where|while|just|maybe))$/i; - const midSentence = s => { s = String(s || "").trim(); return s.length >= 12 && !/[.!?)\]"'”’`]$/.test(s) && DANGLING.test(s); }; function noteThread(){ const mine = events().filter(e => e.kind === "note").map(e => ({by: "you", at: e.at, text: e.text, seq: e.seq})); const claude = (S.doc.notes || []).map(n => Object.assign({by: "claude"}, n)); diff --git a/plugins/planning/surface/surface.test.sh b/plugins/planning/surface/surface.test.sh index 5c85b50798..9ee28014f5 100755 --- a/plugins/planning/surface/surface.test.sh +++ b/plugins/planning/surface/surface.test.sh @@ -267,8 +267,8 @@ if command -v playwright-cli >/dev/null 2>&1; then for n in 1 2 3 4 5; do grade "ui_c.$n" "$tmp/ui_c$n.out"; done for n in 1 2 3 4 5 6 7 8 9 10 11 12 13; do grade "ui_journey.$n" "$tmp/uj$n.out"; done else - echo "SKIP: 298 browser checks not run, 114 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite - skip=$((skip + 298)) + echo "SKIP: 300 browser checks not run, 116 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite + skip=$((skip + 300)) fi echo "PASS=$pass FAIL=$fail SKIP=$skip" diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index 646fd783b1..31f322b772 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -80,6 +80,11 @@ async page => { // the user journey in order on one page, no reload after phase ok("the ask's toast clears when the view changes", /reply lands in the thread/.test(askToast) && (await text("#toast")) === "", askToast + " / " + await text("#toast")); await page.fill("#note", "Should we pin the version?"); await arm("o"); ok("an Own answer ending in ? shows the Ask Claude nudge before save", /This reads as a question\. Ask Claude instead\?/.test(await text("#askNudge")) && !!(await page.$('#askNudge [data-act="ask"]')), await text("#decideRow")); + await page.fill("#note", "We should pin the version because"); + ok("an Own note that ends mid-sentence shows the cut-off nudge and Save stays enabled", /looks cut off/.test(await text("#cutNudge")) && !(await page.$("#askNudge")) && !(await page.$("[data-save][disabled]")), await text("#decideRow")); + await page.fill("#note", "This is complete."); + ok("a finished Own note shows no cut-off nudge", !(await page.$("#cutNudge")), await text("#decideRow")); + await page.fill("#note", "Should we pin the version?"); await page.keyboard.press("Escape"); await tap("[data-save]", 700); const own = await last(); ok("saving as own stays possible", own.kind === "own" && own.id === "Q5" && own.text === "Should we pin the version?", JSON.stringify(own));