diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json
index b696448c9b..722cb8394c 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.47.6",
+ "version": "0.47.7",
"userConfig": {
"surface": {
"type": "string",
diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md
index 45e2e33569..00fe217893 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.47.7] - 2026-09-29
+
+### Fixed
+
+- **A group summary that predates questions added to its group is flagged.** Writing a group `summary` records the group's question ids as `summaryOf`. `round.py add` and `add-round` warn when a question lands in a group whose summary was written for a different set, and the page marks that summary Stale until it is rewritten ([#5451](https://github.com/melodic-software/claude-code-plugins/issues/5451)).
+
## [0.47.6] - 2026-09-29
### Fixed
diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md
index 7d0074fa2d..df52bf23c5 100644
--- a/plugins/planning/skills/interview/context/surface.md
+++ b/plugins/planning/skills/interview/context/surface.md
@@ -65,7 +65,7 @@ When the first wake prompts for permission, offer the user one allow rule per co
| `reply` | `id`, `text`, `seq`, `kind` (`reply`, `rephrase`, `note`), `rec` + `why` + `affects`, `handled`, `force` | Answer an ask or rephrase; `rec` revises the recommendation |
| `revise` | `id`, `title`, `short`, `facts`, `basis`, `rec`, `why`, `text`, `alternatives`, `seq`, `affects`, `force` | Reword a question |
| `note-reply` | `text`, `seq` | Answer a note in Notes to Claude; with no `seq`, post a closing probe there |
-| `add`, `add-round`, `group` | `question`; `round`, `meta`, `groups`, `questions`, `visuals`; `id`, `title`, `summary`, `dependsOn` | New questions and groups |
+| `add`, `add-round`, `group` | `question`; `round`, `meta`, `groups`, `questions`, `visuals`; `id`, `title`, `summary`, `dependsOn` | New questions and groups; writing a `summary` records the group's current question ids as `summaryOf`, and the page marks the summary Stale once the members differ, so rewrite the summary after adding questions |
| `meta` | `set` (`title`, `eyebrow`, `stages`, `next`) | Merge into `meta`; other meta keys stay |
| `archive` | `ids`, `why` | Take off-path questions out of the open count |
| `replace-visual` | `visual` | Swap in a full visual object for the top-level visual with the same id; an unknown id is refused |
diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html
index 211a5d894d..7fb8f9d0f1 100644
--- a/plugins/planning/surface/index.html
+++ b/plugins/planning/surface/index.html
@@ -522,6 +522,11 @@
Interview
const isOpen = st => st === "open" || st === "need";
const live = q => !["sup", "arch"].includes(stateOf(q));
const isStale = q => q.state === "stale" && stateOf(q) !== "arch";
+ const summaryStale = g => {
+ if (!g.summary || !Array.isArray(g.summaryOf)) return false;
+ const now = S.doc.questions.filter(q => q.group === g.id).map(q => q.id).sort();
+ return now.join("\n") !== g.summaryOf.join("\n");
+ };
const stageLabel = s => ((S.doc.meta || {}).stages || {})[s] || (s ? s[0].toUpperCase() + s.slice(1) : "Round");
const roundLabel = q => stageLabel(q.stage) + " round " + (q.round ?? 1);
function headRound(){ // the highest round among live questions of the newest question's stage
@@ -602,7 +607,7 @@
";
}
}
diff --git a/plugins/planning/surface/round.py b/plugins/planning/surface/round.py
index a76cad4678..963cc98dba 100644
--- a/plugins/planning/surface/round.py
+++ b/plugins/planning/surface/round.py
@@ -334,6 +334,31 @@ def lint_questions(doc, qs):
)
+def group_members(doc, gid):
+ """Ids of every question in the group, archived and superseded included: the page lists them all."""
+ return sorted(q["id"] for q in doc["questions"] if q.get("group") == gid)
+
+
+def record_summary_of(doc, gid):
+ next(x for x in doc["groups"] if x["id"] == gid)["summaryOf"] = group_members(
+ doc, gid
+ )
+
+
+def warn_stale_summaries(doc, qs):
+ """One warning per group whose summary was written for a different set of questions."""
+ for gid in dict.fromkeys(q.get("group") for q in qs):
+ g = next((x for x in doc["groups"] if x["id"] == gid), None)
+ if g is None or "summaryOf" not in g:
+ continue
+ added = [i for i in group_members(doc, gid) if i not in g["summaryOf"]]
+ if added:
+ warn(
+ f"group {gid} summary predates {len(added)} questions; refresh it with: "
+ f"round.py group {gid} --summary ..."
+ )
+
+
def put_group(doc, g):
capped(f"group {g['id']} title", g.get("title"), LINE_CAP)
capped(f"group {g['id']} summary", g.get("summary"), TEXT_CAP)
@@ -342,6 +367,8 @@ def put_group(doc, g):
cur = {"id": g["id"]}
doc["groups"].append(cur)
cur.update({k: v for k, v in g.items() if v is not None})
+ if g.get("summary") is not None:
+ record_summary_of(doc, g["id"])
if not cur.get("title"):
sys.exit(f"a new group needs a title: {g['id']}")
known = {x["id"] for x in doc["groups"]}
@@ -374,6 +401,7 @@ def op_meta(d, doc, a):
def op_add(d, doc, a):
touched = add_question(doc, a.question)
+ warn_stale_summaries(doc, [a.question])
check_primaries(doc)
return touched, f"added {a.question['id']}"
@@ -391,6 +419,10 @@ def op_add_round(d, doc, a):
if a.round is not None:
q.setdefault("round", a.round)
touched += add_question(doc, q)
+ for g in a.groups or []:
+ if g.get("summary") is not None:
+ record_summary_of(doc, g["id"])
+ warn_stale_summaries(doc, a.questions or [])
known = {v.get("id") for v in doc["visuals"]}
for v in a.visuals or []:
if not v.get("id"):
diff --git a/plugins/planning/surface/schema/questions.schema.json b/plugins/planning/surface/schema/questions.schema.json
index cdba367625..6543ff0004 100644
--- a/plugins/planning/surface/schema/questions.schema.json
+++ b/plugins/planning/surface/schema/questions.schema.json
@@ -122,6 +122,11 @@
"id": {"type": "string"},
"title": {"type": "string"},
"summary": {"type": "string"},
+ "summaryOf": {
+ "description": "Ids of the questions in the group when its summary was last written; the surface compares it with the current members.",
+ "type": "array",
+ "items": {"type": "string"}
+ },
"dependsOn": {"type": "array", "items": {"type": "string"}}
}
},
diff --git a/plugins/planning/surface/test_round.py b/plugins/planning/surface/test_round.py
index 311c6453dc..8be3aec5f3 100644
--- a/plugins/planning/surface/test_round.py
+++ b/plugins/planning/surface/test_round.py
@@ -1477,6 +1477,46 @@ def test_confirm_records_no_decision(self):
self.assertEqual(history["Q1"][1]["alt"], "0")
+class TestGroupSummaryOf(DirCase):
+ """A group summary records the questions it was written for and warns when they change."""
+
+ def add_group(self, summary):
+ rc, out, err = self.rp("group", "g3", "--title", "T", "--summary", summary)
+ self.assertEqual(rc, 0, out + err)
+
+ def add(self, qid):
+ return self.rp("add", "--file", self.file("q.json", question(qid, group="g3")))
+
+ def test_a_summary_rewrite_records_the_members(self):
+ self.add_group("First take.")
+ self.assertEqual(self.add("Q4")[0], 0)
+ self.assertEqual(self.add("Q5")[0], 0)
+ self.add_group("Second take.")
+ g3 = next(g for g in self.doc()["groups"] if g["id"] == "g3")
+ self.assertEqual(g3["summaryOf"], ["Q4", "Q5"])
+
+ def test_a_question_added_after_the_summary_warns_and_keeps_summary_of(self):
+ self.add_group("Take.")
+ self.add("Q4")
+ self.add_group("Take.")
+ rc, out, err = self.add("Q5")
+ self.assertEqual(rc, 0, out + err)
+ self.assertIn("group g3 summary predates 1 questions", err)
+ g3 = next(g for g in self.doc()["groups"] if g["id"] == "g3")
+ self.assertEqual(g3["summaryOf"], ["Q4"])
+
+ def test_add_round_with_summary_and_questions_records_them_without_warning(self):
+ spec = {
+ "groups": [{"id": "g3", "title": "T", "summary": "Take."}],
+ "questions": [question("Q4", group="g3"), question("Q5", group="g3")],
+ }
+ rc, out, err = self.rp("add-round", "--file", self.file("r.json", spec))
+ self.assertEqual(rc, 0, out + err)
+ self.assertNotIn("summary predates", err)
+ g3 = next(g for g in self.doc()["groups"] if g["id"] == "g3")
+ self.assertEqual(g3["summaryOf"], ["Q4", "Q5"])
+
+
class TestMeta(DirCase):
"""`add-round` meta and the `meta` op: title, eyebrow, stages, next; nothing else."""
diff --git a/plugins/planning/surface/tests/fixtures/questions.json b/plugins/planning/surface/tests/fixtures/questions.json
index 2d2e5710f7..3749c876bc 100644
--- a/plugins/planning/surface/tests/fixtures/questions.json
+++ b/plugins/planning/surface/tests/fixtures/questions.json
@@ -14,12 +14,14 @@
{
"id": "content",
"title": "Content and visuals",
- "summary": "What rich content means and where it lives."
+ "summary": "What rich content means and where it lives.",
+ "summaryOf": ["Q1"]
},
{
"id": "ordering",
"title": "Ordering and groups",
"summary": "How the rail groups and orders questions.",
+ "summaryOf": ["Q2", "Q3"],
"dependsOn": [
"content"
]
diff --git a/plugins/planning/surface/tests/ui_a.js b/plugins/planning/surface/tests/ui_a.js
index d6c7a069dd..7cb0ff4714 100644
--- a/plugins/planning/surface/tests/ui_a.js
+++ b/plugins/planning/surface/tests/ui_a.js
@@ -17,6 +17,7 @@ async page => {
const lock = await page.textContent('.sec[data-key="g:g10"] .lock').catch(() => "");
ok("dependent group shows locked", /opens after New group/.test(lock), lock);
ok("independent group not locked", !(await page.$('.sec[data-key="g:g9"] .lock')));
+ ok("only the group whose summary predates a question shows Stale", (await page.$$('.sec[data-key="g:ordering"] .sec-hint .chip.hot')).length === 1 && !(await page.$('.sec[data-key="g:content"] .sec-hint .chip.hot')));
ok("needs connector on N2", /needs N1/.test(await page.textContent('.qbtn[data-q="N2"]')));
await page.setViewportSize({width: 510, height: 860});
if (!(await page.isVisible('.qbtn[data-q="Q7"]'))) await page.click('.sec[data-key="g:realtime"] .sec-h');