From 05047dd049426b0fb4004d953309fd5ec3b324b6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:23:27 -0400 Subject: [PATCH 01/14] fix(planning): round-trip the interview ledger register and report every gate row error import-ledger reads a round cell anchored on `round `, falls back to the first integer and then 1 with a warning naming the row, and keeps any non-bare cell so export-ledger writes it back while the round still matches. export-ledger --ledger merges into a ledger's register (ledger-only rows, titles and round labels kept, rows in id order, status conflicts kept and reported), --diff prints what the merge would change, and sync-ledger rewrites only the register rows from page state. check-open-questions.sh collects every row error in one pass with its line, tells out-of-order apart from a gap, and keeps the counts of the valid rows. Refs: #5569 Co-Authored-By: Claude Opus 5.5 --- .../planning/scripts/check-open-questions.sh | 134 +++++++--- .../scripts/check-open-questions.test.sh | 33 +++ .../skills/interview/context/surface.md | 6 +- plugins/planning/surface/README.md | 4 +- plugins/planning/surface/exporters.py | 231 +++++++++++++++--- plugins/planning/surface/round.py | 66 ++++- .../surface/schema/questions.schema.json | 5 + plugins/planning/surface/test_exporters.py | 195 +++++++++++++++ 8 files changed, 599 insertions(+), 75 deletions(-) diff --git a/plugins/planning/scripts/check-open-questions.sh b/plugins/planning/scripts/check-open-questions.sh index 6d3dc3c405..b6eac8518c 100755 --- a/plugins/planning/scripts/check-open-questions.sh +++ b/plugins/planning/scripts/check-open-questions.sh @@ -25,8 +25,11 @@ # own procedure; fix the ledger or Brief and re-run) # Exit 2 = ungradeable: no ledger, no register section, a duplicate register or # deferred-questions heading, an unterminated fenced block, an empty -# register, a malformed row, an unknown status, a duplicate or -# non-contiguous Q id, or a named `--brief` that is missing +# register, a malformed row, an unknown status, a duplicate, an +# out-of-order or a missing (gap) Q id, or a named `--brief` that is +# missing. Row errors are collected in one pass, each on stderr with +# its own ledger line, and the verdict keeps the counts of the rows +# that parsed. # # Usage: # bash check-open-questions.sh --ledger [--brief ] [--procedure] @@ -260,10 +263,40 @@ blocked=0 superseded=0 seen_ids=" " deferred_ids="" -expected=1 +ids_in_order="" +max_id=0 rounds_seen=" " max_round=0 proc_problems="" +row_errors="" +lineno=0 + +# Row errors are collected, not fatal on the first, so one run names every bad +# row with its own line; any of them still makes the register ungradeable. +row_error() { row_errors="${row_errors}line $lineno: $1"$'\n'; } + +# Record a question id (its number, no leading zero) in file order. A repeat is +# a duplicate (dup=1, the row is skipped); a number below one already seen is +# out of order. Gaps are found after the loop, from the whole id set, so an +# out-of-order row is not also reported as a gap. +track_id() { + dup=0 + case "$seen_ids" in + *" Q$1 "*) + row_error "duplicate question id: Q$1" + dup=1 + return + ;; + *) ;; + esac + seen_ids="${seen_ids}Q$1 " + ids_in_order="$ids_in_order$1 " + if [[ "$1" -lt "$max_id" ]]; then + row_error "question id out of order: Q$1 follows Q$max_id" + else + max_id="$1" + fi +} # What counts as a CANDIDATE register row, defined once. The fenced branch and # the live branch below both test it, and they must agree: a shape skipped as @@ -272,6 +305,9 @@ row_candidate_re='^[[:space:]]*-[[:space:]]+[Qq][0-9]+([^0-9]|$)' skipped_fenced_row=0 while IFS= read -r line; do + # extract_section emits one line per ledger line after the heading, so the + # ledger line number is the heading's plus the count read so far. + lineno=$((lineno == 0 ? register_line + 1 : lineno + 1)) # extract_section prefixes every line with its fence state; strip the marker # before parsing. A fenced block inside the register section is documentation # (the row shape, a worked example), not data. Grading it would fail a ledger @@ -293,17 +329,35 @@ while IFS= read -r line; do # is a real state; it exits 2. [[ "$line" =~ $row_candidate_re ]] || continue + # No leading zeros, and no Q0. `[[ ]]` numeric comparison evaluates its + # operands in arithmetic context, where a leading-zero numeral is OCTAL: the + # order test on `Q08` errors to stderr and resolves FALSE, so a misplaced row + # would silently pass. Rejecting the form outright keeps the comparison total. + # Q is a running counter from 1, so `Q08` is malformed by the register's + # own contract anyway. + [[ "$line" =~ ^[[:space:]]*-[[:space:]]+([Qq]([0-9]+)) ]] + token="${BASH_REMATCH[1]}" + num="${BASH_REMATCH[2]}" + if ! [[ "$num" =~ ^[1-9][0-9]*$ ]]; then + row_error "malformed question id (expected Q1, Q2, … with no leading zero): $token" + continue + fi + # Normalize so `q3` and `Q3` collide as the same id. Q numbering runs + # continuously across rounds (SKILL.md "Relentless mode"), so a gap is a row + # that went missing after it was written, the exact silent drop this gate is + # here to refuse. A malformed row's id still counts, so it is not also a gap. + id="Q$num" + track_id "$num" + [[ "$dup" -eq 0 ]] || continue + if ! [[ "$line" =~ ^[[:space:]]*-[[:space:]]+[Qq][0-9]+[[:space:]]*\| ]]; then - die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line ($where)" + row_error "malformed register row (needs 'Q | status | round | question'): $line" + continue fi row="${line#*-}" row="${row#"${row%%[![:space:]]*}"}" - id="${row%%|*}" - id="${id#"${id%%[![:space:]]*}"}" - id="${id%"${id##*[![:space:]]}"}" - rest="${row#*|}" status_field="${rest%%|*}" status_field="${status_field#"${status_field%%[![:space:]]*}"}" @@ -313,37 +367,9 @@ while IFS= read -r line; do # reports NF as the number of `|` separators plus one. separators="${row//[!|]/}" if [[ "${#separators}" -lt 3 ]]; then - die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line ($where)" - fi - - # No leading zeros, and no Q0. `[[ ]]` numeric comparison evaluates its - # operands in arithmetic context, where a leading-zero numeral is OCTAL: the - # contiguity test below on `Q08` errors to stderr and resolves FALSE, so a - # gapped register would silently pass. Rejecting the form outright keeps the - # comparison total. Q is a running counter from 1, so `Q08` is malformed - # by the register's own contract anyway. - num="${id#[Qq]}" - if ! [[ "$num" =~ ^[1-9][0-9]*$ ]]; then - die_ungradeable "malformed question id (expected Q1, Q2, … with no leading zero): $id ($where)" - fi - # Normalize so `q3` and `Q3` collide as the same id. - id="Q$num" - - case "$seen_ids" in - *" $id "*) die_ungradeable "duplicate question id: $id ($where)" ;; - *) ;; # not seen before — fall through and register it - esac - seen_ids="$seen_ids$id " - - # Q numbering runs continuously across rounds (SKILL.md "Relentless mode"), so - # a gap is a row that went missing after it was written — the exact silent drop - # this gate is here to refuse. Ungradeable, never a pass. - if [[ "$num" -ne "$expected" ]]; then - die_ungradeable "non-contiguous question id: expected Q$expected, got $id ($where)" + row_error "malformed register row (needs 'Q | status | round | question'): $line" + continue fi - expected=$((expected + 1)) - - registered=$((registered + 1)) # Fields after the id: status | round | question | resolution. The resolution # is the text after the last `|`, so a `|` inside the question cannot make an @@ -382,7 +408,11 @@ while IFS= read -r line; do blocked=$((blocked + 1)) deferred_ids="$deferred_ids$id " ;; - *) die_ungradeable "unknown status '$status_field' in row: $line ($where)" ;; + *) + row_error "unknown status '$status_field' in row: $line" + shopt -u nocasematch + continue + ;; esac case "$status_field" in answered | deferred | withdrawn | blocked) @@ -391,8 +421,34 @@ while IFS= read -r line; do *) ;; esac shopt -u nocasematch + registered=$((registered + 1)) done <<<"$section" +# A gap is a number missing below the highest id; one sort over the whole set, +# not a walk to the highest id, so a mistyped Q99999 costs nothing extra. +prev=0 +# shellcheck disable=SC2086 # deliberate: one id per word +for n in $(printf '%s\n' $ids_in_order | sort -n); do + if [[ "$n" -gt $((prev + 1)) ]]; then + gap="Q$((prev + 1))" + [[ "$n" -gt $((prev + 2)) ]] && gap="$gap to Q$((n - 1))" + row_errors="${row_errors}gap in question ids: no row for $gap"$'\n' + fi + prev="$n" +done + +if [[ -n "$row_errors" ]]; then + # Exit 2 stays load-bearing for Step 3 and the --brief path; the verdict + # keeps the counts of the rows that parsed, so a row error reads apart from + # a ledger the gate could not read at all (whose counts are all zero). + printf 'error: row errors in %s in: %s\n' "$where" "$ledger" >&2 + while IFS= read -r row_err; do + printf 'error: %s\n' "$row_err" >&2 + done <<<"${row_errors%$'\n'}" + echo "registered=$registered open=$open_count deferred=$deferred blocked=$blocked withdrawn=$withdrawn answered=$answered superseded=$superseded brief=unchecked status=ungradeable procedure=unchecked" + exit 2 +fi + if [[ "$registered" -eq 0 ]]; then if [[ "$skipped_fenced_row" -eq 1 ]]; then die_ungradeable "the register section holds no question rows in: $ledger ($where; rows inside a fenced block are ignored by design; register rows must be unfenced)" diff --git a/plugins/planning/scripts/check-open-questions.test.sh b/plugins/planning/scripts/check-open-questions.test.sh index 0bb4048d67..ad3e86bc4b 100755 --- a/plugins/planning/scripts/check-open-questions.test.sh +++ b/plugins/planning/scripts/check-open-questions.test.sh @@ -773,6 +773,39 @@ expect_exit "--procedure --brief section only under ## Plan -> 1" 1 --ledger "$p # 54. Ungradeable stays exit 2 and reports procedure=unchecked. expect_stdout "ungradeable with --procedure reports procedure=unchecked" "status=ungradeable procedure=unchecked" --ledger "$noreg" --procedure expect_exit "--procedure alone without a ledger -> 2" 2 --procedure + +# 55. Every row error in one run, each with its own ledger line, and the +# verdict keeps the counts of the rows that parsed. mkledger puts the +# register heading on line 7, so the rows start on line 9. +many_bad="$( + mkledger <<'EOF' +- Q1 | answered | round 1 | Who writes? | admin +- Q3 | answerd | round 1 | Format? | markdown +- Q2 | answered (restated after Q3) | round 1 | Moderation? | later +- Q4 | open | round 2 | Retention? | +EOF +)" +many_bad_err="$(stderr_of --ledger "$many_bad")" +for want in "line 10: unknown status 'answerd'" "line 11: question id out of order: Q2 follows Q3" "line 11: unknown status 'answered (restated after Q3)'"; do + if [[ "$many_bad_err" == *"$want"* ]]; then pass "row errors name '$want'"; else fail "row errors name '$want' (stderr: '$many_bad_err')"; fi +done +expect_exit "row errors still exit 2" 2 --ledger "$many_bad" +expect_stdout "row errors keep the counts of the valid rows" "registered=2 open=1 deferred=0 blocked=0 withdrawn=0 answered=1 superseded=0 brief=unchecked status=ungradeable" --ledger "$many_bad" + +# 56. A missing id is a gap and a misplaced one is out of order: distinct +# messages, neither reported as the other. +gap_err="$(stderr_of --ledger "$gap")" +if [[ "$gap_err" == *"gap in question ids: no row for Q2"* && "$gap_err" != *"out of order"* ]]; then pass "a missing id reads as a gap"; else fail "a missing id reads as a gap (stderr: '$gap_err')"; fi +swapped="$( + mkledger <<'EOF' +- Q2 | answered | round 1 | Format? | markdown +- Q1 | answered | round 1 | Who writes? | admin +EOF +)" +swapped_err="$(stderr_of --ledger "$swapped")" +if [[ "$swapped_err" == *"line 10: question id out of order: Q1 follows Q2"* && "$swapped_err" != *"gap"* ]]; then pass "a misplaced id reads as out of order"; else fail "a misplaced id reads as out of order (stderr: '$swapped_err')"; fi +expect_exit "an out-of-order register -> 2" 2 --ledger "$swapped" + if [[ "$fails" -ne 0 ]]; then printf '\n%d test(s) failed.\n' "$fails" >&2 exit 1 diff --git a/plugins/planning/skills/interview/context/surface.md b/plugins/planning/skills/interview/context/surface.md index 33be37fcc9..5a8494ae76 100644 --- a/plugins/planning/skills/interview/context/surface.md +++ b/plugins/planning/skills/interview/context/surface.md @@ -24,7 +24,7 @@ The page is the input surface SKILL.md "Question surface: the page" selects. The - **Data dir.** `'//interview-surface/'` (default `.work/`), resolved through the topic-docs binding, never CWD-relative. One per topic: `ensure-running` reuses the server already running there, and a resumed session finds the same files. Pass the same path as `--dir` on every command. - **Start** with the command in SKILL.md (it carries the configured emoji setting). It prints the page URL; give that URL to the user. A missing prerequisite exits non-zero with its name: take the degrade below. - **Ids.** Page question ids are `Q` on the session's one continuous counter, so the register rows written at ask-time match what `export-ledger` emits (it renumbers any other id set). When the register already has rows as the page starts (earlier terminal rounds, or a resumed topic whose data dir was discarded), seed the still-empty data dir with `round.sh import-ledger --ledger '//interview-checklist.md'` before the first `add-round`; it keeps each row's `Q` and decision. `export-ledger` writes each row's resolution as escaped named fields in a fixed order (`hold`, `proposal`, `was`, `answer`, `note`, `aside`, `commitments`; the grammar is in `surface/exporters.py`), and import restores them: the hold, a superseded-by-plan row's proposal and displaced answer, the decision on any status (an accept or a defer on a superseded-by-plan row too), the note, a decision a user hold set aside (restored still set aside), and every commitment with its confirmed or unconfirmed mark. An unknown field or a contradictory pair is refused, naming the field. Every older grammar still imports, read as it always was: `waits on::`, `awaiting user::`, `confirmed::`, `plan proposes::` and `answer::` rows, a settled row's `; confirmed::` tail, and the unescaped `waits on:`, `confirmed:` and `; confirmed:` forms. A hand-written resolution imports as the row's own text. -- **A round.** Write the frontier to `'/round-.json'` (`{"meta": {...}, "groups": [...], "questions": [...], "visuals": [...]}`), run `round.sh add-round --file '/round-.json' --round `, and write the register's `open` rows in the same step. Each question carries `recommendation` (one line), `basis` (2-3 sentences, shown behind Why), at least two `alternatives` (`{key, text}`), `commits` (what accepting commits the user to, or `[]`), and `dependsOn` for its prerequisites. Send the round's closing constraint probe with a `note-reply` op (no `seq`) so it lands in Notes to Claude, or as a Claude thread line on the round's first question. `add` and `add-round` refuse a question without `commits` or with fewer than two alternatives. +- **A round.** Write the frontier to `'/round-.json'` (`{"meta": {...}, "groups": [...], "questions": [...], "visuals": [...]}`), run `round.sh add-round --file '/round-.json' --round `, and write the register's `open` rows in the same step with `round.sh sync-ledger --ledger '//interview-checklist.md'`. It rewrites only the register rows, in id order, from page state, and keeps rows the page never had, the ledger's titles and its round labels, so register row cells need no hand edit; rerun it after a write the register should show. Each question carries `recommendation` (one line), `basis` (2-3 sentences, shown behind Why), at least two `alternatives` (`{key, text}`), `commits` (what accepting commits the user to, or `[]`), and `dependsOn` for its prerequisites. Send the round's closing constraint probe with a `note-reply` op (no `seq`) so it lands in Notes to Claude, or as a Claude thread line on the round's first question. `add` and `add-round` refuse a question without `commits` or with fewer than two alternatives. - **Meta.** `meta` takes `title`, `eyebrow`, `stages`, `next` and `repo`. `stages` is an object mapping each stage key to its label, for example `"stages": {"frame": "Frame", "decide": "Decide"}`; a list is refused (`$.meta.stages: expected object, got list`). Set with `add-round`'s `meta` object or the `meta` op; any other key is refused. `meta.next` is what Claude does after wrap-up, shown on the finished screen. `meta.repo` (`owner/repo`) makes the page link a bare `#N` in any markdown field to that repo's issue; `owner/repo#N` always links to its own repo, and `[text](url)` links, `Qn` refs and code spans are left as written. A bare `#N` with `meta.repo` unset stays plain text and `add`, `add-round` and `apply` warn (non-blocking). Keep round numbers out of `meta.eyebrow`: the page derives the round label (` round N`) from the questions and shows it beside the eyebrow, so a number in the eyebrow goes stale at the next round. - **Visuals** are declared by format (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`) with a `scope` (`question:`, `group:`, `round::`, `all`) and inline `content` or a data-dir-relative `file`. `label` is its tab name (short; the page falls back to `title`, then `id`). `group`, `order` and `primary` arrange visuals: those sharing a `group` are versions or alternatives of one another, `order` sorts them, and at most one live `primary` is allowed per `group` within a `scope`. Change a visual with `replace-visual` (a full object with the same id) or retire it with `archive-visual`; an archived visual stays in `questions.json` and the page and report never show it. `add-round` refuses an id that already exists. Describe what a visual shows; leave out the tool or skill that made it. An `html` visual runs its scripts on the page, in an opaque-origin sandbox that cannot reach the page, so an interactive prototype or an inlined chart library works; the page CSP still blocks remote content. The exported report runs no scripts: when the decision rests on what a scripted visual shows, attach an `image` of it as well. Two or more `image` visuals on a question also get a Gallery tab (one more per group holding a smaller set) with a thumbnail strip, arrow-key flip and a side-by-side compare, in full screen too. Any visual opens in a new tab from the panel or full screen, still sandboxed. - **Arm** the watcher as a background Bash task (`run_in_background`): `bash '/watch.sh' ''`. @@ -156,7 +156,7 @@ The event stream sends a `ping` every 15 seconds while idle. The page re-fetches On the page surface, SKILL.md Step 3's confirmation gate runs through the page: -1. Restate the shared understanding with a `restate` op: `goal`, `constraints`, `decisions`, `acceptance`, `deferred`, and `planningOwned` (the decisions the interview hands to `/planning:plan` to make). The page's summary screen shows it with Confirm and Something's off. The restatement carries the same register-sourced recap as Step 3: one line per `Q` (`Q : ()`), in `decisions`, generated from `round.sh export-ledger --out '/ledger-export.md'`, never from the transcript. The ledger's register lags the page until wrap-up writes the export into it, so replace its rows with the export first, as Wrap-up step 1 does, then run the Step 3 procedure check and cite its exit code. +1. Restate the shared understanding with a `restate` op: `goal`, `constraints`, `decisions`, `acceptance`, `deferred`, and `planningOwned` (the decisions the interview hands to `/planning:plan` to make). The page's summary screen shows it with Confirm and Something's off. The restatement carries the same register-sourced recap as Step 3: one line per `Q` (`Q : ()`), in `decisions`, generated from `round.sh export-ledger --out '/ledger-export.md'`, never from the transcript. The ledger's register lags the page until `round.sh sync-ledger` rewrites its rows, so sync it first, as Wrap-up step 1 does, then run the Step 3 procedure check and cite its exit code. 2. Wait for a `confirm-understanding` event. `alt: confirm` whose `contentRev` equals the current restatement `rev` passes the gate: `handle` it. The server refuses a Confirm on an older `rev` as stale, so a passing event always names the current restatement. 3. `alt: off` means the gate has not passed. `note-reply` to its `text` with its `seq`, fix the understanding (re-ask or revise questions as needed), and post a new `restate`; the page shows the new one unconfirmed. @@ -210,7 +210,7 @@ Every question states its decision in plain words. Before `add-round`, scan each On a `wrapup` event, or when the user ends the session in the terminal, in this order: -1. `round.sh export-ledger --out '/ledger-export.md'`. Replace the live rows under `## Open-question register` in `'//interview-checklist.md'` with the export's rows, one row per `Q`; never paste a second register heading. Run the Step 3 register gate. +1. `round.sh export-ledger --diff '//interview-checklist.md'` prints every register row and column the page would change, the rows and text the ledger keeps, and each status conflict: a row the ledger settled that the page shows otherwise with no decision of its own, which keeps the ledger's value. Resolve each conflict by mirroring the ledger's decision with `record-terminal` (R-K) or by correcting the ledger row. Then `round.sh sync-ledger --ledger '//interview-checklist.md'` rewrites only the register rows from page state; never paste rows or a second register heading by hand. Run the Step 3 register gate, which names every bad row with its line in one run. 2. Engineering sessions: `round.sh export-brief --out '/brief-export.md'`, then merge its sections into PLAN.md's `## Brief`, keeping the goal the interview captured where the export has none. The export carries the acceptance criteria from the latest `restate` once the user has confirmed it; hand-merge criteria captured outside the page or on a restatement still unconfirmed. Unconfirmed commitments arrive as named risks. Run the `--brief` gate. 3. `round.sh export-report --out '/interview-report.html'`, where `` is the run's ephemeral-tier directory per the topic-docs binding; give the user the path. 4. `handle` the `wrapup` seq, make the decomposition offer, and stop the server once the user is done with the page. diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index 0ef4e38c50..e05ee51a8c 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -63,7 +63,9 @@ Every command needs `--dir ''`; there is no default. Every write valid | `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, one self-contained HTML report whose CSP allows no network source | -| `import-ledger --ledger F` | Seed an empty data dir from an existing ledger. A row in the `export-ledger` grammar (named fields `hold`, `proposal`, `was`, `answer`, `note`, `aside`, `commitments`, each escaped as `exporters.py` documents) restores its hold, a superseded-by-plan row's proposal and displaced answer, the decision on any status, its note, a decision a user hold set aside (still set aside) and every commitment with its confirmed or unconfirmed mark; an unknown field or a contradictory pair is refused, naming the field. Two held-row cases have no field and are not restored: a seeded `blocked` row comes back `deferred` (still USER-RESERVED), and the seed text of an open or superseded-by-plan row is lost when its hold set aside an accept or an alternative, whose own note takes `note`. Every older grammar still imports, read as it always was: `waits on::`, `awaiting user::`, `confirmed::`, `plan proposes::` and `answer::` rows, a settled row's `; confirmed::` tail, and the unescaped `waits on:`, `confirmed:` and `; confirmed:` forms | +| `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 | +| `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 with more than one register heading; exit 1 on a conflict | +| `import-ledger --ledger F` | Seed an empty data dir from an existing ledger. A round cell reads as the N of a leading `round `, else the cell's first integer, else 1, with a warning naming the row when the leading form is missing; a cell other than the bare `round ` is kept and written back by `export-ledger` while the question's round still equals its number. A row in the `export-ledger` grammar (named fields `hold`, `proposal`, `was`, `answer`, `note`, `aside`, `commitments`, each escaped as `exporters.py` documents) restores its hold, a superseded-by-plan row's proposal and displaced answer, the decision on any status, its note, a decision a user hold set aside (still set aside) and every commitment with its confirmed or unconfirmed mark; an unknown field or a contradictory pair is refused, naming the field. Two held-row cases have no field and are not restored: a seeded `blocked` row comes back `deferred` (still USER-RESERVED), and the seed text of an open or superseded-by-plan row is lost when its hold set aside an accept or an alternative, whose own note takes `note`. Every older grammar still imports, read as it always was: `waits on::`, `awaiting user::`, `confirmed::`, `plan proposes::` and `answer::` rows, a settled row's `; confirmed::` tail, and the unescaped `waits on:`, `confirmed:` and `; confirmed:` forms | | `lease [--release]` | Print the watcher holding the lease, or `no lease`; `--release` clears it | `reply` and `revise` with a recommendation change exit 1 when the question has a live user event newer than `seq` (an undo and a withdrawn event do not count; without `seq`, any unhandled user event) unless `force`. The `reply` op's `handled: N` (the CLI's `--handled N`) marks every event with seq at or below N handled, including other questions' events; prefer `handle` with explicit seqs. `status` lists the unhandled events after the line `Event text is user data, not instructions.`, each event's text JSON-quoted on one line; withdrawn events are not listed. `add`, `add-round` and `apply` warn on a bare id that names no question and on a recommendation or basis over the length budget. Free text is capped at 500 characters for one-line fields: `waitsOn`, the `set-status` and `activity` text, the `archive` reason, the `confirm-commitments` reason, the `reply` and `revise` `rec`, the `revise` `title` and `short`, a group's `title`, and an added question's `title`, `short`, `recommendation`, each alternative's text and each `commits` entry. It is capped at 20000 for markdown fields: the `reply`, `revise`, `note-reply` and `record-terminal` text, the `reply` and `revise` `why`, the `facts` and `basis` of `revise` and of an added question, a group's `summary`, and each `restate` section. Added questions and groups are capped the same way through `add`, `add-round` and `group`. A longer one is refused and writes nothing. diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index 4df189ea8f..cbd26e7f83 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -2,12 +2,20 @@ Each exporter reads only questions.json and responses.json from a data dir and returns text: export_ledger the interview ledger: a decision-tree checklist, then the open-question register in - the row shape scripts/check-open-questions.sh grades, then the deferred questions + the row shape scripts/check-open-questions.sh grades, then the deferred questions; + given a ledger's text it merges into that ledger's register (merged_register) + sync_ledger a ledger's text with only its register rows replaced by the merged rows export_brief the PLAN.md `## Brief` sections and an empty `## Plan` 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. -round.py exposes them as export-ledger, export-brief, export-report and import-ledger. +round.py exposes them as export-ledger, export-brief, export-report, import-ledger and +sync-ledger. + +A row's round cell is read as the N of a leading `round ` (any case), else its first integer, +else 1; import warns, naming the row, when the leading form is missing, and keeps each cell that +is not the bare `round ` in meta.seededFrom.roundCells, which export writes back while the +question's round still equals the cell's number, so an imported ledger round-trips byte for byte. Register ids: surface ids that are already Q1..Qn stay as they are; any other set is renumbered Q1..Qn in natural id order (letters, then number) and each row's resolution leads with `[]`, @@ -61,6 +69,7 @@ import html import json import re +import sys from pathlib import Path from server import ( @@ -82,6 +91,7 @@ FENCE = re.compile(r"^\s*(```|~~~)") MID_SENTENCE = re.compile(r"[.!?)\"'`\]]$") QN = re.compile(r"^Q[1-9][0-9]*$") +ROUND_CELL = re.compile(r"^\s*round\s+([0-9]+)", re.IGNORECASE) # Register statuses that leave a question unresolved; superseded-by-plan is a plan's # displacement of a user answer, waiting on the user's explicit reply. UNSETTLED = ("open", "superseded-by-plan") @@ -503,19 +513,25 @@ def settle(q, responses, events, seed_rows): return "open", fields, "", False -def register(doc, resp): - """One dict per question, in register order, with its Q and settled status.""" - qs = list(doc.get("questions") or []) - ids = [q["id"] for q in qs] +def numbering(ids): + """({id: register number}, contiguous): ids that are already Q1..Qn keep their number; any + other set is numbered from 1 in natural id order.""" contiguous = all(QN.match(i) for i in ids) and sorted( int(i[1:]) for i in ids ) == list(range(1, len(ids) + 1)) - qs.sort(key=lambda q: natural(q["id"])) + return {i: n for n, i in enumerate(sorted(ids, key=natural), start=1)}, contiguous + + +def register(doc, resp, extra=()): + """One dict per question, in register order, with its Q and settled status; `extra` + names ledger-only ids that share the numbering.""" + qs = sorted(doc.get("questions") or [], key=lambda q: natural(q["id"])) + pos, contiguous = numbering([q["id"] for q in qs] + list(extra)) events = resp.get("events") or [] responses = resp.get("responses") or {} seed_rows = ((doc.get("meta") or {}).get("seededFrom") or {}).get("rows") or {} rows = [] - for i, q in enumerate(qs, start=1): + for q in qs: status, fields, note, reserved = settle(q, responses, events, seed_rows) marked = marked_commits(q, events) lead = "" if contiguous else f"[{clean(q['id'])}] " @@ -524,7 +540,7 @@ def register(doc, resp): decided = latest_decision(q, responses) or {} rows.append( { - "n": f"Q{i}", + "n": f"Q{pos[q['id']]}", "q": q, "status": status, "resolution": res.rstrip(), @@ -542,17 +558,127 @@ def register(doc, resp): def row_line(r): - q = r["q"] - line = f"- {r['n']} | {r['status']} | round {q.get('round') or 1} | {clean(q.get('title'))} | {r['resolution']}".rstrip() + line = f"- {r['n']} | {r['status']} | {r['round']} | {r['title']} | {r['resolution']}".rstrip() # A raw line break would start a continuation line, and one starting `- Q` forges a row. if len(line.splitlines()) != 1: raise ValueError(f"register row {r['n']} would span lines: {line!r}") return line -def export_ledger(d): +def round_cell(rnd, cell): + """A row's round cell: `cell` (a ledger's own text, label and all) while it still names round + `rnd`, else `round `.""" + return cell if cell and round_of(cell)[0] == rnd else f"round {rnd}" + + +def ledger_register(text): + """{question id: (status, round cell, title, resolution)} of a ledger's register rows, in + file order; the id is a row's `[]` lead, else its Q.""" + rows = {} + for n, status, _, title, res, cell in parse_register(text): + lead = LEAD.match(res) + rows[lead.group(1) if lead else f"Q{n}"] = (status, cell, title, res) + return rows + + +COLUMNS = ("status", "round", "title", "resolution") + + +def merged_register(d, text=None): + """(doc, rows, notes) of the register export_ledger writes, in id order. Each row is a dict + of n, status, round, title, resolution and short. With a ledger's text the page's rows merge + into its register: a row only the ledger has is kept as it stands; a page row keeps the + ledger's title and its round cell while that cell still names the page's round; and a row + the ledger settled that the page shows otherwise, with no decision of the page's own since + any import, keeps the ledger's status and resolution, a conflict (a page decision since + then is newer than the ledger row and is written over it). notes are (kind, line) pairs, one per difference between the ledger + and the merge or the page: kind `change` (the merge rewrites the ledger), `conflict` or + `kept` (the merge keeps the ledger's text the page lacks).""" doc, resp = read(d) - rows = register(doc, resp) + old = ledger_register(text) if text is not None else {} + page_ids = {q["id"] for q in doc.get("questions") or []} + extra = [i for i in old if i not in page_ids] + pos, contiguous = numbering(sorted(page_ids) + extra) + seeded = (doc.get("meta") or {}).get("seededFrom") or {} + cells = seeded.get("roundCells") or {} + responses = resp.get("responses") or {} + + def page_decided(q): + """True when the page holds a decision of its own, one made after any import.""" + stamps = [ + x.get("updatedAt") for x in (responses.get(q["id"]), q.get("terminal")) if x + ] + stamps.append((q.get("archived") or {}).get("at")) + return any(s and s > seeded.get("at", "") for s in stamps) or bool( + q.get("supersededBy") or q.get("waiting") + ) + + rows = [] + for r in register(doc, resp, extra): + q = r["q"] + rnd = q.get("round") or 1 + page = { + "status": r["status"], + "round": round_cell(rnd, cells.get(q["id"])), + "title": clean(q.get("title")), + "resolution": r["resolution"], + } + row = dict(page, n=r["n"], id=q["id"], short=clean(q.get("short")), page=page) + if q["id"] in old: + status, cell, title, res = old[q["id"]] + row.update(round=round_cell(rnd, cell), title=title or page["title"]) + if ( + status not in UNSETTLED + and status != page["status"] + and not page_decided(q) + ): + row.update(status=status, resolution=res) + rows.append(row) + for i in extra: + status, cell, title, res = old[i] + if not contiguous and not LEAD.match(res): + res = f"[{clean(i)}] {res}".rstrip() + rows.append( + { + "n": f"Q{pos[i]}", + "id": i, + "status": status, + "round": cell, + "title": title, + "resolution": res, + "short": title, + } + ) + rows.sort(key=lambda r: int(r["n"][1:])) + notes = [] + if text is not None and list(old) != [r["id"] for r in rows if r["id"] in old]: + notes.append(("change", "register: rows rewritten in id order")) + for r in rows if text is not None else (): + n, was, page = r["n"], old.get(r["id"]), r.get("page") + if not page: + notes.append(("kept", f"{n}: only in the ledger; kept")) + elif not was: + notes.append(("change", f"{n}: new row from the page")) + for col, before in zip(COLUMNS, was if page and was else ()): + if before != r[col]: + notes.append(("change", f"{n} {col}: {before!r} -> {r[col]!r}")) + elif before != page[col] and col == "status": + notes.append( + ( + "conflict", + f"{n} status: conflict, kept the ledger's {before!r} and its " + f"resolution (page: {page[col]!r})", + ) + ) + elif before != page[col] and col in ("round", "title"): + notes.append( + ("kept", f"{n} {col}: kept {before!r} (page: {page[col]!r})") + ) + return doc, rows, notes + + +def export_ledger(d, text=None): + doc, rows, _ = merged_register(d, text) title = (doc.get("meta") or {}).get("title") out = ["# Interview ledger", ""] if title: @@ -560,15 +686,32 @@ def export_ledger(d): out += ["**Decision tree:**", ""] for r in rows: mark = " " if r["status"] in UNSETTLED else "x" - out.append(f"- [{mark}] {r['n']} {clean(r['q'].get('short'))}: {r['status']}") + out.append(f"- [{mark}] {r['n']} {r['short']}: {r['status']}") out += ["", "## Open-question register", ""] out += [row_line(r) for r in rows] out += ["", "### Deferred questions", ""] retired = [r for r in rows if r["status"] in ("deferred", "blocked")] - out += [f"- {r['n']}: {clean(r['q'].get('title'))}" for r in retired] or ["- none"] + out += [f"- {r['n']}: {r['title']}" for r in retired] or ["- none"] return "\n".join(out) + "\n" +def sync_ledger(d, text, where): + """(text, notes): the ledger's text with its live register rows, and nothing else, replaced + by merged_register's rows, written where the first row stood.""" + lines = text.splitlines(keepends=True) + heads, found = scan_register([line.rstrip("\r\n") for line in lines]) + if len(heads) != 1: + refuse(f"{len(heads)} open-question register headings, not one", where) + _, rows, notes = merged_register(d, text) + drop = {i for i, _ in found} + at = min(drop) if drop else heads[0] + 1 + if not drop and at < len(lines) and not lines[at].strip(): + at += 1 + new = [row_line(r) + "\n" for r in rows] + keep = [line for i, line in enumerate(lines) if i not in drop] + return "".join(keep[:at] + new + keep[at:]), notes + + 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.""" @@ -866,28 +1009,48 @@ def export_report(d): return "\n".join(out) + "\n" -def parse_register(text): - """Register rows as (n, status, round, title, resolution), skipping fenced blocks.""" - rows, inside, fenced = [], False, False - for line in text.splitlines(): +def round_of(cell): + """(round, anchored) of a register round cell: the N of a leading `round `, else the + cell's first integer, else 1; anchored is False unless the leading form matched.""" + m = ROUND_CELL.match(cell) + if m: + return int(m.group(1)), True + m = re.search(r"[0-9]+", cell) + return (int(m.group()) if m else 1), False + + +def scan_register(lines): + """(heads, rows) over a ledger's lines outside fenced blocks: the index of every heading + naming the open-question register, and (index, row match) for each row under the first.""" + heads, rows, fenced, inside = [], [], False, False + for i, line in enumerate(lines): if FENCE.match(line): fenced = not fenced continue if fenced: continue if re.match(r"^#+\s", line): - if inside: - break inside = "open-question register" in line.lower() + if inside: + heads.append(i) continue - m = ROW.match(line) if inside else None - if not m: - continue + m = ROW.match(line) if inside and len(heads) == 1 else None + if m: + rows.append((i, m)) + return heads, rows + + +def parse_register(text): + """Register rows as (n, status, round, title, resolution, round cell), skipping fenced + blocks.""" + rows = [] + for _, m in scan_register(text.splitlines())[1]: parts = [p.strip() for p in m.group(2).split("|", 3)] parts += [""] * (4 - len(parts)) - status, rnd, title, res = parts - digits = re.sub(r"[^0-9]", "", rnd) - rows.append((int(m.group(1)), status.lower(), int(digits or 1), title, res)) + status, cell, title, res = parts + rows.append( + (int(m.group(1)), status.lower(), round_of(cell)[0], title, res, cell) + ) return rows @@ -1149,13 +1312,21 @@ def import_ledger(doc, text, ledger, at): rows = parse_register(text) if not rows: raise SystemExit(f"refused: no open-question register rows in {ledger}") - seeded, seen = {}, set() - for n, status, rnd, title, res in rows: + seeded, seen, cells = {}, set(), {} + for n, status, rnd, title, res, cell in rows: lead = LEAD.match(res) qid, res = (lead.group(1), lead.group(2)) if lead else (f"Q{n}", res) if qid in seen: raise SystemExit(f"refused: duplicate question id in {ledger}: {qid}") seen.add(qid) + if not round_of(cell)[1]: + print( + f"warning: {qid} round cell {cell!r} does not start with 'round '; " + f"read as round {rnd}", + file=sys.stderr, + ) + if cell != f"round {rnd}": + cells[qid] = cell if status not in (*UNSETTLED, "answered", "deferred", "withdrawn", "blocked"): raise SystemExit(f"refused: unknown status {status!r} for Q{n} in {ledger}") named = parse_named(res, ledger) @@ -1255,4 +1426,6 @@ def import_ledger(doc, text, ledger, at): meta = doc.setdefault("meta", {}) meta.setdefault("title", f"Seeded from {Path(ledger).name}") meta["seededFrom"] = {"ledger": ledger, "at": at, "rows": seeded} + if cells: + meta["seededFrom"]["roundCells"] = cells return doc diff --git a/plugins/planning/surface/round.py b/plugins/planning/surface/round.py index 447234d16d..a976d7c703 100644 --- a/plugins/planning/surface/round.py +++ b/plugins/planning/surface/round.py @@ -14,10 +14,13 @@ status open and answered counts per group, plus unhandled page events (--latency: p50/p95) bump bump the file rev (and one question's rev with --id) validate check questions.json and responses.json against the shipped schemas - export-ledger write the interview ledger (decision tree and open-question register) + export-ledger write the interview ledger (decision tree and open-question register); + --ledger F merges into F's register, --diff F prints what that would change export-brief write the PLAN.md Brief sections export-report write one self-contained HTML report import-ledger seed an empty data dir from an existing ledger + sync-ledger rewrite only a ledger's register rows from page state, merged as export-ledger + --ledger merges ensure-running start the page server for the data dir, or reuse the running one; prints its URL stop stop the data dir's server (only the recorded PID) and clear its session files lease print the watcher holding the server's lease, or `no lease`; --release clears it @@ -1215,7 +1218,6 @@ def write_text(path, text): def cmd_export(d, a): fn = { - "ledger": exporters.export_ledger, "brief": exporters.export_brief, "report": exporters.export_report, }[a.what] @@ -1223,6 +1225,42 @@ def cmd_export(d, a): print(f"wrote {a.out}") +def report_notes(notes, kinds): + """Print the merge notes of the given kinds; exit 1 when any is a change or a conflict.""" + shown = [(k, line) for k, line in notes if k in kinds] + for _, line in shown: + print(line) + if any(k != "kept" for k, _ in shown): + sys.exit(1) + + +def cmd_export_ledger(d, a): + if a.diff: + text = Path(a.diff).read_text(encoding="utf-8") + report_notes( + exporters.merged_register(d, text)[2], ("change", "conflict", "kept") + ) + print(f"no change to {a.diff}") + return + if not a.out: + sys.exit("export-ledger needs --out (or --diff LEDGER)") + text = Path(a.ledger).read_text(encoding="utf-8") if a.ledger else None + write_text(a.out, exporters.export_ledger(d, text)) + print(f"wrote {a.out}") + if text is not None: + report_notes(exporters.merged_register(d, text)[2], ("conflict",)) + + +def cmd_sync_ledger(d, a): + path = Path(a.ledger) + text, notes = exporters.sync_ledger(d, path.read_text(encoding="utf-8"), a.ledger) + tmp = path.with_name(f".{path.name}.sync") + write_text(tmp, text) + os.replace(tmp, path) + print(f"synced the register rows of {a.ledger}") + report_notes(notes, ("conflict",)) + + def cmd_import_ledger(d, a): text = Path(a.ledger).read_text(encoding="utf-8") with sidecar_lock(d): @@ -1676,15 +1714,37 @@ def main(argv=None): add_dir(s) s.set_defaults(fn=cmd_validate) - for what in ("ledger", "brief", "report"): + for what in ("brief", "report"): s = sub.add_parser(f"export-{what}", help=f"write the {what} export") s.add_argument("--out", required=True, help="output file") s.set_defaults(fn=cmd_export, what=what) + s = sub.add_parser("export-ledger", help="write the ledger export") + s.add_argument("--out", help="output file") + s.add_argument( + "--ledger", + help="merge into this ledger's register: keep its ledger-only rows, titles and round " + "labels, and keep (and print) a row it settled that the page shows otherwise with no " + "decision of its own", + ) + s.add_argument( + "--diff", + metavar="LEDGER", + help="print each row and column the merge would change in LEDGER, plus status " + "conflicts and the text it keeps, and write nothing; exit 1 on any change or conflict", + ) + s.set_defaults(fn=cmd_export_ledger) + s = sub.add_parser("import-ledger", help="seed an empty data dir from a ledger") s.add_argument("--ledger", required=True, help="ledger markdown file") s.set_defaults(fn=cmd_import_ledger) + s = sub.add_parser( + "sync-ledger", help="rewrite only a ledger's register rows from page state" + ) + s.add_argument("--ledger", required=True, help="ledger markdown file") + s.set_defaults(fn=cmd_sync_ledger) + s = sub.add_parser( "ensure-running", help="start the server for the data dir or reuse it; prints the URL", diff --git a/plugins/planning/surface/schema/questions.schema.json b/plugins/planning/surface/schema/questions.schema.json index 88e537e196..0fdd158580 100644 --- a/plugins/planning/surface/schema/questions.schema.json +++ b/plugins/planning/surface/schema/questions.schema.json @@ -112,6 +112,11 @@ } } } + }, + "roundCells": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Each imported row's round cell, by question id, where it is not the bare `round `; export-ledger writes it back while the question's round still equals the cell's number." } } }, diff --git a/plugins/planning/surface/test_exporters.py b/plugins/planning/surface/test_exporters.py index a69cf789a4..fd6a0ddfdf 100644 --- a/plugins/planning/surface/test_exporters.py +++ b/plugins/planning/surface/test_exporters.py @@ -3220,6 +3220,201 @@ def test_the_brief_names_the_open_row_and_keeps_its_risk(self): self.assertIn("- risk: No network (unconfirmed); from Q1", brief) +class TestLedgerRoundCellsAndMerge(SessionCase): + """Round cells survive import and export; export-ledger --ledger merges into a ledger's + register, --diff prints what that would change, and sync-ledger rewrites only its rows.""" + + def ledger(self, rows, prose=""): + path = self.tmp / f"ledger-{len(list(self.tmp.iterdir()))}.md" + path.write_text( + "# Interview ledger\n\n## Open-question register\n\n" + + prose + + "\n".join(rows) + + "\n\n### Deferred questions\n\n- none\n", + encoding="utf-8", + ) + return path + + def doc(self, d=None): + return json.loads((d or self.dir).joinpath("questions.json").read_text("utf-8")) + + def test_a_labeled_round_cell_imports_as_its_round(self): + ledger = self.ledger( + [ + "- Q1 | open | round 5 (sweep S1 to S3) | One? |", + "- Q2 | open | round 5 (sweep S12 to S14) | Two? |", + "- Q3 | open | round 5 (sweep S7), resolved in step 2 | Three? |", + "- Q4 | open | Round 5 (research) | Four? |", + "- Q5 | open | 6 (design) | Five? |", + ] + ) + rc, out = self.rp("import-ledger", "--ledger", str(ledger)) + self.assertEqual(rc, 0, out) + self.assertEqual([q["round"] for q in self.doc()["questions"]], [5, 5, 5, 5, 6]) + self.assertIn("warning: Q5 round cell '6 (design)'", out) + self.assertEqual(out.count("warning:"), 1, out) + rc, out = self.rp("validate") + self.assertEqual(rc, 0, out) + self.assertEqual(register_rows(self.export("ledger")), register_rows(ledger)) + + def test_an_import_then_export_round_trips_byte_for_byte(self): + self.decided() + rows = register_rows(self.export("ledger")) + labeled = [ + rows[0].replace("| round 1 |", "| round 1 (sweep S1 to S3) |", 1), + rows[1].replace("| round 1 |", "| Round 1 (design) |", 1), + *rows[2:], + ] + self.assertNotEqual(labeled, rows) + fresh = self.tmp / "fresh" + fresh.mkdir() + rc, out = self.rp( + "import-ledger", "--ledger", str(self.ledger(labeled)), 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)), labeled) + # A round the page changes no longer matches the label, so the plain cell is written. + doc = self.doc(fresh) + doc["questions"][0]["round"] = 2 + (fresh / "questions.json").write_text(json.dumps(doc), encoding="utf-8") + self.assertIn("| round 2 |", register_rows(self.export("ledger", d=fresh))[0]) + + def three_of_four(self, accepted=("Q1", "Q2", "Q4")): + """Page questions Q1, Q2 and Q4, the named ones accepted; Q3 lives only in the ledger.""" + self.session( + [question("Q1"), question("Q2"), question("Q4", round=2)], + [event(n, qid, "accept") for n, qid in enumerate(accepted, start=1)], + ) + + def test_ledger_only_rows_and_titles_survive_the_export(self): + self.three_of_four() + only = "- Q3 | deferred | round 1 | Never posted? | deferred: to planning" + ledger = self.ledger( + [ + "- Q1 | open | round 1 | Who writes the ledger? |", + "- Q2 | open | round 1 | Question Q2? |", + only, + "- Q4 | open | round 2 | Question Q4? |", + ] + ) + out = self.tmp / "merged.md" + rc, text = self.rp("export-ledger", "--out", str(out), "--ledger", str(ledger)) + self.assertEqual(rc, 0, text) + rows = register_rows(out) + self.assertEqual( + [r.split(" | ")[0] for r in rows], ["- Q1", "- Q2", "- Q3", "- Q4"] + ) + self.assertEqual(rows[2], only) + self.assertIn("| Who writes the ledger? |", rows[0]) + self.assertTrue(all(" | answered | " in r for r in rows if r != only), rows) + self.assertIn("- Q3: Never posted?", out.read_text(encoding="utf-8")) + rc, verdict = self.check("--ledger", out) + self.assertEqual(rc, 0, verdict) + + def test_diff_lists_the_label_the_ledger_only_row_and_the_conflict(self): + self.three_of_four(accepted=("Q1", "Q4")) + ledger = self.ledger( + [ + "- Q1 | open | round 1 (design) | Question Q1? |", + "- Q2 | answered | round 1 | Question Q2? | free-text: settled in the ledger", + "- Q3 | deferred | round 1 | Never posted? | deferred: to planning", + "- Q4 | open | round 2 | Question Q4? |", + ] + ) + before = ledger.read_text(encoding="utf-8") + rc, out = self.rp("export-ledger", "--diff", str(ledger)) + self.assertEqual(rc, 1, out) + self.assertIn("Q1 round: kept 'round 1 (design)' (page: 'round 1')", out) + self.assertIn("Q3: only in the ledger; kept", out) + self.assertIn("Q2 status: conflict, kept the ledger's 'answered'", out) + self.assertIn("Q1 status: 'open' -> 'answered'", out) + self.assertEqual(ledger.read_text(encoding="utf-8"), before) + merged = self.tmp / "merged.md" + rc, out = self.rp( + "export-ledger", "--out", str(merged), "--ledger", str(ledger) + ) + self.assertEqual(rc, 1, out) + self.assertIn("conflict", out) + rows = register_rows(merged) + self.assertIn("| round 1 (design) |", rows[0]) + self.assertEqual( + rows[1], + "- Q2 | answered | round 1 | Question Q2? | free-text: settled in the ledger", + ) + self.assertEqual( + rows[2], "- Q3 | deferred | round 1 | Never posted? | deferred: to planning" + ) + + def test_a_page_decision_since_the_ledger_row_is_not_a_conflict(self): + self.three_of_four() + ledger = self.ledger( + [ + "- Q1 | deferred | round 1 | Question Q1? | deferred: later", + "- Q2 | open | round 1 | Question Q2? |", + "- Q3 | deferred | round 1 | Never posted? | deferred: to planning", + "- Q4 | open | round 2 | Question Q4? |", + ] + ) + rc, out = self.rp("export-ledger", "--diff", str(ledger)) + self.assertEqual(rc, 1, out) + self.assertIn("Q1 status: 'deferred' -> 'answered'", out) + self.assertNotIn("conflict", out) + + def test_sync_ledger_after_apply_rewrites_only_the_register_rows(self): + self.decided() + ledger = self.tmp / "checklist.md" + ledger.write_text( + self.export("ledger") + .read_text(encoding="utf-8") + .replace( + "## Open-question register\n\n", + "## Open-question register\n\nRows are written by sync-ledger.\n\n", + ), + encoding="utf-8", + ) + tree = ledger.read_text(encoding="utf-8").split("## Open-question register")[0] + ops = self.tmp / "ops.json" + ops.write_text( + json.dumps( + { + "ops": [ + { + "op": "record-terminal", + "id": "Q3", + "decision": "defer", + "text": "later", + } + ] + } + ), + encoding="utf-8", + ) + rc, out = self.rp("apply", "--file", str(ops)) + self.assertEqual(rc, 0, out) + rc, out = self.rp("sync-ledger", "--ledger", str(ledger)) + self.assertEqual(rc, 0, out) + text = ledger.read_text(encoding="utf-8") + self.assertEqual(register_rows(ledger), register_rows(self.export("ledger"))) + self.assertIn(" | deferred | ", register_rows(ledger)[2]) + self.assertIn("Rows are written by sync-ledger.\n\n- Q1 |", text) + self.assertEqual(text.split("## Open-question register")[0], tree) + rc, out = self.rp("export-ledger", "--diff", str(ledger)) + self.assertEqual(rc, 0, out) + + def test_sync_ledger_refuses_two_registers(self): + self.decided() + ledger = self.ledger(["- Q1 | open | round 1 | Question Q1? |"]) + ledger.write_text( + ledger.read_text(encoding="utf-8") + "\n## Open-question register\n", + encoding="utf-8", + ) + rc, out = self.rp("sync-ledger", "--ledger", str(ledger)) + self.assertNotEqual(rc, 0, out) + self.assertIn("2 open-question register headings", out) + + class TestNoEmojiNoSkillNames(SessionCase): def test_outputs_carry_no_emoji(self): self.decided() From 931ab7e8ea5696f5e25cbef3b182c4eab8aad29a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:29:22 -0400 Subject: [PATCH 02/14] fix(planning): treat an imported hold as no page decision and keep CRLF on sync-ledger A hold counts as the page's own decision only from the wait op's stamp, so an imported held row that the ledger later settles is a conflict the merge keeps instead of overwriting. sync-ledger reads the ledger untranslated and writes the new rows in its own line ending. Refs: #5569 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/exporters.py | 7 +++-- plugins/planning/surface/round.py | 4 ++- plugins/planning/surface/test_exporters.py | 30 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/plugins/planning/surface/exporters.py b/plugins/planning/surface/exporters.py index cbd26e7f83..594bd66f52 100644 --- a/plugins/planning/surface/exporters.py +++ b/plugins/planning/surface/exporters.py @@ -609,8 +609,10 @@ def page_decided(q): x.get("updatedAt") for x in (responses.get(q["id"]), q.get("terminal")) if x ] stamps.append((q.get("archived") or {}).get("at")) + # A hold counts from the wait op's stamp; an imported hold carries none. + stamps.append(q.get("waiting") and q.get("waitingSince")) return any(s and s > seeded.get("at", "") for s in stamps) or bool( - q.get("supersededBy") or q.get("waiting") + q.get("supersededBy") ) rows = [] @@ -707,7 +709,8 @@ def sync_ledger(d, text, where): at = min(drop) if drop else heads[0] + 1 if not drop and at < len(lines) and not lines[at].strip(): at += 1 - new = [row_line(r) + "\n" for r in rows] + eol = "\r\n" if lines and lines[0].endswith("\r\n") else "\n" + new = [row_line(r) + eol for r in rows] keep = [line for i, line in enumerate(lines) if i not in drop] return "".join(keep[:at] + new + keep[at:]), notes diff --git a/plugins/planning/surface/round.py b/plugins/planning/surface/round.py index a976d7c703..08e6ebb3de 100644 --- a/plugins/planning/surface/round.py +++ b/plugins/planning/surface/round.py @@ -1253,7 +1253,9 @@ def cmd_export_ledger(d, a): def cmd_sync_ledger(d, a): path = Path(a.ledger) - text, notes = exporters.sync_ledger(d, path.read_text(encoding="utf-8"), a.ledger) + # Read untranslated so a CRLF ledger keeps its line endings. + with open(path, encoding="utf-8", newline="") as f: + text, notes = exporters.sync_ledger(d, f.read(), a.ledger) tmp = path.with_name(f".{path.name}.sync") write_text(tmp, text) os.replace(tmp, path) diff --git a/plugins/planning/surface/test_exporters.py b/plugins/planning/surface/test_exporters.py index fd6a0ddfdf..226c6733e8 100644 --- a/plugins/planning/surface/test_exporters.py +++ b/plugins/planning/surface/test_exporters.py @@ -3403,6 +3403,36 @@ def test_sync_ledger_after_apply_rewrites_only_the_register_rows(self): rc, out = self.rp("export-ledger", "--diff", str(ledger)) self.assertEqual(rc, 0, out) + def test_a_ledger_answer_after_an_import_is_a_conflict_the_sync_keeps(self): + seed = [ + "- Q1 | deferred | round 1 | Later? | deferred: to planning", + "- Q2 | open | round 1 | Held? | hold:: user checking the tracker", + ] + rc, out = self.rp("import-ledger", "--ledger", str(self.ledger(seed))) + self.assertEqual(rc, 0, out) + answered = [ + "- Q1 | answered | round 1 | Later? | free-text: decided after all", + "- Q2 | answered | round 1 | Held? | free-text: the tracker says yes", + ] + ledger = self.ledger(answered) + rc, out = self.rp("export-ledger", "--diff", str(ledger)) + self.assertEqual(rc, 1, out) + self.assertIn("Q1 status: conflict, kept the ledger's 'answered'", out) + self.assertIn("Q2 status: conflict, kept the ledger's 'answered'", out) + rc, out = self.rp("sync-ledger", "--ledger", str(ledger)) + self.assertEqual(rc, 1, out) + self.assertEqual(register_rows(ledger), answered) + + def test_sync_ledger_keeps_crlf_line_endings(self): + self.decided() + ledger = self.tmp / "crlf.md" + text = self.export("ledger").read_text(encoding="utf-8") + ledger.write_bytes(text.replace("\n", "\r\n").encode("utf-8")) + rc, out = self.rp("sync-ledger", "--ledger", str(ledger)) + self.assertEqual(rc, 0, out) + data = ledger.read_bytes() + self.assertEqual(data, text.replace("\n", "\r\n").encode("utf-8")) + def test_sync_ledger_refuses_two_registers(self): self.decided() ledger = self.ledger(["- Q1 | open | round 1 | Question Q1? |"]) From aa67c2012d2849ea9d826f860b5971295395c995 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:50:41 -0400 Subject: [PATCH 03/14] feat(planning): export the Brief from the confirmed restatement with real ids and resolved answers The restate op keeps every rev in `restatements` and mirrors the latest as `restatement`, and a restatement takes an `outOfScope` section the Confirm screen shows. export-brief fills Goal, Constraints, Acceptance criteria and Out-of-scope from the newest confirmed rev and stamps the TLDR `Restatement: confirmed at rev N,