Skip to content
Open
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.11",
"version": "0.48.0",
"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.48.0] - 2026-09-29

### Added

- **The interview page can accept a round's recommendations and hand the round to `/planning:audit-answers`, limited to the accepted questions.** "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.11] - 2026-09-30

### Added
Expand Down
2 changes: 2 additions & 0 deletions plugins/planning/skills/audit-answers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ 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 `<topic-slug>` 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
Expand Down
5 changes: 4 additions & 1 deletion plugins/planning/skills/interview/context/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ When the first wake prompts for permission, offer the user one allow rule, `Bash

| `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 |
Expand Down Expand Up @@ -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, `handle` the `accept-audit` seq and every fanned-out accept seq with no reply, then run `/planning:audit-answers` on the event's `items` only, so questions outside the round stay open. 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`. 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.

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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Every command needs `--dir '<data_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": <current>}`. 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": <accept-audit 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": <current>}`. 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

Expand Down
13 changes: 13 additions & 0 deletions plugins/planning/surface/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*(```|~~~)")
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 20 additions & 6 deletions plugins/planning/surface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,8 @@ <h1 id="title">Interview</h1>
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 += '<div class="sec' + (s.pin ? " pin" : "") + (s.lock.length ? " locked" : "") + (s.fresh ? " fresh" : "") + '" data-key="' + esc(s.key) + '" data-collapsed="' + s.collapsed + '" data-dflt="' + s.done + '">' +
'<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>" +
(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>" : "") +
(elig.length && s.round && !s.gid ? '<button class="tbtn small" data-auditround="' + esc(s.round) + '" data-submit="1" title="Accept the recommendation on the open questions you have opened here and have agents check the answers">Accept all and have agents check them</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">' + (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 Expand Up @@ -1533,18 +1534,18 @@ <h1 id="title">Interview</h1>
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, 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
// 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 = "<p>Accepts the recommendation on each open question you have opened here, with the note you typed on it. Commitments stay unconfirmed.</p><ul>" +
$("dlgTitle").textContent = "Accept " + items.length + " in " + where + (audit ? " and have agents check them?" : "?");
$("dlgBody").innerHTML = "<p>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.</p><ul>" +
snaps.map(s => { const q = Q[s.id]; return "<li><b>" + esc(q.id) + "</b> " + esc(q.short || q.title) + ": " + esc(trunc(firstLine(q.recommendation), 110)) + (s.text ? '<div class="kbd">Note: ' + esc(trunc(s.text, 160)) + "</div>" : "") + "</li>"; }).join("") + "</ul>" +
(challenged.length ? '<p class="left-out">Left out, their note challenges a commitment: ' + challenged.map(q => esc(q.id)).join(", ") + ".</p>" : "") +
(held.length ? '<p class="left-out">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(", ") + ".</p>" : "") +
(unopened ? '<p class="left-out">Left out: ' + unopened + " open " + (unopened === 1 ? "question" : "questions") + " you have not opened.</p>" : "") +
"<p>This set cannot be undone as one step: Reopen each question to change it.</p>";
$("dlgOk").disabled = !items.length;
Expand All @@ -1553,6 +1554,18 @@ <h1 id="title">Interview</h1>
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); }
Expand Down Expand Up @@ -1659,6 +1672,7 @@ <h1 id="title">Interview</h1>
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; }
Expand Down
Loading
Loading