From b1f6d884d39fa7139cb913b2e4dfd42e33cd63cd Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:17:34 +0000 Subject: [PATCH] Resolve issue #9895 Lane-Activity: work-resolve-issue/resolve-issue --- ...-9895-export-restated-sections-6c1e94a7.md | 7 ++ .../skills/interview/context/surface.md | 2 +- plugins/planning/surface/README.md | 2 +- plugins/planning/surface/exporters.py | 29 ++++++++- plugins/planning/surface/test_exporters.py | 65 ++++++++++++++++++- 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 .changes/planning/fix-9895-export-restated-sections-6c1e94a7.md diff --git a/.changes/planning/fix-9895-export-restated-sections-6c1e94a7.md b/.changes/planning/fix-9895-export-restated-sections-6c1e94a7.md new file mode 100644 index 0000000000..b63793aeda --- /dev/null +++ b/.changes/planning/fix-9895-export-restated-sections-6c1e94a7.md @@ -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` or says USER-RESERVED, so `/planning:plan` sees them ([#9895](https://github.com/melodic-software/claude-code-plugins/issues/9895)). diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 72cf2e03e5..16b669310b 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -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` or says `USER-RESERVED`. Lead each `deferred` and `planningOwned` bullet with its source `Q` where it has one | | `constraint` | `text` (one line), `id` (optional `C`) | Record a constraint the interview found: it joins top-level `constraints` as the next `C` 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`, `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. diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 482acaf899..050a201c69 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -73,7 +73,7 @@ Every command needs `--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 : ()` 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 | diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index 8ce0fb6ff2..be20831650 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -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. @@ -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 where it has one) and tagged + ARBITER_PLAN, or ARBITER_USER when its lead Q 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*\|(.*)$") @@ -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 += ( @@ -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) diff --git a/plugins/planning/surface/test_exporters.py b/plugins/planning/surface/test_exporters.py index 07e367ab3e..57b38c5039 100644 --- a/plugins/planning/surface/test_exporters.py +++ b/plugins/planning/surface/test_exporters.py @@ -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} @@ -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( @@ -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(