diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index bd327dbaf2..bfe365b93d 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.52.2", + "version": "0.53.0", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 0c6672199f..b736ba3564 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.53.0] - 2026-09-30 + +### Added + +- **The interview surface records a hedged decision, and an accepted or hedged row with an unticked commitment exports as open.** The page has a Hedged choice that takes a required condition; it exports as `answer:: hedged: ` with the condition in `note::` and imports back as `hedged`. A row whose counting decision is an accept or a hedge while any commitment is unticked, including after a revise replaces the commitment list, now grades `open` in the ledger, so `check-open-questions.sh` fails it under `lock` instead of passing it with the commitments only named as risks in the Brief ([#5471](https://github.com/melodic-software/claude-code-plugins/issues/5471)). + ## [0.52.2] - 2026-09-30 ### Fixed diff --git a/plugins/planning/skills/audit-answers/SKILL.md b/plugins/planning/skills/audit-answers/SKILL.md index fc8d7f57ba..3fa9d82767 100644 --- a/plugins/planning/skills/audit-answers/SKILL.md +++ b/plugins/planning/skills/audit-answers/SKILL.md @@ -55,7 +55,7 @@ Validation needs a complete answer set. If the interview is already fully answer - 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. - a decision the interview's **auto-guard** class covers, a genuine user choice with real tradeoffs and no codebase answer, is held out of the auto-accept and routed to the human as a real question in the confirm round (Step 4). - a register row at **`superseded-by-plan`** (a plan change displaced the user's answer) is held out of the auto-accept, never validated into `answered`, and routed to the human as a real question showing both the proposed and the displaced answer. Only the user's reply to that row moves it. -- a register row whose resolution carries `hedged:` anywhere (including the page's `free-text: hedged:` export) is validated, but it never closes on a CONFIRMED verdict: it is routed to the human in the Step 4 confirm round whatever the validators return. Its `open` commitment rows are held out of the auto-accept like any other floor item. +- a register row whose resolution carries `hedged:` anywhere (including the page's `hedged:` answer and the legacy `free-text: hedged:` export) is validated, but it never closes on a CONFIRMED verdict: it is routed to the human in the Step 4 confirm round whatever the validators return. Its `open` commitment rows are held out of the auto-accept like any other floor item. A `free-text:` row is validated like any answer and flagged in its verdict, so the human sees which answers were given in the user's own words rather than picked from the authored options. diff --git a/plugins/planning/skills/interview/context/loop.md b/plugins/planning/skills/interview/context/loop.md index d2a95d166a..aa67fd55fd 100644 --- a/plugins/planning/skills/interview/context/loop.md +++ b/plugins/planning/skills/interview/context/loop.md @@ -243,9 +243,9 @@ Fields: `Q | status | round | question | resolution`. Statuses: **Commitment rows.** A recommendation's `Commits you to:` parts (SKILL.md Stance "Relentless mode") are rows of their own, written `open` at ask-time and numbered after their headline, so the ids stay contiguous and the gate grades each one. The question field names the headline: `- Q6 | open | round 2 | (part of Q5) token scope for the review step |`. An explicit acceptance of the headline resolves each to `answered` with `accepted via Q5: `; choosing an alternative or rejecting the headline sets each to `withdrawn` with `pruned by Q5 = `. The read-only decision table gives each part its own row number, and the `me` decision tree gives each its own checkbox. Unattended, each part takes the ladder on its own: a codebase-resolvable headline does not make its parts resolvable, and a part that is the user's decision is `blocked` like any other row. -**Commitment parts on the page.** On the page surface a recommendation's parts are the question's `commits` entries ([`surface.md`](surface.md) R1 and R3), never rows of their own: `export-ledger` rewrites the register one row per page question at wrap-up. The page is stricter than the terminal rule: accepting the headline confirms only the parts the user ticks. An alternative withdraws the parts, so they do not reach the Brief; a defer's open row covers them. An `own` answer keeps its unticked parts, which reach the Brief as named risks through `export-brief`, as do an accept's; that over-counts an unhedged `own` answer, the safe direction. On its own, a terminal answer mirrored with `record-terminal` ticks none: after the user confirms parts in the terminal, tick them with a `confirm-commitments` op rather than leaving them unconfirmed. The register gate does not grade parts. Commitment rows from earlier terminal rounds become page questions of their own under `import-ledger`: resolve or withdraw each by hand when its headline is answered. When the page degrades to the terminal, register each `commits` entry of a still-open question as a commitment row before the next round. +**Commitment parts on the page.** On the page surface a recommendation's parts are the question's `commits` entries ([`surface.md`](surface.md) R1 and R3), never rows of their own: `export-ledger` rewrites the register one row per page question at wrap-up. The page is stricter than the terminal rule: accepting the headline confirms only the parts the user ticks, and an accepted or hedged row with an unticked part exports `open`, so the register gate holds it until each part is ticked or the decision changes. An alternative withdraws the parts, so they do not reach the Brief; a defer's open row covers them. An `own` answer keeps its unticked parts, which reach the Brief as named risks through `export-brief`, as do an accept's and a hedged row's; that over-counts an unhedged `own` answer, the safe direction. On its own, a terminal answer mirrored with `record-terminal` ticks none: after the user confirms parts in the terminal, tick them with a `confirm-commitments` op rather than leaving them unconfirmed. Commitment rows from earlier terminal rounds become page questions of their own under `import-ledger`: resolve or withdraw each by hand when its headline is answered. When the page degrades to the terminal, register each `commits` entry of a still-open question as a commitment row before the next round. -**Hedged flag.** A hedged reply (SKILL.md Stance "Relentless mode") resolves at most its headline, which resolves to `answered` with `hedged: `; its commitment rows stay `open` under the drift check. Downstream passes treat a row carrying `hedged:` with the same scrutiny as a `free-text:` one. On the page, mirror a hedged terminal reply with `record-terminal` as `own` with the text prefixed `hedged:`, never as `accept`, which records accept-all; it exports as `free-text: hedged: `. A hedged "Own answer" typed on the page gets the same echo on its wake and exports as plain `free-text: `. The Step 3 confirmation restate lists every row carrying `hedged:`, and every `free-text:` row whose answer is hedged, for explicit confirmation. +**Hedged flag.** A hedged reply (SKILL.md Stance "Relentless mode") resolves at most its headline, which resolves to `answered` with `hedged: `; its commitment rows stay `open` under the drift check. Downstream passes treat a row carrying `hedged:` with the same scrutiny as a `free-text:` one. On the page, mirror a hedged terminal reply with `record-terminal` as `hedged`, the condition in `text`, never as `accept`, which records accept-all; it exports as `hedged: ` with the condition in `note::`, and a legacy `free-text: hedged: ` row still imports as `own`. A hedged "Own answer" typed on the page gets the same echo on its wake and exports as plain `free-text: `. The Step 3 confirmation restate lists every row carrying `hedged:`, and every `free-text:` row whose answer is hedged, for explicit confirmation. ### Drift check: a reply that does not answer is not an answer diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index b6f4dd66f1..b5ec11587f 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -136,7 +136,7 @@ When the work returns, clear both (`wait` with `"clear": true`, `set-status` wit "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 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": the page's Hedged choice records it directly, and a terminal reply is mirrored with `record-terminal` as `hedged`, the condition in `text`. An accepted or hedged row with an unticked commitment exports `open`, so the register gate holds it until each commitment is ticked. 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/DEFERRED.md b/plugins/planning/surface/DEFERRED.md index 03d9bed619..6f3f79e69d 100644 --- a/plugins/planning/surface/DEFERRED.md +++ b/plugins/planning/surface/DEFERRED.md @@ -8,9 +8,7 @@ Reason for parking: the owner's scope guard, "we want these to be, in most cases | Entry | Decision | |---|---| -| 1. A `hedged` decision kind on the page: the skill records a conditional accept as hedged, and the page has no such state | Build: #5471, after #4611 | -| 2. The register gate grades a page export clean while commitments are unticked | Build: #5471, after #4611 | -| 3. Addendum wording lint: whether the coined-term check becomes a script check | Open: awaiting the owner | +| 1. Addendum wording lint: whether the coined-term check becomes a script check | Open: awaiting the owner | ## Deferred behaviors diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index 30b2f92752..4df189ea8f 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -19,19 +19,23 @@ proposal:: E(new) a plan's proposal and the answer it displaced; always a pair, was:: E(old) written on every row whose question the plan superseded answer:: E(answer) the decision that counts, on any status: `accepted: `, - `alt : ` (`alt ` for a key the question does not - list), `free-text: `, `deferred[: ]`, and on a - withdrawn row `archived: ` or `superseded by ` - note:: E(note) an accept's or an alternative's note; on a deferred or blocked + `hedged: `, `alt : ` (`alt ` for a key the + question does not list), `free-text: `, + `deferred[: ]`, and on a withdrawn row `archived: ` + or `superseded by ` + note:: E(note) an accept's or an alternative's note; a hedged answer's + condition, which it always has; on a deferred or blocked row, or a held row, whose answer is a bare `deferred`, the row's text as a ledger seeded it (written even when empty); on an open or superseded-by-plan row with no proposal, the - seeded text, but not beside a held row's accept or alternative, - whose note it is + seeded text, but not beside a held row's accept, hedged answer or + alternative, whose note it is aside:: E(aside) the newest decision a user hold set aside, when none counts; import restores it still set aside commitments:: M(c1)[; M(c2)...] every commitment in order, M() being `+` (confirmed) or `-` (unconfirmed) then E(text) +An accepted or hedged decision with an unconfirmed commitment exports `open`, keeping its answer, +note and commitments; import restores that decision with those commitments unconfirmed. A superseded-by-plan row keeps that status under a defer, whose answer carries it. A deferred row seeded from a ledger keeps that ledger's arbiter (ARBITER_USER when its text says USER-RESERVED, else ARBITER_PLAN); a deferral made on the page is ARBITER_USER. A held row keeps a seeded @@ -169,11 +173,13 @@ def split_fields(s): def decision_fields(q, rec): - """(answer, note): the decision in the resolution vocabulary, and the note an accept or an - alternative carries. An alternative whose key the question does not list is `alt `.""" + """(answer, note): the decision in the resolution vocabulary, and the note an accept, a hedged + accept (its condition) or an alternative carries. An alternative whose key the question does + not list is `alt `.""" decision, text = rec.get("decision"), rec.get("text") or "" - if decision == "accept": - return f"accepted: {q.get('recommendation') or 'the recommendation'}", text + if decision in ("accept", "hedged"): + word = "accepted" if decision == "accept" else "hedged" + return f"{word}: {q.get('recommendation') or 'the recommendation'}", text if decision == "alt": key = rec.get("alt") or "" alt = next( @@ -326,13 +332,13 @@ def readable(status, fields, marked): decided = fields.get("answer") or fields.get("aside") or "" if fields.get("note"): # A decision's note is labeled; any other note is the row's own seeded text. - noted = decided.startswith("accepted: ") or ALT.match(decided) + noted = decided.startswith(("accepted: ", "hedged: ")) or ALT.match(decided) parts.append(("note: " if noted else "") + fields["note"]) if status == "deferred" and fields.get("note") is None: # A page deferral; a seeded one's arbiter is in its own text. parts.append("arbiter: USER-RESERVED") - # An unconfirmed commitment reaches the Brief only as a named risk, and only where the - # decision carries it (register's "carries"). + # An unconfirmed commitment reaches the Brief as a named risk, and only where the decision + # carries it (register's "carries"). confirmed = [c for ok, c in marked if ok] if confirmed: parts.append("confirmed: " + "; ".join(confirmed)) @@ -351,9 +357,10 @@ def held_terminal(q, answer, note, at): decision, key, text = "alt", alt.group(1), note if not any(a.get("key") == key for a in q["alternatives"]): q["alternatives"].append({"key": key, "text": alt.group(2)}) - elif answer.startswith("accepted: "): - decision, key, text = "accept", None, note - q["recommendation"] = answer[len("accepted: ") :] + elif answer.startswith(("accepted: ", "hedged: ")): + word, _, rec = answer.partition(": ") + decision, key, text = ("accept" if word == "accepted" else word), None, note + q["recommendation"] = rec else: decision, key, text = "own", None, answer return {"decision": decision, "alt": key, "text": text, "updatedAt": at} @@ -469,7 +476,7 @@ def settle(q, responses, events, seed_rows): fields["note"] = note or None # The seeded text of a row still unsettled once the hold clears rides in note, unless # the decision's own note is there. - if said not in ("accept", "alt") and fields.get("note") is None: + if said not in ("accept", "hedged", "alt") and fields.get("note") is None: if kind == "aside" or status == "superseded-by-plan": fields["note"] = seed_text(seed) return status if status == "superseded-by-plan" else "open", fields, "", False @@ -486,6 +493,8 @@ def settle(q, responses, events, seed_rows): fields["note"] = seed_text(seed) return status, fields, clean(text), False status = "deferred" if decision == "defer" else "answered" + if decision in ("accept", "hedged") and commitments(q, events)[1]: + status = "open" return status, fields, clean(text), decision == "defer" fields["note"] = seed_text(seed) if status == "superseded-by-plan": @@ -524,9 +533,9 @@ def register(doc, resp): "reserved": reserved, "confirmed": confirmed, "unconfirmed": unconfirmed, - # Accept and own carry the recommendation's commitments; an alternative + # Accept, hedged and own carry the recommendation's commitments; an alternative # withdraws them and a defer's open row covers them. - "carries": decided.get("decision") in ("accept", "own"), + "carries": decided.get("decision") in ("accept", "hedged", "own"), } ) return rows @@ -560,6 +569,27 @@ def export_ledger(d): return "\n".join(out) + "\n" +def held_open(r): + """True for a row the unticked-commitment gate holds open: a decision that carries its + commitments, no hold, and a commitment still unconfirmed.""" + return ( + r["status"] == "open" + and r["carries"] + and bool(r["unconfirmed"]) + and not r["q"].get("waiting") + ) + + +def named_risks(rows): + """(row, commitment) for each unconfirmed commitment an answered or gated row carries.""" + return [ + (r, c) + for r in rows + if (r["status"] == "answered" and r["carries"]) or held_open(r) + for c in r["unconfirmed"] + ] + + def export_brief(d): doc, resp = read(d) rows = register(doc, resp) @@ -570,7 +600,8 @@ def export_brief(d): } answered = [r for r in rows if r["status"] == "answered"] confirmed = [(r, c) for r in rows for c in r["confirmed"]] - risks = [(r, c) for r in answered if r["carries"] for c in r["unconfirmed"]] + risks = named_risks(rows) + gated = [r["n"] for r in rows if held_open(r)] superseded = count["superseded-by-plan"] out = ["## Brief", "", "### TLDR", ""] out.append( @@ -581,6 +612,10 @@ def export_brief(d): out.append( f"- {len(confirmed)} commitments confirmed; {len(risks)} unconfirmed, carried as named risks" ) + if gated: + out.append( + f"- {', '.join(gated)}: decided, open until its commitments are confirmed" + ) out += ["", "### Goal", "", para(title), "", "### Constraints", ""] out += [ f"- {r['n']} {clean(r['q'].get('short'))}: {r['display']}" for r in answered @@ -727,7 +762,8 @@ 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", "hedged", "alt", "defer") ): ends.append( f"#{e['seq']} {e.get('id') or 'note'} ends mid-sentence: {clean(text)}" @@ -822,12 +858,7 @@ def export_report(d): "
  • none
  • " ] out.append("

    Named risks

      ") - risks = [ - (r, c) - for r in rows - if r["status"] == "answered" and r["carries"] - for c in r["unconfirmed"] - ] + risks = named_risks(rows) out += [f"
    • {esc(r['n'])}: {esc(c)} (unconfirmed)
    • " for r, c in risks] or [ "
    • none
    • " ] @@ -972,7 +1003,9 @@ def answer_kind(answer, where): return "defer" if answer.startswith(("archived: ", "superseded by ")): return "withdraw" - if answer.startswith(("accepted: ", "free-text: ")) or ALT.match(answer): + if answer.startswith(("accepted: ", "hedged: ", "free-text: ")) or ALT.match( + answer + ): return "decide" refuse(f"unreadable field 'answer' {answer!r}", where) @@ -998,10 +1031,18 @@ def clash(a, b): who, sep, waits = (hold or "").partition(" ") if hold is not None and (who not in ("claude", "user") or not sep): refuse(f"unreadable field 'hold' {hold!r}", where) - if "proposal" in fields and status in ("open", "deferred", "blocked"): + if "proposal" in fields and ( + status in ("deferred", "blocked") or (status == "open" and kind != "decide") + ): clash("proposal", f"status {status}") fits = { - "open": kind is None or (hold is not None and kind != "withdraw"), + "open": kind is None + or (hold is not None and kind != "withdraw") + or ( + kind == "decide" + and answer.startswith(("accepted: ", "hedged: ")) + and any(not ok for ok, _ in marked) + ), "superseded-by-plan": kind in (None, "defer") or (hold is not None and kind == "decide"), "answered": kind == "decide", @@ -1017,8 +1058,10 @@ def clash(a, b): status == "blocked" or (note is not None and status in ("deferred", "open")) ) noted = decided is not None and ( - decided.startswith("accepted: ") or bool(ALT.match(decided)) + decided.startswith(("accepted: ", "hedged: ")) or bool(ALT.match(decided)) ) + if decided is not None and decided.startswith("hedged: ") and not note: + refuse("a hedged answer with no condition in field 'note'", where) # Any other note on a row still unsettled is the seeded text of a row no decision counts on: # on a held row, one with no counted answer or a superseded-by-plan one. residual = ( diff --git a/plugins/planning/surface/index.html b/plugins/planning/surface/index.html index e9b94ec6a7..14ef6b5715 100644 --- a/plugins/planning/surface/index.html +++ b/plugins/planning/surface/index.html @@ -134,7 +134,7 @@ .chip{display:inline-flex;align-items:center;gap:5px;font:500 11.5px/1 var(--mono);padding:4px 7px;border-radius:4px;white-space:nowrap} .chip::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:currentColor} .s-open{color:var(--accent);background:var(--accent-soft)} -.s-accept{color:var(--ok);background:var(--ok-soft)} +.s-accept,.s-hedged{color:var(--ok);background:var(--ok-soft)} .s-alt,.s-own{color:var(--chg);background:var(--chg-soft)} .s-defer{color:var(--def);background:var(--def-soft)} .s-sup,.s-arch,.s-blocked{color:var(--wait);background:var(--wait-soft)} @@ -440,15 +440,16 @@

      Interview

      const fmt = iso => { const d = new Date(iso); return isNaN(d) ? String(iso || "") : d.toLocaleString([], {month: "short", day: "numeric", hour: "numeric", minute: "2-digit"}); }; const fmtT = iso => { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleTimeString([], {hour: "numeric", minute: "2-digit"}); }; const trunc = (s, n) => { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "..." : s; }; - const LABEL = {open: "Open", accept: "Accepted", alt: "Alternative", own: "Own answer", defer: "Deferred", wait: "Pending research", need: "Needs your answer", sup: "Superseded", arch: "Archived"}; - const DEC = new Set(["accept", "alt", "own", "defer", "reopen"]); + const LABEL = {open: "Open", accept: "Accepted", hedged: "Hedged", alt: "Alternative", own: "Own answer", defer: "Deferred", wait: "Pending research", need: "Needs your answer", sup: "Superseded", arch: "Archived"}; + const DEC = new Set(["accept", "alt", "own", "defer", "hedged", "reopen"]); const SUMMARY = "__summary"; const DEFAULTS = {shortcuts: true, undoSeconds: 5, checkpoint: 0, theme: "auto", density: "compact", minText: 14, displayName: "You", waitTimeout: 90, staleDepth: "direct", port: 0, openBrowser: true, leaseTimeout: 600}; const RESTART = "Restart the server to enable this."; const DEAF_AFTER = 30; // seconds with no watcher poll before the rung 5 message + const LINE_CAP = 500; // the server's one-line cap, which a hedged condition must fit const FREEZE_MS = 10000; // longest wrap-up freeze on decision saves const KEYS = [["Ctrl/Cmd+Enter", "Save the armed choice with the note"], ["Ctrl/Cmd+Shift+Enter", "Ask Claude, no decision"], ["1 to 9", "Arm choice N as numbered on screen"], - ["a", "Arm Accept; on a stale question with a decision, Reconfirm it (choice 1)"], ["o", "Arm Own answer and focus the note"], ["d", "Arm Defer"], ["r", "Reopen"], ["i", "Focus the note"], + ["a", "Arm Accept; on a stale question with a decision, Reconfirm it (choice 1)"], ["o", "Arm Own answer and focus the note"], ["d", "Arm Defer"], ["h", "Arm Hedged (accept with a condition) and focus the note"], ["r", "Reopen"], ["i", "Focus the note"], ["Esc", "Leave the note and keep the draft; again to close the panel"], ["n / Shift+N", "Next / previous item needing you"], ["j / k, Alt+Down / Alt+Up", "Next / previous question"], ["[", "Hide or show the question list"], ["v / m / l / ,", "Visuals / Notes to Claude / Activity / Settings"], ["f", "Full-screen visual"], ["Left / Right", "Previous / next image in a gallery, while the visuals panel or full screen has focus"], ["w", "Summary and wrap-up"], ["u", "Undo, during the undo window"], ["/", "Filter"], ["?", "This sheet"]]; @@ -559,8 +560,8 @@

      Interview

      return s; } const byClaude = (q, i) => (q.commitsConfirmed || []).find(c => String(c.index) === String(i)); - const accepted = q => ["accept", "own"].includes(stateOf(q)); // alt and defer carry none, and neither does a held question - function commitRows(){ // commitments carried by accept (or reconfirmed accept) and own decisions + const accepted = q => ["accept", "hedged", "own"].includes(stateOf(q)); // alt and defer carry none, and neither does a held question + function commitRows(){ // commitments carried by accept (or reconfirmed accept), hedged and own decisions const out = []; S.doc.questions.slice().sort(byIdCmp).forEach(q => { if (!accepted(q)) return; const cf = confirmed(q); (q.commits || []).forEach((c, i) => out.push({q, i, c, on: cf.has(String(i))})); }); return out; @@ -816,7 +817,7 @@

      Interview

      const actTarget = e => e.notes ? "notes" : (e.ids || []).find(id => Q[id]) || (isRestate(e) ? SUMMARY : null); // ---------- history thread ---------- - const VERB = {accept: "accepted", own: "answered", defer: "deferred", reopen: "reopened", ask: "asked Claude", rephrase: "asked for a rephrase", undo: "undid a save", note: "wrote", wrapup: "asked to wrap up"}; + const VERB = {accept: "accepted", hedged: "accepted with a condition", own: "answered", defer: "deferred", reopen: "reopened", ask: "asked Claude", rephrase: "asked for a rephrase", undo: "undid a save", note: "wrote", wrapup: "asked to wrap up"}; function whoLabel(h){ if (h.by === "claude") { if (h.kind === "rephrase") return "Claude rephrased"; @@ -913,17 +914,17 @@

      Interview

      function galFlip(d){ const g = curGallery(); if (!g) return; const i = g.imgs.findIndex(v => v.id === S.gal.a); S.gal.a = g.imgs[(Math.max(i, 0) + d + g.imgs.length) % g.imgs.length].id; galRefresh(); } function reconfirmOf(q){ // on a stale question, the kept decision re-armed exactly const d = isStale(q) && decisionOf(q), k = d && d.decision; - if (!["accept", "alt", "own", "defer"].includes(k)) return null; + if (!["accept", "alt", "own", "defer", "hedged"].includes(k)) return null; const a = (q.alternatives || []).find(x => x.key === d.alt); if (k === "alt" && !a) return null; // a revise removed the kept alternative: nothing to reconfirm - const text = k === "accept" ? "the recommendation" : k === "alt" ? "alternative (" + d.alt + "): " + a.text : k === "own" ? "your own answer" : "deferred"; + const text = k === "accept" ? "the recommendation" : k === "alt" ? "alternative (" + d.alt + "): " + a.text : k === "own" ? "your own answer" : k === "hedged" ? "hedged: " + (d.text || "") : "deferred"; return {re: true, kind: k, alt: k === "alt" ? d.alt : null, keep: d.text || "", label: "Reconfirm", text}; } function choicesOf(q){ const r = reconfirmOf(q), c = r ? [r] : []; c.push({kind: "accept", label: "Accept", text: firstLine(q.recommendation) || "the recommendation"}); (q.alternatives || []).forEach(a => c.push({kind: "alt", alt: a.key, label: "(" + a.key + ")", text: a.text})); - c.push({kind: "own", label: "Own answer", text: "uses your note"}, {kind: "defer", label: "Defer", text: "decide later"}); + c.push({kind: "own", label: "Own answer", text: "uses your note"}, {kind: "defer", label: "Defer", text: "decide later"}, {kind: "hedged", label: "Hedged", text: "accept, the note is the condition"}); return c.map((x, i) => Object.assign(x, {n: i + 1})); } function armedOf(cs){ // the armed choice by identity, never by the number it had when armed @@ -1011,14 +1012,14 @@

      Interview

      (st === "wait" ? 'Counts once Claude\'s research on ' + esc(q.id) + " returns." : "") : 'No decision yet.' + (setting("shortcuts") ? " Number keys pick; Ctrl+Enter saves." : " Ctrl+Enter saves.") + "" + (last ? receipts(last) : ""); const cs = choicesOf(q), cur = d && d.decision && !d.aside ? cs.find(c => !c.re && c.kind === d.decision && (c.kind !== "alt" || c.alt === d.alt)) : null; - const letter = c => c.re ? " a" : c.kind === "accept" ? (cs[0].re ? "" : " a") : c.kind === "own" ? " o" : c.kind === "defer" ? " d" : ""; + const letter = c => c.re ? " a" : c.kind === "accept" ? (cs[0].re ? "" : " a") : c.kind === "own" ? " o" : c.kind === "defer" ? " d" : c.kind === "hedged" ? " h" : ""; const armed = armedOf(cs); $("choices").innerHTML = cs.map(c => '").join(""); - const needText = armed && armed.kind === "own" && keptText(armed) === undefined && !text, frozen = wrapFreeze() > 0; + const needText = armed && noteKind(armed.kind) && keptText(armed) === undefined && !text, frozen = wrapFreeze() > 0; const label = armed ? (st === "wait" ? "Answer anyway: " : "Save: ") + armed.label + (withNote(q, armed) ? " with note" : "") : "Save"; const asks = armed && armed.kind === "own" && /\?$/.test(text), cut = !asks && midSentence(text); - $("decideRow").innerHTML = '' + (frozen ? "Wrapping up" : needText ? "Own answer needs a note" : "Ctrl+Enter") + "" + + $("decideRow").innerHTML = '' + (frozen ? "Wrapping up" : needText ? (armed.kind === "hedged" ? "Hedged needs a condition" : "Own answer needs a note") : "Ctrl+Enter") + "" + (asks ? 'This reads as a question. Ask Claude instead?" : "") + (cut ? 'This note looks cut off. Finish it before saving?' : "") + (d && d.decision && !d.aside ? '' : ""); @@ -1399,10 +1400,11 @@

      Interview

      const q = Q[S.sel]; if (!q || S.busy) return; const c = choicesOf(q).find(x => x.n === n); if (!c) return; S.armed = {kind: c.kind, alt: c.alt ?? null, re: !!c.re}; renderAnswer(); - if (c.kind === "own" && keptText(c) === undefined) $("note").focus(); + if (noteKind(c.kind) && keptText(c) === undefined) $("note").focus(); } - function keptText(c){ // Reconfirm sends a note typed over the kept one, else the kept note; never an empty own answer - if (!c.re || (!c.keep && c.kind === "own")) return undefined; + const noteKind = k => k === "own" || k === "hedged"; // decisions whose note is the answer itself + function keptText(c){ // Reconfirm sends a note typed over the kept one, else the kept note; never an empty own answer or condition + if (!c.re || (!c.keep && noteKind(c.kind))) return undefined; const t = $("note").value.trim(); return t && t !== c.keep.trim() ? t : c.keep; } @@ -1452,7 +1454,9 @@

      Interview

      if (!c && S.armed) { S.armed = null; toast("Pick a choice again: the choices changed.", true); renderAnswer(); return; } if (!c) { toast("Pick a choice first (1 to " + choicesOf(q).length + ").", true); const el = $("choices"); el.classList.remove("nudge"); void el.offsetWidth; el.classList.add("nudge"); return; } const keep = keptText(c); - if (c.kind === "own" && keep === undefined && !$("note").value.trim()) { toast("Own answer needs a note.", true); $("note").focus(); return; } + const note = keep !== undefined ? keep : $("note").value.trim(); + if (noteKind(c.kind) && !note) { toast(c.kind === "hedged" ? "Hedged needs a condition in the note." : "Own answer needs a note.", true); $("note").focus(); return; } + if (c.kind === "hedged" && note.length > LINE_CAP) { toast("The condition is too long: at most " + LINE_CAP + " characters.", true); $("note").focus(); return; } await send(c.kind, c.alt, undefined, keep, withNote(q, c)); } async function send(kind, alt, force, keep, noted){ @@ -1792,7 +1796,7 @@

      Interview

      if (e.ctrlKey || e.metaKey || e.altKey || !setting("shortcuts")) return; const k = e.key, q = Q[S.sel]; if (/^[1-9]$/.test(k)) arm(+k); - else if (k === "a") armKind("accept"); else if (k === "d") armKind("defer"); else if (k === "o") armKind("own"); + else if (k === "a") armKind("accept"); else if (k === "d") armKind("defer"); else if (k === "o") armKind("own"); else if (k === "h") armKind("hedged"); else if (k === "r") { const d = q && decisionOf(q); if (d && d.decision && !d.aside) send("reopen"); } else if (k === "j") step(1); else if (k === "k") step(-1); else if (k === "n" || k === "N") { const n = nextNeeding(k === "N" ? -1 : 1); if (n) select(n, {animate: true}); else if (k === "n" && allDone()) select(SUMMARY, {animate: true}); } diff --git a/plugins/planning/surface/round.py b/plugins/planning/surface/round.py index 62967323f4..541476df2d 100644 --- a/plugins/planning/surface/round.py +++ b/plugins/planning/surface/round.py @@ -63,6 +63,7 @@ import schema # noqa: E402 from server import ( # noqa: E402 EMPTY_RESPONSES, + LINE_CAP, SCHEMA_VERSION, Settings, check_alt, @@ -74,7 +75,7 @@ write_private, ) -DECISIONS = ("accept", "alt", "own", "defer") +DECISIONS = ("accept", "alt", "own", "defer", "hedged") SESSION_FILES = (".interview-session.json", ".interview-session.env") LOCK_NAME = "questions.json.lock" LOCK_SECONDS = 10 @@ -86,7 +87,6 @@ # commitment, a hold, a status, an activity entry, a reason) and markdown fields (facts, a # basis, a revision reason, a group summary, a thread reply, a note, a terminal answer, a # restatement section). -LINE_CAP = 500 TEXT_CAP = 20000 QUESTION_CAPS = ( ("title", LINE_CAP), @@ -638,6 +638,10 @@ def op_note_reply(d, doc, a): def op_record_terminal(d, doc, a): q = find(doc, a.id) capped("record-terminal text", a.text, TEXT_CAP) + if a.decision == "hedged": + if not (a.text or "").strip(): + sys.exit("refused: --decision hedged needs --text: the condition") + capped("record-terminal condition", a.text, LINE_CAP) if a.decision == "alt" and not a.alt: sys.exit("--alt KEY required with --decision alt") if a.decision == "alt": @@ -662,6 +666,7 @@ def op_record_terminal(d, doc, a): "alt": f"Chose ({a.alt})", "own": "Answered", "defer": "Deferred", + "hedged": "Hedged", }[a.decision] q.setdefault("history", []).append( { diff --git a/plugins/planning/surface/schema/event.schema.json b/plugins/planning/surface/schema/event.schema.json index f0f148a891..2aee39cc13 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). `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.", + "description": "One entry of responses.json events, the append-only log of everything the user did. `hedged` is an accept whose `text` is its required condition (one line). `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", "accept-audit"] + "enum": ["accept", "alt", "own", "defer", "hedged", "reopen", "ask", "rephrase", "note", "undo", "wrapup", "confirm", "confirm-understanding", "accept-audit"] }, "alt": {"type": ["string", "null"]}, "text": {"type": "string"}, diff --git a/plugins/planning/surface/schema/ops.schema.json b/plugins/planning/surface/schema/ops.schema.json index 3e0bfdc4a9..14665022c8 100644 --- a/plugins/planning/surface/schema/ops.schema.json +++ b/plugins/planning/surface/schema/ops.schema.json @@ -202,7 +202,7 @@ "properties": { "op": {"enum": ["record-terminal"]}, "id": {"type": "string"}, - "decision": {"enum": ["accept", "alt", "own", "defer"]}, + "decision": {"enum": ["accept", "alt", "own", "defer", "hedged"]}, "alt": {"type": "string"}, "text": {"type": "string"} } diff --git a/plugins/planning/surface/schema/questions.schema.json b/plugins/planning/surface/schema/questions.schema.json index 5d8feea564..c1ec32a9d8 100644 --- a/plugins/planning/surface/schema/questions.schema.json +++ b/plugins/planning/surface/schema/questions.schema.json @@ -159,7 +159,7 @@ "required": ["decision"], "additionalProperties": false, "properties": { - "decision": {"enum": ["accept", "alt", "own", "defer"]}, + "decision": {"enum": ["accept", "alt", "own", "defer", "hedged"]}, "alt": {"type": ["string", "null"]}, "text": {"type": "string"}, "updatedAt": {"type": "string"}, diff --git a/plugins/planning/surface/schema/responses.schema.json b/plugins/planning/surface/schema/responses.schema.json index f75dcf9249..76f3f39721 100644 --- a/plugins/planning/surface/schema/responses.schema.json +++ b/plugins/planning/surface/schema/responses.schema.json @@ -27,7 +27,7 @@ "required": ["decision"], "additionalProperties": false, "properties": { - "decision": {"enum": ["accept", "alt", "own", "defer", null]}, + "decision": {"enum": ["accept", "alt", "own", "defer", "hedged", null]}, "alt": {"type": ["string", "null"]}, "text": {"type": "string"}, "updatedAt": {"type": "string"}, diff --git a/plugins/planning/surface/server.py b/plugins/planning/surface/server.py index 9d1efbb3a0..bb6a3c6414 100644 --- a/plugins/planning/surface/server.py +++ b/plugins/planning/surface/server.py @@ -39,7 +39,9 @@ "history": {}, "events": [], } -DECISIONS = {"accept", "alt", "own", "defer", "reopen"} +# One-line free-text cap; a hedged decision's condition is one. +LINE_CAP = 500 +DECISIONS = {"accept", "alt", "own", "defer", "hedged", "reopen"} REQUESTS = {"ask", "rephrase"} # 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`). @@ -920,8 +922,14 @@ def record(self, msg): qid = None elif kind != "undo" and qid not in qs: raise ValueError("unknown question") - if kind in ("own", "ask", "note") and not text.strip(): - raise ValueError("text required") + if kind in ("own", "ask", "note", "hedged") and not text.strip(): + raise ValueError( + "text required: say the condition" + if kind == "hedged" + else "text required" + ) + if kind == "hedged" and len(text) > LINE_CAP: + raise ValueError(f"text too long: at most {LINE_CAP} characters") items = check_accept_audit(msg) if kind == "accept-audit" else None now = now_iso() extra = {} diff --git a/plugins/planning/surface/test_exporters.py b/plugins/planning/surface/test_exporters.py index b535903cd8..a69cf789a4 100644 --- a/plugins/planning/surface/test_exporters.py +++ b/plugins/planning/surface/test_exporters.py @@ -111,7 +111,7 @@ def check(self, *args): return p.returncode, p.stdout + p.stderr def decided(self): - """Q1 accepted with a confirmed commitment, Q2 alt, Q3 own, Q4 archived.""" + """Q1 accepted with both commitments confirmed, Q2 alt, Q3 own, Q4 archived.""" qs = [ question("Q1", commits=["One writer only", "No network"]), question("Q2"), @@ -123,6 +123,7 @@ def decided(self): event(2, "Q1", "confirm", alt="0"), event(3, "Q2", "alt", alt="a", text="with a note"), event(4, "Q3", "own", text="My own words."), + event(5, "Q1", "confirm", alt="1"), ] self.session(qs, events) @@ -160,7 +161,7 @@ def test_row_shapes_and_status_mapping(self): rows[0], r"^- Q1 \| answered \| round 1 \| Question Q1\? \| answer:: accepted: ", ) - self.assertIn("commitments:: +One writer only; -No network", rows[0]) + self.assertIn("commitments:: +One writer only; +No network", rows[0]) self.assertIn("alt a: Alt a of Q2", rows[1]) self.assertIn("free-text: My own words.", rows[2]) self.assertRegex(rows[3], r"^- Q4 \| withdrawn \| .*Off the chosen path\.") @@ -261,7 +262,7 @@ def deferred_session(self): question("Q3"), ] events = [ - event(1, "Q1", "accept"), + event(1, "Q1", "own", text="Mine."), event(2, "Q2", "defer", text="after the pilot"), event(3, "Q3", "accept"), ] @@ -365,7 +366,8 @@ def test_confirmed_commitment_is_an_assumption_and_archived_is_out_of_scope(self text = self.export("brief").read_text(encoding="utf-8") assumptions = text.split("### Captured assumptions")[1].split("###")[0] self.assertIn("One writer only", assumptions) - self.assertIn("risk: No network (unconfirmed)", assumptions) + self.assertIn("No network: confirmed on Q1", assumptions) + self.assertNotIn("risk:", assumptions) scope = text.split("### Out-of-scope")[1].split("###")[0] self.assertIn("Off the chosen path.", scope) @@ -423,17 +425,16 @@ def test_audit_accepts_carry_the_note_and_a_hand_accept_does_not(self): ledger = self.export("ledger") q1, q2, q3 = register_rows(ledger) self.assertIn( - f"answer:: accepted: Recommended answer for Q1.; note:: {PENDING}", q1 + f"| open | round 1 | Question Q1? | answer:: accepted: Recommended answer for " + f"Q1.; note:: {PENDING}; commitments:: -One writer only", + q1, ) self.assertIn(f"note:: {PENDING}", q2) self.assertRegex(q3, r"\| answer:: accepted: Recommended answer for Q3\.$") rc, out = self.check("--ledger", ledger) - self.assertEqual(rc, 0, out) + self.assertEqual(rc, 1, out) + self.assertIn("open=1", 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): @@ -661,8 +662,9 @@ def test_brief_and_report_show_escaped_rows_as_plain_text(self): ] brief = self.export("brief").read_text(encoding="utf-8") constraints = brief.split("### Constraints")[1].split("###")[0] - for n, i in (("Q1", 0), ("Q2", 1), ("Q3", 2)): + for n, i in (("Q2", 1), ("Q3", 2)): self.assertIn(f"- {n} Short {n}: {plain[i]}\n", constraints) + self.assertNotIn("Q1", constraints) scope = brief.split("### Out-of-scope")[1].split("###")[0] self.assertIn(f"- Q5 Question Q5?: {plain[4]}\n", scope) self.assertIn("- One / writer; only: confirmed on Q1;", brief) @@ -1174,7 +1176,7 @@ def test_an_answered_row_round_trips_its_confirmed_commitments(self): self.assertEqual( rows, [ - "- Q1 | answered | round 1 | Question Q1? | answer:: accepted: Recommended " + "- Q1 | open | round 1 | Question Q1? | answer:: accepted: Recommended " "answer for Q1.; note:: fine\\; really" + tail, "- Q2 | answered | round 1 | Question Q2? | answer:: alt a: Alt a of Q2; " "note:: a note" + tail, @@ -1255,6 +1257,110 @@ def test_an_unheld_answer_round_trips_its_text_and_note(self): self.assertEqual(register_rows(self.export("ledger", d=fresh)), rows) self.assertEqual(load_state(fresh), load_state(self.dir)) + def test_a_hedged_answer_exports_its_condition_and_round_trips(self): + qs = [ + question("Q1", commits=["Runs weekly"]), + question( + "Q2", + terminal={ + "decision": "hedged", + "text": "if a; b holds", + "updatedAt": AT, + }, + ), + question("Q3"), + ] + ev = [event(1, "Q1", "hedged", text="only if the lock is cheap")] + ev.append(event(2, "Q3", "accept", text="fine")) + self.session(qs, ev) + first = self.export("ledger") + rows = register_rows(first) + self.assertEqual( + rows, + [ + "- Q1 | open | round 1 | Question Q1? | answer:: hedged: Recommended " + "answer for Q1.; note:: only if the lock is cheap; commitments:: -Runs weekly", + "- Q2 | answered | round 1 | Question Q2? | answer:: hedged: Recommended " + "answer for Q2.; note:: if a\\; b holds", + "- Q3 | answered | round 1 | Question Q3? | answer:: accepted: Recommended " + "answer for Q3.; note:: fine", + ], + ) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp("import-ledger", "--ledger", str(first), d=fresh) + self.assertEqual(rc, 0, out) + rc, out = self.rp("validate", d=fresh) + self.assertEqual(rc, 0, out) + self.assertEqual(register_rows(self.export("ledger", d=fresh)), rows) + got = json.loads((fresh / "questions.json").read_text(encoding="utf-8")) + self.assertEqual( + [ + (q["terminal"]["decision"], q["terminal"]["text"]) + for q in got["questions"] + ], + [ + ("hedged", "only if the lock is cheap"), + ("hedged", "if a; b holds"), + ("accept", "fine"), + ], + ) + + def test_a_hedged_row_lists_its_condition_in_the_brief(self): + self.session( + [question("Q1")], [event(1, "Q1", "hedged", text="only if it is cheap")] + ) + brief = self.export("brief").read_text(encoding="utf-8") + self.assertIn( + "- Q1 Short Q1: hedged: Recommended answer for Q1.; note: only if it is cheap", + brief, + ) + self.assertIn("1 answered", brief) + + def test_a_held_hedged_answer_keeps_its_condition_and_round_trips(self): + self.session( + [question("Q1", waiting=True, waitsOn="the lookup")], + [event(1, "Q1", "hedged", text="only if it is cheap")], + ) + first = self.export("ledger") + rows = register_rows(first) + self.assertIn( + "hold:: claude the lookup; answer:: hedged: Recommended answer for Q1.; " + "note:: only if it is cheap", + rows[0], + ) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp("import-ledger", "--ledger", str(first), d=fresh) + self.assertEqual(rc, 0, out) + self.assertEqual(register_rows(self.export("ledger", d=fresh)), rows) + + def test_a_hedged_answer_with_no_condition_is_refused_on_import(self): + ledger = self.tmp / "hedged.md" + ledger.write_text( + "## Open-question register\n\n" + "- Q1 | answered | round 1 | T? | answer:: hedged: Do it\n", + encoding="utf-8", + ) + rc, out = self.rp("import-ledger", "--ledger", str(ledger)) + self.assertNotEqual(rc, 0) + self.assertIn("hedged answer with no condition", out) + + def test_the_legacy_free_text_hedge_still_imports_as_own(self): + ledger = self.tmp / "legacy.md" + ledger.write_text( + "## Open-question register\n\n" + "- Q1 | answered | round 1 | T? | answer:: free-text: hedged: Do it if cheap\n", + encoding="utf-8", + ) + rc, out = self.rp("import-ledger", "--ledger", str(ledger)) + self.assertEqual(rc, 0, out) + got = json.loads((self.dir / "questions.json").read_text(encoding="utf-8")) + terminal = got["questions"][0]["terminal"] + self.assertEqual( + (terminal["decision"], terminal["text"]), ("own", "hedged: Do it if cheap") + ) + def test_an_escaped_answer_that_contradicts_its_row_is_refused(self): for i, row in enumerate( [ @@ -1353,6 +1459,10 @@ def test_a_named_row_with_an_unknown_or_contradictory_field_is_refused(self): "answered | round 1 | Who? | answer:: free-text: x; note:: y", "contradictory fields 'answer' and 'note'", ), + ( + "open | round 1 | Who? | answer:: free-text: x; commitments:: -One", + "contradictory fields 'answer' and 'status open'", + ), ] ): with self.subTest(row=row): @@ -2107,7 +2217,14 @@ def resumed_state(d): for q in doc["questions"]: r = rows[q["id"]] # A held row shows no seed text until its hold clears, when the property test compares it. - unsettled = r["status"] in exporters.UNSETTLED and not q.get("waiting") + unsettled = ( + r["status"] in exporters.UNSETTLED + and not q.get("waiting") + and not ( + r["status"] == "open" + and exporters.latest_decision(q, resp.get("responses") or {}) + ) + ) state[q["id"]].update( commits=exporters.marked_commits(q, events), archived=(q.get("archived") or {}).get("why"), @@ -2358,7 +2475,11 @@ def build(self, seed): } if structured: rows[qid]["proposal"] = [new, old] - if decision in ("accept", "alt", "own"): + # An accept that leaves a commitment unticked reads open. + unticked = len(q.get("commitsConfirmed", [])) < len(q["commits"]) + if decision == "accept" and unticked: + expect["open"] += 1 + elif decision in ("accept", "alt", "own"): expect["answered"] += 1 elif decision == "defer" and not superseded: expect["deferred"] += 1 @@ -2974,6 +3095,131 @@ def test_only_a_confirm_after_the_list_changed_is_exported_and_imported(self): self.assertEqual(exporters.commitments(got["questions"][0], []), (["B"], ["A"])) +class TestUntickedCommitmentGate(SessionCase): + """An accepted or hedged row with an unconfirmed commitment exports open, so the `lock` gate + does not pass until each commitment is confirmed.""" + + COMMITS = ["One writer only", "No network"] + + def one(self, events): + self.session([question("Q1", commits=self.COMMITS)], events) + + def row(self): + (row,) = register_rows(self.export("ledger")) + return row + + def gate(self): + return self.check("--ledger", self.export("ledger"))[0] + + def test_an_accept_with_an_unticked_commitment_is_open_and_blocks_the_gate(self): + self.one([event(1, "Q1", "accept"), event(2, "Q1", "confirm", alt="0")]) + self.assertEqual( + self.row(), + "- Q1 | open | round 1 | Question Q1? | answer:: accepted: Recommended answer " + "for Q1.; commitments:: +One writer only; -No network", + ) + self.assertEqual(self.gate(), 1) + + def test_an_accept_with_every_commitment_ticked_is_answered(self): + self.one( + [ + event(1, "Q1", "accept"), + event(2, "Q1", "confirm", alt="0"), + event(3, "Q1", "confirm", alt="1"), + ] + ) + self.assertIn("| answered |", self.row()) + self.assertEqual(self.gate(), 0) + + def test_a_hedged_answer_with_an_unticked_commitment_is_open(self): + self.one([event(1, "Q1", "hedged", text="only if cheap")]) + self.assertEqual( + self.row(), + "- Q1 | open | round 1 | Question Q1? | answer:: hedged: Recommended answer " + "for Q1.; note:: only if cheap; commitments:: -One writer only; -No network", + ) + self.assertEqual(self.gate(), 1) + + def test_an_own_answer_and_an_alternative_do_not_open_the_row(self): + qs = [question(f"Q{i}", commits=["Part"]) for i in (1, 2)] + self.session( + qs, [event(1, "Q1", "own", text="Mine."), event(2, "Q2", "alt", alt="a")] + ) + self.assertEqual( + [r.split(" | ")[1] for r in register_rows(self.export("ledger"))], + ["answered", "answered"], + ) + + def test_revised_commitments_reopen_an_accept_whose_earlier_confirms_no_longer_tick( + self, + ): + self.one( + [ + event(1, "Q1", "accept"), + event(2, "Q1", "confirm", alt="0"), + event(3, "Q1", "confirm", alt="1"), + ] + ) + self.assertEqual(self.gate(), 0) + rc, out = self.rp( + "revise", "Q1", "--commit", "Two writers", "--commit", "Online", "--force" + ) + self.assertEqual(rc, 0, out) + self.assertEqual( + self.row(), + "- Q1 | open | round 1 | Question Q1? | answer:: accepted: Recommended answer " + "for Q1.; commitments:: -Two writers; -Online", + ) + self.assertEqual(self.gate(), 1) + + def test_the_open_row_round_trips_through_import_ledger(self): + self.one( + [ + event(1, "Q1", "hedged", text="only if cheap"), + event(2, "Q1", "confirm", alt="1"), + ] + ) + ledger = self.export("ledger") + rows = register_rows(ledger) + self.assertIn("| open |", rows[0]) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp("import-ledger", "--ledger", str(ledger), d=fresh) + self.assertEqual(rc, 0, out) + rc, out = self.rp("validate", d=fresh) + self.assertEqual(rc, 0, out) + got = json.loads((fresh / "questions.json").read_text(encoding="utf-8")) + (q,) = got["questions"] + self.assertEqual( + (q["terminal"]["decision"], q["terminal"]["text"]), + ("hedged", "only if cheap"), + ) + self.assertEqual( + exporters.commitments(q, []), (["No network"], ["One writer only"]) + ) + self.assertEqual(register_rows(self.export("ledger", d=fresh)), rows) + + def test_an_open_row_with_an_answer_and_every_commitment_ticked_is_refused(self): + bad = self.tmp / "bad.md" + bad.write_text( + "## Open-question register\n\n- Q1 | open | round 1 | Who? | answer:: " + "accepted: Yes.; commitments:: +A\n", + encoding="utf-8", + ) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp("import-ledger", "--ledger", str(bad), d=fresh) + self.assertNotEqual(rc, 0) + self.assertIn("refused", out) + + def test_the_brief_names_the_open_row_and_keeps_its_risk(self): + self.one([event(1, "Q1", "accept")]) + brief = self.export("brief").read_text(encoding="utf-8") + self.assertIn("- 0 commitments confirmed; 2 unconfirmed", brief) + self.assertIn("- Q1: decided, open until its commitments are confirmed", brief) + self.assertIn("- risk: No network (unconfirmed); from Q1", brief) + + class TestNoEmojiNoSkillNames(SessionCase): def test_outputs_carry_no_emoji(self): self.decided() diff --git a/plugins/planning/surface/test_round.py b/plugins/planning/surface/test_round.py index 81b049005b..78fb77a3f6 100644 --- a/plugins/planning/surface/test_round.py +++ b/plugins/planning/surface/test_round.py @@ -1360,6 +1360,25 @@ def test_known_alt_key_is_recorded(self): self.assertEqual(self.q("Q1")["terminal"]["alt"], "b") +class TestRecordTerminalHedged(DirCase): + """record-terminal --decision hedged carries its condition in --text, one line.""" + + def test_a_condition_is_required_and_capped_at_a_line(self): + for extra in ([], ["--text", " "], ["--text", "x" * 501]): + self.assert_refused("record-terminal", "Q1", "--decision", "hedged", *extra) + self.assertNotIn("terminal", self.q("Q1")) + + def test_a_hedged_answer_is_recorded_and_validates(self): + rc, out, err = self.rp( + "record-terminal", "Q1", "--decision", "hedged", "--text", "if cheap" + ) + self.assertEqual(rc, 0, out + err) + t = self.q("Q1")["terminal"] + self.assertEqual((t["decision"], t["text"]), ("hedged", "if cheap")) + rc, out, err = self.rp("validate") + self.assertEqual(rc, 0, out + err) + + class TestReviseSetsAsideOwn(DirCase): """A recommendation revision sets aside the counted own answer; other decisions stay.""" diff --git a/plugins/planning/surface/test_schema.py b/plugins/planning/surface/test_schema.py index a338e1d809..21b5be9e7b 100644 --- a/plugins/planning/surface/test_schema.py +++ b/plugins/planning/surface/test_schema.py @@ -166,6 +166,18 @@ def test_accept_audit_event_and_the_accept_it_fans_out(self): missing = {**audit, "items": [{"id": "Q1"}]} self.assertIn("missing required 'contentRev'", schema.first_error(missing, s)) + def test_a_hedged_event_and_its_response_validate(self): + e = {"seq": 1, "id": "Q1", "kind": "hedged", "text": "if cheap", "at": "t"} + self.assertIsNone(schema.first_error(e, schema.load("event"))) + resp = {"decision": "hedged", "alt": None, "text": "if cheap"} + doc = {"seq": 1, "responses": {"Q1": resp}, "events": [e]} + self.assertIsNone(schema.first_error(doc, schema.load("responses"))) + ops = schema.load("ops") + op = {"op": "record-terminal", "id": "Q1", "decision": "hedged", "text": "x"} + self.assertIsNone( + schema.first_error(op, ops["$defs"]["record-terminal"], "$", ops) + ) + 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 eeb017e6df..0a35ca95f8 100644 --- a/plugins/planning/surface/test_server.py +++ b/plugins/planning/surface/test_server.py @@ -1238,6 +1238,33 @@ def test_closed_client_frees_the_waiter_and_gets_no_delivery(self): self.assertNotIn("deliveredAt", ev) +class TestHedged(WaitCase): + """A hedged decision is an accept that carries its condition as the event text.""" + + @classmethod + def prepare(cls): + seed_questions(cls.dir, question("A")) + + def test_a_hedged_decision_needs_a_condition_within_the_line_cap(self): + before = self.state()["responses"]["events"] + for text in ("", " ", "x" * 501): + code, data = self.post({"id": "A", "kind": "hedged", "text": text}) + self.assertEqual(code, 400, data) + self.assertEqual(self.state()["responses"]["events"], before) + + def test_a_hedged_decision_is_recorded_rebuilds_and_validates(self): + from server import rebuild_responses + + code, data = self.post({"id": "A", "kind": "hedged", "text": "if it is cheap"}) + self.assertEqual(code, 200, data) + r = self.state()["responses"] + self.assertEqual(r["responses"]["A"]["decision"], "hedged") + self.assertEqual(r["responses"]["A"]["text"], "if it is cheap") + self.assertEqual(rebuild_responses(r["events"]), (r["responses"], r["history"])) + rc, out = self.rp("validate") + self.assertEqual(rc, 0, out) + + class TestQuestionState(WaitCase): """AC19: stale direct dependents, upstream-pending descendants, archived, revising.""" diff --git a/plugins/planning/surface/tests/ui_c.js b/plugins/planning/surface/tests/ui_c.js index e256e01327..a6411edf52 100644 --- a/plugins/planning/surface/tests/ui_c.js +++ b/plugins/planning/surface/tests/ui_c.js @@ -21,7 +21,7 @@ async page => { let n = 0; for (const q of s.questions.questions) { const r = s.responses.responses[q.id], dec = (r && r.decision) || (q.terminal && q.terminal.decision); - if (!["accept", "own"].includes(dec) || q.archived || !(q.commits || []).length) continue; + if (!["accept", "hedged", "own"].includes(dec) || q.archived || !(q.commits || []).length) continue; const cf = new Set(s.responses.events.filter(e => e.kind === "confirm" && e.id === q.id && !e.withdrawn && !(q.commitsSinceSeq != null && e.seq <= q.commitsSinceSeq)).map(e => String(e.alt)).concat((q.commitsConfirmed || []).map(c => String(c.index)))); n += q.commits.filter((c, i) => !cf.has(String(i))).length; } @@ -108,10 +108,24 @@ async page => { ok("Ctrl+Enter saves nothing behind the open ? sheet", armedBehind && (await events()).length === n1 && await page.evaluate(() => document.getElementById("keysDlg").open), "armed " + armedBehind); await page.keyboard.press("Escape"); await page.waitForTimeout(150); ok("AC16: Esc closes the sheet", await page.evaluate(() => !document.getElementById("keysDlg").open)); + // Hedged: accept with a required condition note; its commitments count like an accept's + await pick("Q3"); await page.click("main.detail h3"); + await page.keyboard.press("h"); + ok("h arms Hedged and focuses the note", /Hedged/.test(await armed()) && (await focused()) === "note", await armed()); + ok("Hedged with no condition cannot be saved", await page.$eval("[data-save]", el => el.disabled) && /Hedged needs a condition/.test(await page.textContent("#decideRow"))); + await page.keyboard.type("only if the migration is reversible"); await page.keyboard.press("Control+Enter"); await page.waitForTimeout(700); + const eh = await events(), hd = eh[eh.length - 1]; + ok("Hedged posts a hedged event with the condition", hd.id === "Q3" && hd.kind === "hedged" && hd.text === "only if the migration is reversible", JSON.stringify(hd)); + await pick("Q3"); + ok("the hedged decision shows as Hedged with its condition", /Hedged/.test(await page.textContent("#cur")) && /only if the migration is reversible/.test(await page.textContent("#cur")), await page.textContent("#cur")); + s = await state(); + ok("a hedged question's commitments count in To confirm", (await counter()) === (openAssumptions(s) ? open(openAssumptions(s)) : ""), (await counter()) + " vs " + openAssumptions(s)); + await page.click("main.detail h3"); await page.keyboard.press("r"); await page.waitForTimeout(600); await pick("Q1"); await page.click("main.detail h3"); + const nBeforeReopen = (await events()).length; await page.keyboard.press("r"); await page.waitForTimeout(700); const e4 = await events(); - ok("AC16: r sends Reopen when a decision exists", e4.length === n1 + 1 && e4[e4.length - 1].kind === "reopen" && e4[e4.length - 1].id === "Q1", e4[e4.length - 1].kind); + ok("AC16: r sends Reopen when a decision exists", e4.length === nBeforeReopen + 1 && e4[e4.length - 1].kind === "reopen" && e4[e4.length - 1].id === "Q1", e4[e4.length - 1].kind); // AC20: archived X1 is greyed, out of the open counts and the meter s = await state(); diff --git a/plugins/planning/tests/interview-defenses.test.sh b/plugins/planning/tests/interview-defenses.test.sh index 5bb166d8b9..3407a2cb04 100755 --- a/plugins/planning/tests/interview-defenses.test.sh +++ b/plugins/planning/tests/interview-defenses.test.sh @@ -497,7 +497,7 @@ pin_section "loop.md open-question register section is unchanged (it binds gaps "$LOOP" \ "## The open-question register" \ "## Step 3. Recognize the stop condition" \ - "afe8b87d549aef867adb49301d892e0814f13b98cef0c3d49f2479a92183eb77" + "c02c6aa427e0d64b7893d8123a6eeaf85ae00a9b36acfa65c29d39059b41072b" # loop.md carries TWINS of two SKILL.md lines that are byte-pinned there: the # confirmation-gate exemption ("`lock` is exempt … its STOP-on-gap rule still applies") in # Step 3, and the `USER-RESERVED` arbiter guidance in Step 4. A twin with no pin is a @@ -612,7 +612,7 @@ pin_section "audit-answers Step 1 section is unchanged (the never-auto floor, he "$AUDIT" \ "### Step 1. Assemble the answer set, holding the never-auto floor" \ "### Step 2. Dispatch fresh-context validators" \ - "4ac90f63d046510f8744728ca5c7e46feaaf5c703eefa596e14f8546d190b242" + "08a4752216efdc690cca29b0c77808b9d994b8599dc3f539eff7afda83fde8a8" pin_section "audit-answers Step 4 section is unchanged (hedged rows always reach the human)" \ "$AUDIT" \ "### Step 4. Merge and triage" \ @@ -804,8 +804,8 @@ pin "SKILL.md: on the page, commitment parts are \`commits\` entries" "$SKILL" \ "on the page, its parts are the question's \`commits\` entries" pin "loop.md defines commitment parts on the page" "$LOOP" \ "**Commitment parts on the page.**" -pin "loop.md: the register gate does not grade page parts" "$LOOP" \ - "The register gate does not grade parts." +pin "loop.md: an accepted or hedged page row with an unticked part exports open" "$LOOP" \ + "an accepted or hedged row with an unticked part exports \`open\`" pin "loop.md: a mirrored terminal answer ticks no part" "$LOOP" \ "a terminal answer mirrored with \`record-terminal\` ticks none" pin "loop.md: a hedged reply is never mirrored as accept" "$LOOP" \