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 '
' + done + " answered. Past your checkpoint of " + n + '. Wrap up, or split what is left.
}
// ---------- 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));