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 @@

Interview

const carry = carryOver(), qs = S.doc.questions.slice().sort(byIdCmp), secs = []; if (S.view === "groups") { const gs = S.doc.groups || []; - gs.forEach(g => secs.push({key: "g:" + g.id, gid: g.id, title: g.title || g.id, hint: g.summary, items: qs.filter(q => q.group === g.id), lock: lockedBy(g.id)})); + gs.forEach(g => secs.push({key: "g:" + g.id, gid: g.id, title: g.title || g.id, hint: g.summary, hintStale: summaryStale(g), items: qs.filter(q => q.group === g.id), lock: lockedBy(g.id)})); const known = new Set(gs.map(g => g.id)), un = qs.filter(q => !known.has(q.group)); if (un.length) secs.push({key: "g:-", title: "Ungrouped", items: un, lock: []}); } else if (S.view === "rounds") { @@ -692,7 +697,7 @@

Interview

'
" + (elig.length && aa ? '" : "") + "
" + (s.lock.length ? '
Locked: opens after ' + s.lock.map(g => esc(groupTitle(g))).join(", ") + "
" : "") + - (s.hint ? '
' + esc(s.hint) + "
" : "") + + (s.hint ? '
' + (s.hintStale ? 'Stale ' : "") + esc(s.hint) + "
" : "") + '"; } } 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');