From b899af87e76af195ba9d06e3d14c5f6217208301 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:33:35 -0400 Subject: [PATCH 1/7] feat(planning): add accept-audit event kind to the interview surface One accept-audit event carries a round id in alt and the accepted questions as id and contentRev pairs. The server accepts each eligible item, skips a changed or ineligible one, writes the accept-audit event followed by one accept per accepted item carrying auditSeq, and answers with the accepted ids and the skipped ids with a reason. Exporters write an accept with auditSeq with the note "pending agent validation" so it never reads as a plain hand accept; commitments stay unconfirmed. Refs #5472 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/README.md | 2 +- plugins/planning/surface/exporters.py | 18 ++- .../planning/surface/schema/event.schema.json | 19 ++- plugins/planning/surface/server.py | 118 ++++++++++++-- plugins/planning/surface/test_exporters.py | 70 +++++++++ plugins/planning/surface/test_schema.py | 19 +++ plugins/planning/surface/test_server.py | 147 ++++++++++++++++++ 7 files changed, 378 insertions(+), 15 deletions(-) diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 50b9c45bd0..c8243adf40 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -68,7 +68,7 @@ Every command needs `--dir ''`; there is no default. Every write valid ## Data contract -`schema/` is the contract; other tools write these formats or read the exports, and the surface reads no other files except a file a visual names inside the data dir. Both documents carry `"schemaVersion": "1.0"`; a file without one reads as version 0 and loads unchanged. `responses.json` is an append-only event log with a global `seq`; undo marks an event `withdrawn` and nothing is deleted. Event kinds: `accept`, `alt`, `own`, `defer`, `reopen`, `ask`, `rephrase`, `note`, `undo`, `wrapup`, `confirm`, `confirm-understanding`. `confirm-understanding` has no id; its `alt` is `confirm` or `off` (`off` needs `text`), and its `contentRev` must equal `restatement.rev`: a missing restatement or `contentRev` is 400, an older rev is 409 `{"error": "stale", "contentRev": }`. A repeated Confirm (a `confirm` of the same commitment, or a `confirm-understanding` Confirm of the same rev) records nothing and returns the first event's seq. Question `state` (`open`, `stale`, `upstream-pending`, `archived`) is computed by the server from `dependsOn` and `archived`, never written. A visual is declared by `format` (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`; `kind` is read as an alias) and describes only its content. +`schema/` is the contract; other tools write these formats or read the exports, and the surface reads no other files except a file a visual names inside the data dir. Both documents carry `"schemaVersion": "1.0"`; a file without one reads as version 0 and loads unchanged. `responses.json` is an append-only event log with a global `seq`; undo marks an event `withdrawn` and nothing is deleted. Event kinds: `accept`, `alt`, `own`, `defer`, `reopen`, `ask`, `rephrase`, `note`, `undo`, `wrapup`, `confirm`, `confirm-understanding`, `accept-audit`. `accept-audit` has no id; its `alt` is the round id and `items` is a non-empty list of `{id, contentRev}` (anything else is 400). The server accepts each item that is open, has a recommendation, is not held, and has every prerequisite decided, and skips one whose `contentRev` no longer matches (`changed`) or that is not eligible (`ineligible`). It writes the `accept-audit` event, then one `accept` per accepted item carrying `auditSeq`, and answers `{"ok": true, "seq": , "accepted": [ids], "skipped": [{"id", "reason"}]}`; when nothing is accepted it writes nothing and answers 409 `{"error": "nothing accepted", "skipped": [...]}`. An accept with `auditSeq` exports with the note `pending agent validation`. `confirm-understanding` has no id; its `alt` is `confirm` or `off` (`off` needs `text`), and its `contentRev` must equal `restatement.rev`: a missing restatement or `contentRev` is 400, an older rev is 409 `{"error": "stale", "contentRev": }`. A repeated Confirm (a `confirm` of the same commitment, or a `confirm-understanding` Confirm of the same rev) records nothing and returns the first event's seq. Question `state` (`open`, `stale`, `upstream-pending`, `archived`) is computed by the server from `dependsOn` and `archived`, never written. A visual is declared by `format` (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`; `kind` is read as an alias) and describes only its content. ## Security model diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index 418a53f904..69c88f164a 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -69,6 +69,8 @@ ARBITER_USER = "**arbiter: USER-RESERVED**" ARBITER_PLAN = "**arbiter: /planning:plan**" SEED_NOTE = "Seeded from ledger" +# The note an accept made by an accept-audit event exports with (schema/event.schema.json). +PENDING_NOTE = "pending agent validation" ROW = re.compile(r"^\s*-\s+[Qq]([0-9]+)\s*\|(.*)$") LEAD = re.compile(r"^\[([^\]\s]+)\]\s*(.*)$") FENCE = re.compile(r"^\s*(```|~~~)") @@ -470,6 +472,16 @@ def latest_decision(q, responses): return newest_decision(q, responses, aside=False) +def pending_validation(rec, events): + """rec, carrying the pending-validation note when it is the accept an accept-audit made: + the page decision it came from is an accept event with `auditSeq`. Any later decision, or a + terminal answer, carries another seq (or none) and reads as its own.""" + src = next((e for e in events if e.get("seq") == rec.get("seq")), None) + if rec.get("decision") == "accept" and src and "auditSeq" in src: + return {**rec, "text": PENDING_NOTE} + return rec + + def commitments(q, events): """(confirmed, unconfirmed) commitment texts; a live `confirm` event ticks one by index, and so does a `commitsConfirmed` record from the confirm-commitments op.""" @@ -532,6 +544,7 @@ def settle(q, responses, events, seed_rows): # The resolution stays the seed's own, so a re-import reads the same proposal back. return "superseded-by-plan", seed_resolution(seed, confirmed), text, False if decision in ("accept", "alt", "own", "defer"): + rec = pending_validation(rec, events) return (*answer_row(q, rec, confirmed), text, decision == "defer") if superseded: # A set-aside decision leaves the plan's proposal waiting on the user again. @@ -765,10 +778,11 @@ def loose_ends(rows, doc, resp): if ( text and not MID_SENTENCE.search(text) - and e.get("kind") in ("own", "note", "ask", "accept", "alt", "defer") + and e.get("kind") + in ("own", "note", "ask", "accept", "alt", "defer", "accept-audit") ): ends.append( - f"#{e['seq']} {e.get('id') or 'note'} ends mid-sentence: {clean(text)}" + f"#{e['seq']} {e.get('id') or e['kind']} ends mid-sentence: {clean(text)}" ) if e.get("kind") in ("ask", "rephrase", "note") and e["seq"] not in replied: ends.append( diff --git a/plugins/planning/surface/schema/event.schema.json b/plugins/planning/surface/schema/event.schema.json index 86e74baa97..f0f148a891 100644 --- a/plugins/planning/surface/schema/event.schema.json +++ b/plugins/planning/surface/schema/event.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://melodic-software.github.io/claude-code-plugins/planning/surface/event", "title": "Interview surface page event", - "description": "One entry of responses.json events, the append-only log of everything the user did. `confirm` ticks one commitment (`alt` is its index as a string) and records no decision. `confirm-understanding` answers the restatement (no id; `alt` is `confirm` or `off`, `text` says what is off, `contentRev` is the restatement rev it answers).", + "description": "One entry of responses.json events, the append-only log of everything the user did. `confirm` ticks one commitment (`alt` is its index as a string) and records no decision. `confirm-understanding` answers the restatement (no id; `alt` is `confirm` or `off`, `text` says what is off, `contentRev` is the restatement rev it answers). `accept-audit` is the user accepting a round's recommendations and asking for agent validation (no id; `alt` is the round id, `items` the accepted questions as id and the contentRev the user held). The server writes it followed by one ordinary `accept` per item, each carrying `auditSeq` set to the accept-audit's seq. An accept with `auditSeq` is accepted pending agent validation, never a plain hand accept: exporters write that row with the note `pending agent validation`. Commitments stay unconfirmed.", "type": "object", "required": ["seq", "kind", "at"], "additionalProperties": false, @@ -10,7 +10,7 @@ "seq": {"type": "integer"}, "id": {"type": ["string", "null"]}, "kind": { - "enum": ["accept", "alt", "own", "defer", "reopen", "ask", "rephrase", "note", "undo", "wrapup", "confirm", "confirm-understanding"] + "enum": ["accept", "alt", "own", "defer", "reopen", "ask", "rephrase", "note", "undo", "wrapup", "confirm", "confirm-understanding", "accept-audit"] }, "alt": {"type": ["string", "null"]}, "text": {"type": "string"}, @@ -18,6 +18,19 @@ "deliveredAt": {"type": "string"}, "withdrawn": {"type": "boolean"}, "undoSeq": {"type": "integer"}, - "contentRev": {"type": "integer"} + "auditSeq": {"type": "integer"}, + "contentRev": {"type": "integer"}, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "contentRev"], + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "contentRev": {"type": "integer"} + } + } + } } } diff --git a/plugins/planning/surface/server.py b/plugins/planning/surface/server.py index 74425cf26e..dc01d65418 100644 --- a/plugins/planning/surface/server.py +++ b/plugins/planning/surface/server.py @@ -41,11 +41,12 @@ } DECISIONS = {"accept", "alt", "own", "defer", "reopen"} REQUESTS = {"ask", "rephrase"} -# Events not tied to a question; `confirm-understanding` answers the restatement. -FREE = {"note", "wrapup", "confirm-understanding"} -# Kinds that carry `alt`; `confirm` carries a commitment index and records no decision, and -# `confirm-understanding` carries `confirm` or `off`. -WITH_ALT = {"alt", "confirm", "confirm-understanding"} +# Events not tied to a question; `confirm-understanding` answers the restatement, and +# `accept-audit` lists the questions one click accepted (each also gets its own `accept`). +FREE = {"note", "wrapup", "confirm-understanding", "accept-audit"} +# Kinds that carry `alt`; `confirm` carries a commitment index and records no decision, +# `confirm-understanding` carries `confirm` or `off`, and `accept-audit` the round id. +WITH_ALT = {"alt", "confirm", "confirm-understanding", "accept-audit"} UNDERSTANDING = ("confirm", "off") API = 2 MAX_BODY = 64 * 1024 @@ -579,6 +580,67 @@ def rebuild_responses(events): return responses, history +def check_accept_audit(msg): + """ValueError (400) unless msg is a well-formed accept-audit: a round id in `alt` and a + non-empty `items` list of {id, contentRev} pairs with no repeated id. Returns the items.""" + if not (isinstance(msg.get("alt"), str) and msg["alt"].strip()): + raise ValueError("accept-audit needs alt: the round id") + items = msg.get("items") + if not isinstance(items, list) or not items: + raise ValueError( + "accept-audit needs items: a non-empty list of {id, contentRev}" + ) + seen = set() + for it in items: + if not ( + isinstance(it, dict) + and set(it) == {"id", "contentRev"} + and isinstance(it["id"], str) + and isinstance(it["contentRev"], int) + and not isinstance(it["contentRev"], bool) + ): + raise ValueError("each item must be {id: string, contentRev: integer}") + if it["id"] in seen: + raise ValueError(f"items repeats {it['id']}") + seen.add(it["id"]) + return items + + +def split_accept_audit(doc, r, items): + """(accepted, skipped) of an accept-audit: the items the server accepts now, and an + {id, reason} for each it refuses. `changed` is a contentRev that no longer matches; + `ineligible` is an unknown, closed, held or recommendation-less question, or one whose + prerequisite has no decision yet.""" + qs = {q.get("id"): q for q in doc.get("questions") or []} + states = question_states(doc, r) + seeded = ((doc.get("meta") or {}).get("seededFrom") or {}).get("rows") or {} + + def decided(qid): + return (r["responses"].get(qid) or {}).get("decision") or ( + (qs.get(qid) or {}).get("terminal") or {} + ).get("decision") + + accepted, skipped = [], [] + for it in items: + q = qs.get(it["id"]) + if q and it["contentRev"] != content_rev(q, r["events"]): + reason = "changed" + elif ( + not q + or states.get(it["id"], ("",))[0] != "open" + or not q.get("recommendation") + or q.get("waiting") + or (seeded.get(it["id"]) or {}).get("status") == "superseded-by-plan" + or not all(decided(p) for p in q.get("dependsOn") or []) + ): + reason = "ineligible" + else: + accepted.append(it) + continue + skipped.append({"id": it["id"], "reason": reason}) + return accepted, skipped + + def check_alt(q, kind, alt): """ValueError (400) unless `alt` names one of q's alternative keys (alt) or commitments (confirm).""" if kind == "alt": @@ -826,7 +888,9 @@ def state(self): } def record(self, msg): - """Append one page event. Returns (seq, contentRev or None). Raises ValueError (400) or Conflict (409).""" + """Append one page event. Returns (seq, contentRev or None, extra), where extra is the + `accepted` and `skipped` an accept-audit adds to its response and {} for any other kind. + Raises ValueError (400) or Conflict (409).""" qid, kind = msg.get("id"), msg.get("kind") if not isinstance(kind, str) or not isinstance(qid, (str, type(None))): raise ValueError("id and kind must be strings") @@ -848,7 +912,9 @@ def record(self, msg): raise ValueError("unknown question") if kind in ("own", "ask", "note") and not text.strip(): raise ValueError("text required") + items = check_accept_audit(msg) if kind == "accept-audit" else None now = now_iso() + extra = {} with self.cond: if kind == "confirm-understanding": check_understanding( @@ -870,6 +936,12 @@ def record(self, msg): qid = event["id"] elif kind == "confirm-understanding": event["contentRev"] = msg["contentRev"] + elif kind == "accept-audit": + accepted, skipped = split_accept_audit(doc, r, items) + if not accepted: + raise Conflict({"error": "nothing accepted", "skipped": skipped}) + event["items"] = accepted + extra = {"accepted": [i["id"] for i in accepted], "skipped": skipped} elif kind in DECISIONS and msg.get("contentRev") is not None: current = content_rev(qs[qid], r["events"]) if msg.get("contentRev") != current: @@ -896,7 +968,11 @@ def record(self, msg): check_alt(qs[qid], kind, alt) dup = repeat_of(r["events"], event) if dup: - return dup["seq"], content_rev(qs[qid], r["events"]) if qid else None + return ( + dup["seq"], + content_rev(qs[qid], r["events"]) if qid else None, + {}, + ) r["seq"] = seq = event["seq"] prev = r["responses"].get(qid, {}) if qid else {} if kind in DECISIONS: @@ -922,10 +998,33 @@ def record(self, msg): if kind == "undo": line["undoSeq"] = event["undoSeq"] r["history"].setdefault(qid, []).append(line) + for it in event["items"] if kind == "accept-audit" else []: + r["seq"] += 1 + accept = { + "seq": r["seq"], + "id": it["id"], + "kind": "accept", + "alt": None, + "text": "", + "at": now, + "auditSeq": seq, + } + r["events"].append(accept) + r["responses"][it["id"]] = decision_view(accept) + r["history"].setdefault(it["id"], []).append( + { + "at": now, + "by": "user", + "kind": "accept", + "alt": None, + "text": "", + "seq": accept["seq"], + } + ) save_json(self.responses, r) self.cond.notify_all() crev = content_rev(qs[qid], r["events"]) if qid in qs else None - return seq, crev + return seq, crev, extra def _undo(self, r, doc, msg, event): """Withdraw a decision Claude has not handled yet, restoring the decision before it.""" @@ -1285,7 +1384,7 @@ def do_POST(self): return self.send(400, {"error": "JSON object required"}) if url.path == "/api/answer": try: - seq, crev = self.hub.record(msg) + seq, crev, extra = self.hub.record(msg) except (ValueError, TypeError) as e: return self.send(400, {"error": str(e)}) except Conflict as e: @@ -1297,6 +1396,7 @@ def do_POST(self): "seq": seq, "contentRev": crev, "listener": self.hub.listener(), + **extra, }, ) if url.path == "/api/visual-open": diff --git a/plugins/planning/surface/test_exporters.py b/plugins/planning/surface/test_exporters.py index 14bd69c14b..77294dd246 100644 --- a/plugins/planning/surface/test_exporters.py +++ b/plugins/planning/surface/test_exporters.py @@ -323,6 +323,76 @@ def test_commits_confirmed_by_claude_count_as_confirmed(self): self.assertIn("confirmed:: One writer only; No network", row) +PENDING = "pending agent validation" + + +class TestAcceptAuditExport(SessionCase): + """An accept an accept-audit made exports as accepted pending agent validation.""" + + def audited(self, extra=(), terminal=None): + items = [{"id": "Q1", "contentRev": 0}, {"id": "Q2", "contentRev": 0}] + qs = [ + question("Q1", commits=["One writer only"], **(terminal or {})), + question("Q2"), + question("Q3"), + ] + events = [ + {**event(1, None, "accept-audit", alt="1"), "items": items}, + {**event(2, "Q1", "accept"), "auditSeq": 1}, + {**event(3, "Q2", "accept"), "auditSeq": 1}, + event(4, "Q3", "accept"), + *extra, + ] + self.session(qs, events) + + def test_audit_accepts_carry_the_note_and_a_hand_accept_does_not(self): + self.audited() + ledger = self.export("ledger") + q1, q2, q3 = register_rows(ledger) + self.assertIn( + f"answer:: accepted: Recommended answer for Q1.; note: {PENDING}", q1 + ) + self.assertIn(f"note: {PENDING}", q2) + self.assertRegex(q3, r"\| accepted: Recommended answer for Q3\.$") + rc, out = self.check("--ledger", ledger) + self.assertEqual(rc, 0, out) + brief = self.export("brief").read_text(encoding="utf-8") + self.assertIn( + f"- Q1 Short Q1: accepted: Recommended answer for Q1.; note: {PENDING}\n", + brief, + ) + self.assertIn("- Q3 Short Q3: accepted: Recommended answer for Q3.\n", brief) + + def test_commitments_stay_unconfirmed(self): + self.audited() + brief = self.export("brief").read_text(encoding="utf-8") + self.assertIn("- 0 commitments confirmed; 1 unconfirmed", brief) + self.assertIn("- risk: One writer only (unconfirmed); from Q1", brief) + + def test_a_later_decision_or_terminal_answer_reads_as_its_own(self): + later = "2099-01-01T00:00:00Z" + self.audited( + extra=[event(5, "Q2", "alt", alt="a")], + terminal={ + "terminal": {"decision": "accept", "text": "", "updatedAt": later} + }, + ) + q1, q2, _ = register_rows(self.export("ledger")) + self.assertNotIn(PENDING, q1) + self.assertIn("alt a: Alt a of Q2", q2) + self.assertNotIn(PENDING, q2) + + def test_the_note_survives_import_and_re_export(self): + self.audited() + ledger = self.export("ledger") + rows = register_rows(ledger) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp("import-ledger", "--ledger", str(ledger), d=fresh) + self.assertEqual(rc, 0, out) + self.assertEqual(register_rows(self.export("ledger", d=fresh)), rows) + + class TestCommitmentsCarriedByAnswerKind(SessionCase): """Q24: accept and own carry a recommendation's commitments; alt and defer carry none.""" diff --git a/plugins/planning/surface/test_schema.py b/plugins/planning/surface/test_schema.py index e11cdfdba1..a338e1d809 100644 --- a/plugins/planning/surface/test_schema.py +++ b/plugins/planning/surface/test_schema.py @@ -147,6 +147,25 @@ def test_event_kinds_include_confirm_understanding(self): } self.assertIsNone(schema.first_error(e, schema.load("event"))) + def test_accept_audit_event_and_the_accept_it_fans_out(self): + s = schema.load("event") + audit = { + "seq": 1, + "id": None, + "kind": "accept-audit", + "alt": "1", + "text": "", + "at": "t", + "items": [{"id": "Q1", "contentRev": 0}], + } + self.assertIsNone(schema.first_error(audit, s)) + accept = {"seq": 2, "id": "Q1", "kind": "accept", "at": "t", "auditSeq": 1} + self.assertIsNone(schema.first_error(accept, s)) + extra = {**audit, "items": [{"id": "Q1", "contentRev": 0, "note": "x"}]} + self.assertIn("unexpected property 'note'", schema.first_error(extra, s)) + missing = {**audit, "items": [{"id": "Q1"}]} + self.assertIn("missing required 'contentRev'", schema.first_error(missing, s)) + def test_question_holds_and_restatement_fields(self): doc = json.loads((FIXTURES / "questions.json").read_text(encoding="utf-8")) q = doc["questions"][0] diff --git a/plugins/planning/surface/test_server.py b/plugins/planning/surface/test_server.py index 1cd19b5d16..1b918312c1 100644 --- a/plugins/planning/surface/test_server.py +++ b/plugins/planning/surface/test_server.py @@ -1464,6 +1464,153 @@ def test_5_a_repeated_confirm_of_the_same_rev_returns_the_existing_seq(self): self.assertEqual(self.state()["responses"]["events"], after["events"]) +class TestAcceptAudit(WaitCase): + """The `accept-audit` event: one post accepts the listed eligible questions, refuses the rest.""" + + @classmethod + def prepare(cls): + seed_questions( + cls.dir, + question("A"), + question("B", dependsOn=["A"]), + question("C"), + question("D", archived={"why": "off path", "at": "t"}), + question("E", recommendation=""), + question("F", waiting=True), + ) + + def audit(self, *items, alt="1"): + return self.post( + { + "kind": "accept-audit", + "alt": alt, + "items": [{"id": i, "contentRev": r} for i, r in items], + } + ) + + def events(self): + return self.state()["responses"]["events"] + + def test_1_a_malformed_event_is_400_and_writes_nothing(self): + item = {"id": "A", "contentRev": 0} + for body in ( + {"kind": "accept-audit", "items": [item]}, + {"kind": "accept-audit", "alt": " ", "items": [item]}, + {"kind": "accept-audit", "alt": "1"}, + {"kind": "accept-audit", "alt": "1", "items": []}, + {"kind": "accept-audit", "alt": "1", "items": ["A"]}, + {"kind": "accept-audit", "alt": "1", "items": [{"id": "A"}]}, + {"kind": "accept-audit", "alt": "1", "items": [{**item, "extra": 1}]}, + { + "kind": "accept-audit", + "alt": "1", + "items": [{"id": "A", "contentRev": True}], + }, + {"kind": "accept-audit", "alt": "1", "items": [{"id": 7, "contentRev": 0}]}, + {"kind": "accept-audit", "alt": "1", "items": [item, item]}, + ): + code, data = self.post(body) + self.assertEqual(code, 400, (body, data)) + self.assertEqual(self.events(), []) + + def test_2_the_accepted_event_lists_its_items_and_fans_out_one_accept_each(self): + code, data = self.audit(("A", 0), ("C", 0)) + self.assertEqual(code, 200, data) + self.assertEqual( + (data["seq"], data["accepted"], data["skipped"]), (1, ["A", "C"], []) + ) + head, *accepts = self.events() + self.assertEqual( + {k: head[k] for k in ("seq", "id", "kind", "alt", "items")}, + { + "seq": 1, + "id": None, + "kind": "accept-audit", + "alt": "1", + "items": [ + {"id": "A", "contentRev": 0}, + {"id": "C", "contentRev": 0}, + ], + }, + ) + self.assertEqual( + [(e["seq"], e["id"], e["kind"], e["auditSeq"]) for e in accepts], + [(2, "A", "accept", 1), (3, "C", "accept", 1)], + ) + responses = self.state()["responses"]["responses"] + self.assertEqual( + (responses["A"]["decision"], responses["A"]["seq"]), ("accept", 2) + ) + self.assertEqual(responses["C"]["seq"], 3) + + def test_3_a_stale_or_ineligible_question_is_skipped_and_left_alone(self): + # Claude revised C after the user opened it. + path = self.dir / "questions.json" + doc = json.loads(path.read_text(encoding="utf-8")) + next(q for q in doc["questions"] if q["id"] == "C")["contentRev"] = 5 + path.write_text(json.dumps(doc), encoding="utf-8") + before = self.state()["responses"]["responses"] + code, data = self.audit( + ("B", 0), ("C", 2), ("D", 0), ("E", 0), ("F", 0), ("X", 0) + ) + self.assertEqual(code, 200, data) + self.assertEqual(data["accepted"], ["B"]) + self.assertEqual( + data["skipped"], + [ + {"id": "C", "reason": "changed"}, + {"id": "D", "reason": "ineligible"}, + {"id": "E", "reason": "ineligible"}, + {"id": "F", "reason": "ineligible"}, + {"id": "X", "reason": "ineligible"}, + ], + ) + after = self.state()["responses"]["responses"] + self.assertEqual({k: v for k, v in after.items() if k != "B"}, before) + self.assertEqual(self.events()[-2]["items"], [{"id": "B", "contentRev": 0}]) + + def test_4_a_prerequisite_decided_only_in_the_same_batch_does_not_count(self): + path = self.dir / "questions.json" + doc = json.loads(path.read_text(encoding="utf-8")) + doc["questions"].append(question("G", dependsOn=["H"])) + doc["questions"].append(question("H")) + path.write_text(json.dumps(doc), encoding="utf-8") + code, data = self.audit(("G", 0), ("H", 0)) + self.assertEqual((code, data["accepted"]), (200, ["H"]), data) + self.assertEqual(data["skipped"], [{"id": "G", "reason": "ineligible"}]) + + def test_5_nothing_accepted_is_409_and_writes_nothing(self): + n = len(self.events()) + code, data = self.audit(("E", 0), ("X", 0)) + self.assertEqual(code, 409, data) + self.assertEqual(data["error"], "nothing accepted") + self.assertEqual(len(data["skipped"]), 2) + self.assertEqual(len(self.events()), n) + + def test_6_content_rev_counts_the_fanned_out_accept_and_undo_restores(self): + code, data = self.post({"id": "A", "kind": "accept", "contentRev": 1}) + self.assertEqual(code, 200, data) + code, data = self.audit(("A", 1)) + self.assertEqual( + (code, data["skipped"]), (409, [{"id": "A", "reason": "changed"}]) + ) + undo = self.events()[-1]["seq"] + code, data = self.post({"kind": "undo", "id": "A", "undoSeq": undo}) + self.assertEqual(code, 200, data) + # The audit's own accept of A is live again, as it was before the plain accept. + self.assertEqual(self.state()["responses"]["responses"]["A"]["seq"], 2) + + def test_7_the_log_rebuilds_and_validates(self): + from server import rebuild_responses + + r = self.state()["responses"] + responses, history = rebuild_responses(r["events"]) + self.assertEqual(responses, r["responses"]) + self.assertEqual(history, r["history"]) + rc, out = self.rp("validate") + self.assertEqual(rc, 0, out) + + class TestConfirmUnderstandingNeedsARestatement(WaitCase): @classmethod def prepare(cls): From a91cae7ca4bef51a0e8edec0f8102758a705e1a5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:58:32 -0400 Subject: [PATCH 2/7] feat(planning): add Accept all and have agents check them to the interview page A round section with opened recommended questions gets a second button beside Accept all. Its dialog says agents will check the accepted answers and commitments stay unconfirmed. Confirming posts one accept-audit event with the round and the id and contentRev pairs, toasts the server's accepted and skipped result, and refreshes. UI checks cover the button's presence and absence and the single event. Refs #5472 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/index.html | 22 ++++++++++++++++++---- plugins/planning/surface/tests/ui_b.js | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index c573fbac3b..30f4da62b3 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -689,7 +689,8 @@

Interview

const elig = s.items.filter(q => op.has(q.id) && eligible(q)), aa = s.gid ? 'data-acceptall="' + esc(s.gid) + '"' : s.round ? 'data-acceptround="' + esc(s.round) + '"' : ""; html += '
' + '
" + - (elig.length && aa ? '" : "") + "
" + + (elig.length && aa ? '" : "") + + (elig.length && s.round && !s.gid ? '' : "") + "
" + (s.lock.length ? '
Locked: opens after ' + s.lock.map(g => esc(groupTitle(g))).join(", ") + "
" : "") + (s.hint ? '
' + esc(s.hint) + "
" : "") + '
    ' + (items.length ? items.map(q => "
  • " + railItem(q, s, carry, fresh) + "
  • ").join("") : '
  • None.
  • ') + "
"; @@ -1527,7 +1528,7 @@

Interview

const base = lsGet("openedRev", {})[q.id]; return typeof base === "number" ? crev(q) - (q.contentRev || 0) + base : crev(q); } - function acceptAllDialog(pool, where){ // one accept event per eligible question, each carrying its note + function acceptAllDialog(pool, where, audit){ // one accept event per eligible question, each carrying its note; audit (a round id): one accept-audit event instead const op = opened(), elig = pool.filter(q => op.has(q.id) && eligible(q)).sort(byIdCmp); const challenged = elig.filter(q => /^\s*Challenge:/m.test(carried(q))), items = elig.filter(q => !challenged.includes(q)); const unopened = pool.filter(q => !op.has(q.id) && eligible(q)).length; @@ -1535,8 +1536,8 @@

Interview

// A question Claude revised after the user last opened it goes with that older revision, so // the server refuses it and the toast names it. const snaps = items.map(q => ({id: q.id, rev: openedRev(q), text: carried(q)})); - $("dlgTitle").textContent = "Accept " + items.length + " in " + where + "?"; - $("dlgBody").innerHTML = "

Accepts the recommendation on each open question you have opened here, with the note you typed on it. Commitments stay unconfirmed.

    " + + $("dlgTitle").textContent = "Accept " + items.length + " in " + where + (audit ? " and have agents check them?" : "?"); + $("dlgBody").innerHTML = "

    Accepts the recommendation on each open question you have opened here" + (audit ? ". Agents will then check the accepted answers. " : ", with the note you typed on it. ") + "Commitments stay unconfirmed.

      " + snaps.map(s => { const q = Q[s.id]; return "
    • " + esc(q.id) + " " + esc(q.short || q.title) + ": " + esc(trunc(firstLine(q.recommendation), 110)) + (s.text ? '
      Note: ' + esc(trunc(s.text, 160)) + "
      " : "") + "
    • "; }).join("") + "
    " + (challenged.length ? '

    Left out, their note challenges a commitment: ' + challenged.map(q => esc(q.id)).join(", ") + ".

    " : "") + (unopened ? '

    Left out: ' + unopened + " open " + (unopened === 1 ? "question" : "questions") + " you have not opened.

    " : "") + @@ -1547,6 +1548,18 @@

    Interview

    dlg.close(); if (S.busy) return; if (wrapFreeze() > 0) { toast("Wrapping up. Saves resume once Claude handles the wrap-up.", true); return; } S.busy = true; let ok = 0; const skipped = []; + if (audit) { + try { + const {res, data} = await post({kind: "accept-audit", alt: audit, items: snaps.map(s => ({id: s.id, contentRev: s.rev}))}); + S.busy = false; + const sk = (data.skipped || []).map(x => x.id); + if (res.ok) snaps.forEach(s => { if (!sk.includes(s.id)) lsSet("draft:" + s.id, null); }); + await refresh().catch(() => {}); + toast(res.ok ? "Accepted " + (data.accepted || []).length + "." + (sk.length ? " Skipped " + sk.join(", ") + ": changed or not eligible." : "") : "Not accepted (" + (data.error || res.status) + ")." + (sk.length ? " Skipped " + sk.join(", ") + "." : ""), !res.ok || sk.length > 0); + renderAll(); + } catch (e) { S.busy = false; if (!e.quiet) toast("Not sent (" + e.message + ").", true); } + return; + } for (const s of snaps) { try { const {res} = await post({id: s.id, kind: "accept", alt: null, text: s.text, contentRev: s.rev}); if (res.ok) { ok++; lsSet("draft:" + s.id, null); } else skipped.push(s.id); } catch (e) { if (e.quiet) return; skipped.push(s.id); } @@ -1653,6 +1666,7 @@

    Interview

    if (t.closest("#keysClose")) { $("keysDlg").close(); return; } if (t.closest("[data-sum]")) { select(SUMMARY, {animate: true}); return; } const ref = t.closest(".ref[data-q], .qbtn"); if (ref) { select(ref.dataset.q, {animate: true}); return; } + const au = t.closest("[data-auditround]"); if (au) { const s = sections().secs.find(x => x.round === au.dataset.auditround); if (s) acceptAllDialog(s.items, s.title, s.round); return; } const aa = t.closest("[data-acceptall]"); if (aa) { acceptAllDialog(S.doc.questions.filter(q => q.group === aa.dataset.acceptall), groupTitle(aa.dataset.acceptall)); return; } const sh = t.closest(".sec-h"); if (sh) { const sec = sh.closest(".sec"), c = sec.dataset.collapsed !== "true"; sec.dataset.collapsed = String(c); sh.setAttribute("aria-expanded", String(!c)); lsSet("col:" + sec.dataset.key, {c, d: sec.dataset.dflt === "true"}); return; } diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index 4c63bac19a..7965f40648 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -39,6 +39,28 @@ async page => { const ev2 = await events(); ok("Accept all saved an accept for N3", ev2[ev2.length - 1].id === "N3" && ev2[ev2.length - 1].kind === "accept"); + // Accept all and have agents check them: per round, one accept-audit event + await page.click('.seg [data-view="rounds"]'); await page.waitForTimeout(300); + const secCount = (await page.$$('.sec[data-key^="r:"]')).length; + const auditBtns = await page.$$('[data-auditround]'); + ok("audit button only on rounds with opened recommended questions", auditBtns.length < secCount, auditBtns.length + " of " + secCount); + ok("no audit button while no round has an open recommended question", auditBtns.length === 0); + await page.evaluate(() => document.querySelector(".qbtn[data-q=\"N3\"]").click()); await page.waitForTimeout(200); + await page.click("[data-act=\"reopen\"]"); await page.waitForTimeout(700); + const ab = await page.$("[data-auditround]"); + const auditRound = ab ? await ab.getAttribute("data-auditround") : ""; + ok("audit button appears for a round with an opened recommended question", !!ab && /agents check/.test(ab ? await ab.textContent() : "")); + if (ab) { + await ab.click(); await page.waitForTimeout(200); + ok("audit dialog says agents check and commitments stay unconfirmed", await page.evaluate(() => document.getElementById("dlg").open && /Agents will then check/.test(document.getElementById("dlgBody").innerText) && /Commitments stay unconfirmed/.test(document.getElementById("dlgBody").innerText) && document.getElementById("dlgBody").querySelectorAll("li").length > 0)); + const na = (await events()).filter(e => e.kind === "accept-audit").length; + await page.click("#dlgOk"); await page.waitForTimeout(900); + const aud = (await events()).filter(e => e.kind === "accept-audit"); + ok("audit posts exactly one accept-audit event with the round and items", aud.length === na + 1 && aud[aud.length - 1].alt === auditRound && aud[aud.length - 1].items.length > 0 && aud[aud.length - 1].items.every(i => i.id && typeof i.contentRev === "number"), JSON.stringify(aud[aud.length - 1])); + ok("audit toasts the accepted result", /Accepted \d/.test(await page.textContent("#toast")), await page.textContent("#toast")); + } + await page.click('.seg [data-view="groups"]'); await page.waitForTimeout(200); + // completion screen await page.keyboard.press("w"); await page.waitForTimeout(300); ok("completion screen when all are answered", /All answered/.test(await page.textContent(".done-h")), await page.textContent(".done-h")); From a25f1b11bf4511c12eac6dd6e02eccc8d4c4f24d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:04:43 -0400 Subject: [PATCH 3/7] feat(planning): route accept-audit events to audit-answers and release 0.48.0 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/.claude-plugin/plugin.json | 2 +- plugins/planning/CHANGELOG.md | 6 ++++++ plugins/planning/skills/interview/context/surface.md | 3 +++ plugins/planning/tests/interview-defenses.test.sh | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index db703fc204..aa6af30bfc 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.4", + "version": "0.48.0", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 7152d15066..29933b40ff 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.48.0] - 2026-09-29 + +### Added + +- **The interview page can accept a round's recommendations and hand the round to `/planning:audit-answers`.** "Accept all and have agents check them" posts one `accept-audit` event; the server records one accept per eligible question, each marked pending agent validation in the exports, and `context/surface.md` routes the event to the audit skill. Per-question undo still works, and the page holds no validation logic ([#5472](https://github.com/melodic-software/claude-code-plugins/issues/5472)). + ## [0.47.4] - 2026-09-29 ### Fixed diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 9e170ec297..5aaeb6592f 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -130,8 +130,11 @@ When the work returns, clear both (`wait` with `"clear": true`, `set-status` wit | `undo` | question, `undoSeq` | withdraws `undoSeq` | Drop that decision from the ledger; `handle` both seqs | | `wrapup` | none | no | Run [Wrap-up](#wrap-up), then `handle` | | `confirm` | question, `alt` is the commitment index | no; ticks one commitment | `handle` | +| `accept-audit` | none; `alt` is the round id, `items` lists the accepted questions | yes, once per listed question (each has its own `accept` event carrying `auditSeq`) | Record each accepted question, then run `/planning:audit-answers` on the filled ledger. Reply to none of the accepts; the audit returns only the doubtful ones as human questions | | `confirm-understanding` | none; `alt` is `confirm` or `off`, `contentRev` is the restatement `rev` | no | `confirm`: the gate passed, `handle`. `off`: `note-reply` to its `text` with its `seq`, see [Confirmation gate](#confirmation-gate) | +"Accept all and have agents check them" arrives as one `accept-audit` event plus its accepts; the page holds no validation logic, so the skill routes the round to `/planning:audit-answers`. + An accept whose note conditions the acceptance ("before we lock it in") is recorded as hedged, headline only, per SKILL.md "A hedged reply resolves only the headline". Accept all, per group and per round section in the Rounds view, arrives as one `accept` event per question, each with its own note `text`, usually in one wake; treat each as a single accept. 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. diff --git a/plugins/planning/tests/interview-defenses.test.sh b/plugins/planning/tests/interview-defenses.test.sh index 83aaf41051..5d79eebf21 100755 --- a/plugins/planning/tests/interview-defenses.test.sh +++ b/plugins/planning/tests/interview-defenses.test.sh @@ -863,6 +863,10 @@ pin "audit-answers: a hedged row never closes on CONFIRMED" "$AUDIT" \ "never closes on a CONFIRMED verdict" pin "audit-answers: a free-text row is validated and flagged" "$AUDIT" \ "A \`free-text:\` row is validated like any answer and flagged" +pin "surface.md routes an accept-audit event to audit-answers" "$PLUGIN_DIR/skills/interview/context/surface.md" \ + "the skill routes the round to \`/planning:audit-answers\`" +pin "surface.md lists the accept-audit event kind" "$PLUGIN_DIR/skills/interview/context/surface.md" \ + "| \`accept-audit\` |" # A8. Whole-line pins — the five lines that ARE the STOP-on-gap defense. These catch the # neutralize-in-place edit the phrase pins above cannot: a qualifier appended to any of From d67fc06acd0f956ceb9bb5ecbd7e104990e157db Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:24:56 -0400 Subject: [PATCH 4/7] fix(planning): leave noted questions out of Accept all and have agents check them The accept-audit event carries no per-question text, so the dialog showed a typed note that was never sent and then cleared its draft. Questions carrying a note are now left out of the audit dialog, like challenged ones, and can be accepted one at a time to keep the note. surface.md says to handle the accept-audit seq and its accepts, and the dead accept-audit kind is removed from loose_ends. Co-Authored-By: Claude Opus 5.5 --- plugins/planning/CHANGELOG.md | 2 +- plugins/planning/skills/interview/context/surface.md | 6 +++--- plugins/planning/surface/exporters.py | 5 ++--- plugins/planning/surface/index.html | 6 +++--- plugins/planning/surface/tests/ui_b.js | 6 ++++++ 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 29933b40ff..e4f85cb95d 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to the `planning` plugin are documented here. Format follows ### Added -- **The interview page can accept a round's recommendations and hand the round to `/planning:audit-answers`.** "Accept all and have agents check them" posts one `accept-audit` event; the server records one accept per eligible question, each marked pending agent validation in the exports, and `context/surface.md` routes the event to the audit skill. Per-question undo still works, and the page holds no validation logic ([#5472](https://github.com/melodic-software/claude-code-plugins/issues/5472)). +- **The interview page can accept a round's recommendations and hand the round to `/planning:audit-answers`.** "Accept all and have agents check them" posts one `accept-audit` event; the server records one accept per eligible question, each marked pending agent validation in the exports, and `context/surface.md` routes the event to the audit skill. A question with a typed note is left out, since the event carries no notes. Per-question undo still works, and the page holds no validation logic ([#5472](https://github.com/melodic-software/claude-code-plugins/issues/5472)). ## [0.47.4] - 2026-09-29 diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 5aaeb6592f..6e3fc4e2d2 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -61,7 +61,7 @@ When the first wake prompts for permission, offer the user one allow rule per co | `op` | Fields | Use | |---|---|---| -| `handle` | `seqs` | Plain accepts (no new note text), `reopen`, `confirm`, `confirm-understanding` with `confirm`, `undo`, `wrapup`: no reply (R9) | +| `handle` | `seqs` | Plain accepts (no new note text), `accept-audit` with its fanned-out accepts, `reopen`, `confirm`, `confirm-understanding` with `confirm`, `undo`, `wrapup`: no reply (R9) | | `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 | @@ -130,10 +130,10 @@ When the work returns, clear both (`wait` with `"clear": true`, `set-status` wit | `undo` | question, `undoSeq` | withdraws `undoSeq` | Drop that decision from the ledger; `handle` both seqs | | `wrapup` | none | no | Run [Wrap-up](#wrap-up), then `handle` | | `confirm` | question, `alt` is the commitment index | no; ticks one commitment | `handle` | -| `accept-audit` | none; `alt` is the round id, `items` lists the accepted questions | yes, once per listed question (each has its own `accept` event carrying `auditSeq`) | Record each accepted question, then run `/planning:audit-answers` on the filled ledger. Reply to none of the accepts; the audit returns only the doubtful ones as human questions | +| `accept-audit` | none; `alt` is the round id, `items` lists the accepted questions | yes, once per listed question (each has its own `accept` event carrying `auditSeq`) | Record each accepted question, `handle` the `accept-audit` seq and every fanned-out accept seq with no reply, then run `/planning:audit-answers` on the filled ledger. The audit returns only the doubtful ones as human questions | | `confirm-understanding` | none; `alt` is `confirm` or `off`, `contentRev` is the restatement `rev` | no | `confirm`: the gate passed, `handle`. `off`: `note-reply` to its `text` with its `seq`, see [Confirmation gate](#confirmation-gate) | -"Accept all and have agents check them" arrives as one `accept-audit` event plus its accepts; the page holds no validation logic, so the skill routes the round to `/planning:audit-answers`. +"Accept all and have agents check them" arrives as one `accept-audit` event plus its accepts; the page holds no validation logic, so the skill routes the round to `/planning:audit-answers`. The page leaves a question that carries a note out of that event, so every fanned-out accept is plain. An accept whose note conditions the acceptance ("before we lock it in") is recorded as hedged, headline only, per SKILL.md "A hedged reply resolves only the headline". Accept all, per group and per round section in the Rounds view, arrives as one `accept` event per question, each with its own note `text`, usually in one wake; treat each as a single accept. diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index 69c88f164a..9e12cbfab6 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -778,11 +778,10 @@ def loose_ends(rows, doc, resp): if ( text and not MID_SENTENCE.search(text) - and e.get("kind") - in ("own", "note", "ask", "accept", "alt", "defer", "accept-audit") + and e.get("kind") in ("own", "note", "ask", "accept", "alt", "defer") ): ends.append( - f"#{e['seq']} {e.get('id') or e['kind']} ends mid-sentence: {clean(text)}" + f"#{e['seq']} {e.get('id') or 'note'} ends mid-sentence: {clean(text)}" ) if e.get("kind") in ("ask", "rephrase", "note") and e["seq"] not in replied: ends.append( diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 15a6a58137..0c43b32123 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -1529,9 +1529,9 @@

    Interview

    const base = lsGet("openedRev", {})[q.id]; return typeof base === "number" ? crev(q) - (q.contentRev || 0) + base : crev(q); } - function acceptAllDialog(pool, where, audit){ // one accept event per eligible question, each carrying its note; audit (a round id): one accept-audit event instead + function acceptAllDialog(pool, where, audit){ // one accept event per eligible question, each carrying its note; audit (a round id): one accept-audit event instead, which carries no notes, so a question with a note is left out const op = opened(), elig = pool.filter(q => op.has(q.id) && eligible(q)).sort(byIdCmp); - const challenged = elig.filter(q => /^\s*Challenge:/m.test(carried(q))), items = elig.filter(q => !challenged.includes(q)); + const held = elig.filter(q => audit ? carried(q) : /^\s*Challenge:/m.test(carried(q))), items = elig.filter(q => !held.includes(q)); const unopened = pool.filter(q => !op.has(q.id) && eligible(q)).length; if (!elig.length) return; // A question Claude revised after the user last opened it goes with that older revision, so @@ -1540,7 +1540,7 @@

    Interview

    $("dlgTitle").textContent = "Accept " + items.length + " in " + where + (audit ? " and have agents check them?" : "?"); $("dlgBody").innerHTML = "

    Accepts the recommendation on each open question you have opened here" + (audit ? ". Agents will then check the accepted answers. " : ", with the note you typed on it. ") + "Commitments stay unconfirmed.

      " + snaps.map(s => { const q = Q[s.id]; return "
    • " + esc(q.id) + " " + esc(q.short || q.title) + ": " + esc(trunc(firstLine(q.recommendation), 110)) + (s.text ? '
      Note: ' + esc(trunc(s.text, 160)) + "
      " : "") + "
    • "; }).join("") + "
    " + - (challenged.length ? '

    Left out, their note challenges a commitment: ' + challenged.map(q => esc(q.id)).join(", ") + ".

    " : "") + + (held.length ? '

    Left out, ' + (audit ? "they carry a note, which this button does not send. Accept them one at a time: " : "their note challenges a commitment: ") + held.map(q => esc(q.id)).join(", ") + ".

    " : "") + (unopened ? '

    Left out: ' + unopened + " open " + (unopened === 1 ? "question" : "questions") + " you have not opened.

    " : "") + "

    This set cannot be undone as one step: Reopen each question to change it.

    "; $("dlgOk").disabled = !items.length; diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index 7965f40648..2d59695c40 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -51,6 +51,12 @@ async page => { const auditRound = ab ? await ab.getAttribute("data-auditround") : ""; ok("audit button appears for a round with an opened recommended question", !!ab && /agents check/.test(ab ? await ab.textContent() : "")); if (ab) { + // A question carrying a typed note is left out of the audit dialog, which sends no notes + await page.fill("#note", "before we lock it in"); await page.waitForTimeout(200); + await ab.click(); await page.waitForTimeout(200); + ok("audit dialog leaves out a question with a typed note and never shows the note", await page.evaluate(() => { const b = document.getElementById("dlgBody"); const lo = b.querySelector(".left-out"); return document.getElementById("dlg").open && !!lo && /they carry a note/.test(lo.innerText) && /N3/.test(lo.innerText) && ![...b.querySelectorAll("li b")].some(x => x.textContent === "N3") && !/before we lock it in/.test(b.innerText); })); + await page.click("#dlgCancel"); await page.waitForTimeout(200); + await page.fill("#note", ""); await page.waitForTimeout(200); await ab.click(); await page.waitForTimeout(200); ok("audit dialog says agents check and commitments stay unconfirmed", await page.evaluate(() => document.getElementById("dlg").open && /Agents will then check/.test(document.getElementById("dlgBody").innerText) && /Commitments stay unconfirmed/.test(document.getElementById("dlgBody").innerText) && document.getElementById("dlgBody").querySelectorAll("li").length > 0)); const na = (await events()).filter(e => e.kind === "accept-audit").length; From 70a728d15a54e9a608476d68c2f10b13b45beae7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:38:59 -0400 Subject: [PATCH 5/7] fix(planning): remove leftover conflict marker in CHANGELOG Co-Authored-By: Claude Sonnet 5.5 --- plugins/planning/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 74a582b7bb..4dd22f4fba 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -8,12 +8,12 @@ All notable changes to the `planning` plugin are documented here. Format follows ### Added - **The interview page can accept a round's recommendations and hand the round to `/planning:audit-answers`.** "Accept all and have agents check them" posts one `accept-audit` event; the server records one accept per eligible question, each marked pending agent validation in the exports, and `context/surface.md` routes the event to the audit skill. A question with a typed note is left out, since the event carries no notes. Per-question undo still works, and the page holds no validation logic ([#5472](https://github.com/melodic-software/claude-code-plugins/issues/5472)). + ## [0.47.5] - 2026-09-29 ### Fixed - **The interview wording lint no longer flags a release label such as `V1` as a bare question id.** `round.py` skips `V` followed by digits, so a coined id such as `AC21` still warns, and the rule in `context/surface.md` states the exception ([#5458](https://github.com/melodic-software/claude-code-plugins/issues/5458)). ->>>>>>> origin/main ## [0.47.4] - 2026-09-29 From e266b5c7a0382140ae02c50b2a2c30f19e935024 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:29:47 -0400 Subject: [PATCH 6/7] fix(planning): keep the audit-answers Step 1 section unchanged State the caller-named id scope under Preconditions so the pinned Step 1 section stays byte-identical. Co-Authored-By: Claude Opus 5.5 --- plugins/planning/skills/audit-answers/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/planning/skills/audit-answers/SKILL.md b/plugins/planning/skills/audit-answers/SKILL.md index a71af5d727..40b33457c3 100644 --- a/plugins/planning/skills/audit-answers/SKILL.md +++ b/plugins/planning/skills/audit-answers/SKILL.md @@ -44,12 +44,12 @@ A completed `/planning:interview` for the topic. The skill validates whatever an The answer set to validate is the resolved decisions in whichever of these exists: the ledger when present, else the Brief's decisions, else the general summary. A **Brief-only** interview (an `auto`/`lock` session with no checklist) and a **summary-only** general interview are both valid inputs, not a reason to stop. Derive `` from `$ARGUMENTS` or the current branch (kebab-case, ≤40 chars; shared with `/planning:interview`); resolve the slices per the topic-docs binding [`${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md`](${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md). If the topic has NO persisted interview output at all, STOP with a message pointing at `/planning:interview`. There is nothing to validate. If output exists but has open consequential branches, Step 1 fills them under the never-auto floor before validating. +When the caller names the question ids to audit (the interview page's `accept-audit` event lists them), the answer set is exactly those answers: do not fill or validate any other open branch, and leave the rest of the interview open. + ## The validation loop ### Step 1. Assemble the answer set, holding the never-auto floor -When the caller names the question ids to audit (the interview page's `accept-audit` event lists them), the answer set is exactly those answers: do not fill or validate any other open branch, and leave the rest of the interview open. - Validation needs a complete answer set. If the interview is already fully answered, every consequential branch resolved, whether by hand or by a prior accept-all, validate it as it stands. If open branches remain, drive the interview's accept-recommended path to fill them into a **working, in-session** set of *provisional* answers. For each open branch, take the orchestrator's recommended answer, NOT persisted to the tracked Brief / ledger / summary yet (an auto-accepted answer is unvalidated, and writing it into the contract before validation is exactly what this skill exists to prevent). **The mechanical never-auto floor is held out of any auto-accept, and no validator ever resolves it:** - a Deferred question tagged **`USER-RESERVED`** stays **deferred**. It is a carry-forward item whose arbiter re-confirms at the `/planning:plan` approval gate *with plan-time context*, so it is not auto-accepted, not validated, and **not turned into an audit question here**; it passes through untouched, arbiter tag intact. From 943cd732d5a20fd36b0dc08cd605a782c222df79 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:55:59 -0400 Subject: [PATCH 7/7] fix(planning): drop the duplicated version key left by the merge Co-Authored-By: Claude Opus 5.5 --- plugins/planning/.claude-plugin/plugin.json | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index b6fd13d121..aa6af30bfc 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -2,7 +2,6 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "planning", "version": "0.48.0", - "version": "0.47.9", "userConfig": { "surface": { "type": "string",