Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/planning/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions plugins/planning/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/skills/interview/context/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 7 additions & 2 deletions plugins/planning/surface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,11 @@ <h1 id="title">Interview</h1>
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
Expand Down Expand Up @@ -602,7 +607,7 @@ <h1 id="title">Interview</h1>
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") {
Expand Down Expand Up @@ -692,7 +697,7 @@ <h1 id="title">Interview</h1>
'<div class="sec-top"><button class="sec-h" aria-expanded="' + !s.collapsed + '"><span class="caret">&#9662;</span><h2>' + esc(s.title) + '</h2><span class="cnt">' + s.open + " open / " + s.items.length + "</span></button>" +
(elig.length && aa ? '<button class="tbtn small" ' + aa + ' data-submit="1" title="Accept the recommendation on the open questions you have opened here">Accept all (' + elig.length + ")</button>" : "") + "</div>" +
(s.lock.length ? '<div class="lock">Locked: opens after ' + s.lock.map(g => esc(groupTitle(g))).join(", ") + "</div>" : "") +
(s.hint ? '<div class="sec-hint">' + esc(s.hint) + "</div>" : "") +
(s.hint ? '<div class="sec-hint">' + (s.hintStale ? '<span class="chip hot" title="This summary was written before questions were added to the group">Stale</span> ' : "") + esc(s.hint) + "</div>" : "") +
'<ul class="qlist">' + (items.length ? items.map(q => "<li>" + railItem(q, s, carry, fresh) + "</li>").join("") : '<li class="empty">None.</li>') + "</ul></div>";
}
}
Expand Down
32 changes: 32 additions & 0 deletions plugins/planning/surface/round.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"]}
Expand Down Expand Up @@ -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']}"

Expand All @@ -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"):
Expand Down
5 changes: 5 additions & 0 deletions plugins/planning/surface/schema/questions.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
}
},
Expand Down
40 changes: 40 additions & 0 deletions plugins/planning/surface/test_round.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
4 changes: 3 additions & 1 deletion plugins/planning/surface/tests/fixtures/questions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
1 change: 1 addition & 0 deletions plugins/planning/surface/tests/ui_a.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading