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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- **`interview` surface:** `export-brief` no longer drops three sections of a confirmed restatement. Its `decisions` recap becomes a `### Decisions` section, and its `deferred` and `planningOwned` bullets join `### Deferred questions` after the register's retired rows, tagged **arbiter: /planning:plan**, or **arbiter: USER-RESERVED** when a bullet leads with a user-reserved row's `Q<N>` or says USER-RESERVED, so `/planning:plan` sees them ([#9895](https://github.com/melodic-software/claude-code-plugins/issues/9895)).
2 changes: 1 addition & 1 deletion plugins/planning/skills/interview/context/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ When the first wake prompts for permission, offer the user one allow rule, `Bash
| `activity` | `text`, `ids` | Log off-page work in the Activity panel |
| `finish` | `brief` (the Brief's path), `next` (markdown; the page falls back to `meta.next`), `text` (one line) | The closing event. The page shows it in a dismissible modal and keeps it after the server stops; a later `add` or `add-round` withdraws it |
| `confirm-commitments` | `id`, `indices` (all when absent), `reason` | Record commitments the user confirmed outside the page, such as in the terminal |
| `restate` | `sections`: `problem`, `workSize`, `goal`, `constraints`, `decisions`, `acceptance`, `outOfScope`, `deferred`, `planningOwned` (markdown, at least one non-empty) | Post the restatement the [confirmation gate](#confirmation-gate) shows; each one gets a new `rev` and joins `restatements`, the latest mirrored as `restatement` |
| `restate` | `sections`: `problem`, `workSize`, `goal`, `constraints`, `decisions`, `acceptance`, `outOfScope`, `deferred`, `planningOwned` (markdown, at least one non-empty) | Post the restatement the [confirmation gate](#confirmation-gate) shows; each one gets a new `rev` and joins `restatements`, the latest mirrored as `restatement`. `export-brief` exports every section of the newest confirmed rev: `problem`, `workSize`, `goal`, `constraints`, `acceptance` and `outOfScope` to the Brief sections of those names, `decisions` to a `### Decisions` section (written only when non-empty), and `deferred` then `planningOwned` to `### Deferred questions` after the register's retired rows, each bullet tagged **arbiter: /planning:plan**, or **arbiter: USER-RESERVED** when it leads with a user-reserved row's `Q<N>` or says `USER-RESERVED`. Lead each `deferred` and `planningOwned` bullet with its source `Q<N>` where it has one |
| `constraint` | `text` (one line), `id` (optional `C<N>`) | Record a constraint the interview found: it joins top-level `constraints` as the next `C<N>` and the page header lists it under "Constraints (N)". An `id` already recorded has its text replaced. `export-brief` takes Constraints from a confirmed restatement; with none it lists these, then the `--ledger` file's `## Constraint ledger` rows under another id. A file without `constraints` still loads, renders and exports. Refused: blank or multi-line `text`, an `id` that is not `C<N>`, `text` over 500 characters |

A new or changed `short` is capped at 80 characters, the shared question shape's cap ([`question-shape.md`](question-shape.md)), and a stored one up to 500 from an older session still loads and stays until revised; other one-line fields (a title, `rec`, an alternative or its tradeoff, a commitment, a hold, a status, an activity entry, a reason) at 500, and markdown fields (`facts`, `basis`, `why`, a note, a summary, a restatement section) at 20000; a longer one is refused and nothing is written.
Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Every command needs `--dir '<data_dir>'`; there is no default. Every write valid
| `repair-rounds` | Rewrite only those drifted rounds to the ledger cell's round (one write; `no round drift` when none). A question moved to another round on purpose also shows as drift until its cell changes, so read `status` first |
| `bump [--id Q]` | Bump the file rev, or one question's |
| `validate` | Check both files against the shipped schemas |
| `export-ledger`, `export-brief`, `export-report --out F` | The ledger register, the PLAN.md Brief sections (`export-brief --ledger L` numbers as L does and carries its ledger-only deferred rows; the Brief takes Problem, Goal, Work size, Constraints, Acceptance criteria and Out-of-scope from the newest confirmed restatement and stamps it), one self-contained HTML report whose CSP allows no network source |
| `export-ledger`, `export-brief`, `export-report --out F` | The ledger register, the PLAN.md Brief sections (`export-brief --ledger L` numbers as L does and carries its ledger-only deferred rows; the Brief takes Problem, Goal, Work size, Constraints, Acceptance criteria and Out-of-scope from the newest confirmed restatement, adds its `decisions` as a Decisions section and its `deferred` and `planningOwned` bullets to Deferred questions, and stamps it), one self-contained HTML report whose CSP allows no network source |
| `export-ledger --ledger L --out F`, `export-ledger --diff L` | Merge into ledger L's register: rows only L has, L's titles and its round labels (while the label still names the page's round) are kept, rows come out in id order, and a row L settled that the page shows otherwise with no decision of its own since any import keeps L's value and is printed as a conflict (exit 1). `--diff` writes nothing and prints each row and column the merge would change, the conflicts and the text it keeps; exit 1 on any change or conflict |
| `export-ledger --recap --out F` | Write the confirmation gate's compact recap instead of the ledger: `Q<N> <status>: <question> (<answer>)` for each live question, one `Withdrawn (N): ...` line for all withdrawn rows, and answers cut (240, then 120, 60, 30 characters) until the recap fits the 20,000-character restate cap; exit 1 after writing when even the shortest cut does not fit |
| `sync-ledger --ledger L` | Rewrite only L's register rows from page state, merged as `export-ledger --ledger` merges; prose, fenced blocks and other sections stay as they are. Refuses a ledger without exactly one register heading; exit 1 on a conflict |
Expand Down
29 changes: 27 additions & 2 deletions plugins/planning/surface/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
Constraints, Acceptance criteria and Out-of-scope come from the newest confirmed
restatement rev (without one, each Problem field reads `unknown` and each Work
size field `not assessed`; with no confirmed Constraints it lists the page's
recorded constraints and the ledger's `## Constraint ledger` rows), and the TLDR's `Restatement:` line stamps it confirmed or UNCONFIRMED
recorded constraints and the ledger's `## Constraint ledger` rows), as do a
Decisions section from its decisions recap and the Deferred questions entries
from its deferred and planningOwned bullets, and the TLDR's `Restatement:` line
stamps it confirmed or UNCONFIRMED
export_report one self-contained HTML file (no external resources; everything escaped); it
also inlines each file visual that resolves inside the data dir
import_ledger seeds an empty questions document from a ledger's register rows.
Expand Down Expand Up @@ -880,6 +883,23 @@ def bullets(text):
]


LEAD_QN = re.compile(r"^[\\\[(*_]*[Qq]([1-9][0-9]*)\b")


def restated_deferred(sections, rows):
"""The restated `deferred`, then `planningOwned`, bullets as Deferred questions entries, each
as written (the restatement leads one with its source Q<N> where it has one) and tagged
ARBITER_PLAN, or ARBITER_USER when its lead Q<N> is a user-reserved row or it says
USER-RESERVED itself."""
reserved = {r["n"] for r in rows if r["reserved"]}
out = []
for b in bullets(sections.get("deferred")) + bullets(sections.get("planningOwned")):
lead = LEAD_QN.match(b[2:])
user = (lead and f"Q{lead.group(1)}" in reserved) or "USER-RESERVED" in b
out.append(f"{b}; {ARBITER_USER if user else ARBITER_PLAN}")
return out


CONSTRAINT_ROW = re.compile(r"^\s*[-*+]\s+(C[0-9]+)\s*\|(.*)$")


Expand Down Expand Up @@ -1023,6 +1043,9 @@ def export_brief(d, text=None):
for r in answered
),
] or ["- none recorded"]
decisions = bullets(sections.get("decisions"))
if decisions:
out += ["", "### Decisions", "", *decisions]
criteria = bullets(sections.get("acceptance"))
out += ["", "### Acceptance criteria", ""]
out += (
Expand Down Expand Up @@ -1056,7 +1079,9 @@ def export_brief(d, text=None):
out.append(
f"- {brief_label(r)}: {clean(r['q'].get('title'))}, defer until {until}; {arbiter}"
)
if not retired:
restated_rows = restated_deferred(sections, rows)
out += restated_rows
if not retired and not restated_rows:
out.append("- none")
out += ["", "## Plan", ""]
return "\n".join(out)
Expand Down
65 changes: 63 additions & 2 deletions plugins/planning/surface/test_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ def test_without_a_ledger_a_gap_keeps_the_register_number_and_adds_the_id(self):


class TestBriefRestatements(SessionCase):
"""Goal, Constraints, Acceptance criteria and Out-of-scope come from the newest confirmed rev."""
"""Every restated section comes from the newest confirmed rev."""

def rev(self, n, **sections):
return {"rev": n, "at": f"2026-09-24T10:0{n}:00Z", "sections": sections}
Expand All @@ -608,7 +608,8 @@ def brief(self, restatements, verdicts):
return self.export("brief").read_text(encoding="utf-8")

def section(self, text, name):
return text.split(f"### {name}")[1].split("###")[0].strip().splitlines()
section = text.split(f"### {name}")[1].split("###")[0].split("\n## ")[0]
return section.strip().splitlines()

def test_a_confirmed_rev_one_survives_an_unconfirmed_rev_three(self):
text = self.brief(
Expand Down Expand Up @@ -692,6 +693,66 @@ def test_restated_problem_and_work_size_fill_their_sections(self):
)
self.assertEqual(self.section(text, "Work size"), ["- Size: Small"])

def test_restated_deferred_planning_owned_and_decisions_reach_the_brief(self):
deferred = ["Q3: moderation model, defer until after V1", "offline mode"]
owned = ["cache key layout", "Q4: retry budget"]
decisions = ["Q2 answered: Question Q2? (accepted)", "Withdrawn (1): Q1"]
text = self.brief(
[
self.rev(
1,
goal="Ship it.",
deferred="\n".join(f"- {x}" for x in deferred),
planningOwned="\n".join(f"- {x}" for x in owned),
decisions="\n".join(decisions),
)
],
[self.confirm(2, 1)],
)
self.assertEqual(
self.section(text, "Deferred questions"),
[f"- {x}; **arbiter: /planning:plan**" for x in deferred + owned],
)
self.assertEqual(
self.section(text, "Decisions"), [f"- {x}" for x in decisions]
)
self.assertNotIn("- none", self.section(text, "Deferred questions"))
rc, out = self.check(
"--ledger", self.export("ledger"), "--brief", self.export("brief")
)
self.assertEqual(rc, 0, out)

def test_a_restated_deferral_of_a_user_reserved_row_keeps_its_arbiter(self):
self.session(
[question("Q1")],
[event(1, "Q1", "accept"), self.confirm(2, 1)],
restatements=[
self.rev(
1,
deferred="- q2: which cap\n- the launch date, USER-RESERVED",
planningOwned="- Q1 follow-up wording",
)
],
)
ledger = self.tmp / "reserved-ledger.md"
ledger.write_text(
LEDGER_HEAD + "- Q1 | answered | round 1 | Question Q1? | accepted\n"
"- Q2 | deferred | round 1 | Which cap? | USER-RESERVED until the pilot\n",
encoding="utf-8",
)
out = self.tmp / "reserved-brief.md"
rc, msg = self.rp("export-brief", "--out", str(out), "--ledger", str(ledger))
self.assertEqual(rc, 0, msg)
lines = self.section(out.read_text(encoding="utf-8"), "Deferred questions")
self.assertEqual(
lines[1:],
[
"- q2: which cap; **arbiter: USER-RESERVED**",
"- the launch date, USER-RESERVED; **arbiter: USER-RESERVED**",
"- Q1 follow-up wording; **arbiter: /planning:plan**",
],
)

def test_no_restatement_writes_no_stamp(self):
self.session([question("Q1")], [event(1, "Q1", "accept")])
self.assertNotIn(
Expand Down
Loading