diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index c120670020..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.11", + "version": "0.48.0", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 9b883987bc..8c2655ff87 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`, 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 diff --git a/plugins/planning/skills/audit-answers/SKILL.md b/plugins/planning/skills/audit-answers/SKILL.md index 0c6ee325f3..40b33457c3 100644 --- a/plugins/planning/skills/audit-answers/SKILL.md +++ b/plugins/planning/skills/audit-answers/SKILL.md @@ -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 `` 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 diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 6035b85765..7da0f472e6 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, `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 | @@ -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. diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 4e3d47ee24..3c79955958 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -70,7 +70,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..9e12cbfab6 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. diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index 7fb8f9d0f1..34229ff71f 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -695,7 +695,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 ? '
' + (s.hintStale ? 'Stale ' : "") + esc(s.hint) + "
" : "") + '
    ' + (items.length ? items.map(q => "
  • " + railItem(q, s, carry, fresh) + "
  • ").join("") : '
  • None.
  • ') + "
"; @@ -1533,18 +1534,18 @@

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, 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 = "

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(", ") + ".

    " : "") + + (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; @@ -1553,6 +1554,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); } @@ -1659,6 +1672,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/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): diff --git a/plugins/planning/surface/tests/ui_b.js b/plugins/planning/surface/tests/ui_b.js index 4c63bac19a..2d59695c40 100644 --- a/plugins/planning/surface/tests/ui_b.js +++ b/plugins/planning/surface/tests/ui_b.js @@ -39,6 +39,34 @@ 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) { + // 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; + 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")); diff --git a/plugins/planning/tests/interview-defenses.test.sh b/plugins/planning/tests/interview-defenses.test.sh index 7070d3d6d3..b9c07a921c 100755 --- a/plugins/planning/tests/interview-defenses.test.sh +++ b/plugins/planning/tests/interview-defenses.test.sh @@ -866,6 +866,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