@bompus/house-rules 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,6 +236,7 @@ in name order, after the modifiers.
236
236
  | `backlog-outline` | A backlog report adds a Markdown outline of every open item grouped by what it waits on; nothing is rendered, so it reads the same on every host. |
237
237
  | `backlog-visual` | A backlog report adds one dark panel of every open item grouped by what it waits on, with a table for waiting items and one row per decision; hosts without a render tool get the same outline in Markdown. |
238
238
  | `coded-offers` | Offers use numbered questions and coded options (`1A`, `1B`) so one short reply answers every decision. |
239
+ | `compression-codecs` | Prefer zstd, then brotli, then gzip for data you write, with browser negotiation, gzip exceptions and measured levels. |
239
240
  | `cross-session-relay` | Relayed work between sessions keeps its source and authorized scope, and an authorized push that changes inherited guidance or tooling is announced to the affected sessions. |
240
241
  | `effort-estimates` | Options that differ in cost, or work that waits on CI, a build or a deploy, carry a wall-clock estimate based on comparable finished work. Estimates from workers, docs or other models are converted the same way or dropped. |
241
242
  | `land-when-done` | Authorized repository work is not finished until it is in the remote default branch. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bompus/house-rules",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "Composable working rules and skills for AI coding agents",
5
5
  "homepage": "https://github.com/bompus/house-rules#readme",
6
6
  "license": "MIT",
package/rules/core.md CHANGED
@@ -190,6 +190,8 @@ Transfer to another top-level conversation only at the user's direction, under a
190
190
 
191
191
  Keep independent repository edits on separate branches and isolated worktrees when a checkout may be shared. Leave other people's and sessions' edits alone.
192
192
 
193
+ Before creating a branch or worktree, read the default branch of the repository the change will land in (`gh repo view <owner>/<repo> --json defaultBranchRef` or `git ls-remote --symref <remote> HEAD`), fetch it and base the work on its tip. That repository is your fork when the change lands there and upstream when a pull request targets upstream. A branch name shown by a tool or harness is not evidence of the default branch, which need not be `main`. If the repository's own guidance names another base, follow it and say so.
194
+
193
195
  Before creating an issue or pull request, search the target repository's open and closed issues and pull requests for the same change. Reuse or coordinate with a match; otherwise link related work and say how yours differs. If you cannot search, say so instead of claiming no matches.
194
196
 
195
197
  Before submitting a pull request to an external upstream project, review the
@@ -223,9 +225,11 @@ At task completion, unlanded or uncommitted task changes get one landing option,
223
225
  Before integration, updating a default checkout or deleting a landed branch, read
224
226
  [the landing procedure](references/landing.md).
225
227
 
226
- Leave alone a checkout another
227
- session or job uses, or whose use you cannot tell. Never merge, reset, stash or
228
- switch it to make it match the default.
228
+ Never reset, stash, switch or merge-commit a checkout to make it match the default.
229
+ A session or job running in it is not a reason to wait or ask. A checkout of the
230
+ default branch that is strictly behind, with no uncommitted changes to tracked files, is fast-forwarded
231
+ with `git merge --ff-only <sha>`. Leave a dirty, diverged or mid-operation one, report its path and reason,
232
+ and do not ask the user.
229
233
 
230
234
  Before the first publish step of a version bump (tag, publish, release, announcement), review unreleased changelog entries and public materials (READMEs, charts, graphics with alt text, listing text, announcement) and fix what is stale first; an urgent release may limit only the materials to what it affects.
231
235
 
@@ -236,7 +240,7 @@ Remove a task worktree only when the default branch contains its work, its tree
236
240
  ## Coordination and isolation
237
241
 
238
242
  A coordination roster, where there is one, may omit sessions. Check checkout and
239
- branch ownership independently before treating them as free.
243
+ branch ownership independently before treating them as free to edit or remove.
240
244
 
241
245
  Stop only processes you started, by the PID you recorded when starting them.
242
246
  Never kill by pattern (`pkill -f`, `pgrep | kill`, or a PID found by matching
@@ -0,0 +1,55 @@
1
+ ---
2
+ description: Prefer zstd, then brotli, then gzip for data you write, with browser negotiation, gzip exceptions and measured levels.
3
+ after: Implementation economy
4
+ ---
5
+
6
+ ## Compression codecs
7
+
8
+ When you choose how to compress data a project writes itself (logs, snapshots,
9
+ caches, archives, API payloads), prefer zstd, then brotli, then gzip. Use the
10
+ first codec in that order that every writer and reader of the data supports. A
11
+ preference does not add a codec to a runtime. Node added `zlib.createZstdCompress`
12
+ in 22.15.0 and 23.8.0 and marks it experimental as of Node 26. Check the version
13
+ and the API before relying on it.
14
+
15
+ These figures are single runs on Node. They use three 10.5 MB log files. Zstd at level 9
16
+ compressed 10.0 to 13.2 times, brotli at 4 compressed 9.5 to 12.9 times and gzip at
17
+ 6 compressed 4.9 to 9.8 times. Compression took 9 to 14 ms at zstd 3, 24 to 33 ms
18
+ at brotli 4 and 40 to 85 ms at gzip 6. The runs did not time zstd 9 or measure the
19
+ ratio at zstd 3, so they do not show zstd winning on both size and speed at one level.
20
+
21
+ On JSON payloads of 0.1 to 7 MB, also single runs, zstd 3 compressed 1.7 to 2.3
22
+ times faster than brotli 4. Its output was 1% to 4% larger up to 1 MB and 19% larger
23
+ at 7 MB. All of this comes from a few files in one project, not a general claim.
24
+
25
+ Treat these levels as starting points and verify them on your data. Use zstd 3 on hot
26
+ paths and zstd 9 for cold data kept at rest. Use brotli 4 for responses compressed on
27
+ request and brotli 11 for static files compressed ahead of time. The runs above did not
28
+ measure brotli 11.
29
+
30
+ For HTTP responses to browsers, negotiate by `Accept-Encoding` instead of sending
31
+ zstd to every client. Pick the first of zstd, brotli and gzip that the header accepts. A coding
32
+ listed with `q=0` is not acceptable, so skip it. If none is acceptable, send the
33
+ response uncompressed unless the header also rules out `identity`, as `identity;q=0` does.
34
+ Then answer 406 Not Acceptable. Zstd content encoding needs Chrome 123, Edge 123
35
+ or Firefox 126, or later. Safari takes its support from the operating system. It has
36
+ zstd from Safari 26.3 on the 26.3 releases of iOS, iPadOS, visionOS and macOS Tahoe.
37
+ Safari on older releases lacks it, and Can I use shows macOS Safari as only partial. Smaller output matters more on high-latency or slow links, where
38
+ brotli can win despite slower compression.
39
+
40
+ Keep gzip where another tool must read the file: `zcat`, tarballs, npm and release
41
+ assets. WebSocket compression stays on `permessage-deflate`, the only extension
42
+ browsers offer; IANA registers no zstd or brotli extension.
43
+
44
+ Before choosing between codecs or changing a working one, measure on the real data.
45
+ Use `hr-benchmarking`'s defaults: 3 runs per codec, and 2 more when the gap is within
46
+ the run-to-run variation. The gain is output size for cold data and compression time
47
+ for hot paths. The other measure is a constraint, and it must not get worse outside the
48
+ run-to-run variation. Change a working codec only when the gain lies outside that
49
+ variation. Converting existing data is outside a task unless the task asks
50
+ for it.
51
+
52
+ References: [Node zlib](https://nodejs.org/api/zlib.html),
53
+ [MDN Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Encoding),
54
+ [WebKit features for Safari 26.3](https://webkit.org/blog/17798/webkit-features-for-safari-26-3/) and
55
+ [Can I use zstd](https://caniuse.com/zstd).
@@ -13,10 +13,10 @@ When a review bot reviews pull requests, merge only after it finishes; fix or
13
13
  answer all findings, resolve threads, and read the full review body.
14
14
 
15
15
  During integration, never switch, reset, force-move or remove the session's
16
- checkout. Default-branch updates must meet the fast-forward and ownership
16
+ checkout. Default-branch updates must meet the fast-forward
17
17
  conditions in § Landing. After the merge, fetch first; move off a landed branch
18
18
  only when its content is in the base and the tree is clean. When the base is
19
19
  checked out elsewhere, detach at the fetched base. Never merge, reset or stash
20
20
  to move the checkout. Leave app-managed checkouts for the app to retire.
21
- If the tree is dirty or ownership is uncertain, stop and record its owner.
21
+ If the tree is dirty, leave the checkout where it is and record the merged commit, its path and the blocker; the landing stands.
22
22
  Update the task record and follow § Cleanup after each landing.
@@ -19,8 +19,10 @@ issues invite, such as performance fixes or a reported defect.
19
19
  request. A behavior change, or a project that asks for prior approval, starts
20
20
  with a comment or discussion, not a pull request. Count the underlying
21
21
  problems and split the submission when the guide asks for one per request.
22
- 3. Meet § Repository work first: the search for existing reports, the review of
23
- the diff, the relevant checks, and the project's template and disclosure ask.
22
+ 3. Meet § Repository work first. Base the work on the default branch of the
23
+ repository the pull request targets. Search for existing reports, review the
24
+ diff, run the relevant checks, and follow the project's template and
25
+ disclosure ask.
24
26
  4. Show the user the exact text and channel once before posting. Posting is
25
27
  public and cannot be undone; this rule makes submitting the expected next
26
28
  step, not a waiver of that look. Only a user instruction naming the kind of
@@ -9,25 +9,34 @@ recommended option includes it, is that direction.
9
9
 
10
10
  At task completion, unlanded or uncommitted task changes get one landing option, `Land PR #<n>` or `Commit and land <change>`, never a bare done; only an explicit user deferral (recorded with branch, commits and reason) leaves it out. The option covers this section and § Cleanup, merge included; name only departures from that. Land through the repository's required path without bypassing checks. Landing is done when a fresh fetch shows the change in the default branch.
11
11
 
12
- Leave alone a checkout another
13
- session or job uses, or whose use you cannot tell. Never merge, reset, stash or
14
- switch it to make it match the default.
12
+ Never reset, stash, switch or merge-commit a checkout to make it match the default.
13
+ A session or job running in it is not a reason to wait or ask. A checkout of the
14
+ default branch that is strictly behind, with no uncommitted changes to tracked files, is fast-forwarded
15
+ with `git merge --ff-only <sha>`. Leave a dirty, diverged or mid-operation one, report its path and reason,
16
+ and do not ask the user.
17
+ Landing never waits on a local checkout, and a roster entry in one is not an input either: the
18
+ checkout's own state decides whether it can be fast-forwarded (below). Removing a worktree is
19
+ different; § Cleanup still waits on use.
15
20
 
16
21
  After integration:
17
22
 
18
- - Fetch, then bring the local default branch current. When it is clean,
19
- strictly behind and not checked out elsewhere, fast-forward it. When another
20
- worktree has it checked out and you know no session or running job is using
21
- that worktree (your own main checkout, for example), run
22
- `git merge --ff-only <sha>` there with the remote head you just fetched and
23
- verified; unrelated uncommitted files may stay. Avoid `git pull` there: a
24
- fetch by another process can rewrite the shared `FETCH_HEAD` mid-pull and
25
- fail it with "Cannot fast-forward to multiple branches". When you cannot
26
- tell, treat it as in use. Confirm the local and fetched remote heads match.
27
- - If the default branch is diverged, on another branch, in use, or the merge
28
- refuses because local edits would be overwritten, leave it and report its
29
- path and the blocker. Never merge, reset, stash or switch branches to make it
30
- match, and never update other sessions' worktrees or separate clones.
23
+ - Fetch, then bring the local default branch current, whoever else has it open.
24
+ When no checkout has it, fast-forward the branch ref. When one checkout has it
25
+ (your own, the primary or another linked worktree), run `git merge --ff-only <sha>`
26
+ there with the remote head you just fetched and verified, provided that checkout
27
+ is on the default branch, has no uncommitted changes to tracked files and no
28
+ unfinished Git operation (merge, rebase, cherry-pick, revert, bisect), and is
29
+ strictly behind. Read that state right before the merge. Untracked files may
30
+ stay, and Git refuses a merge that would overwrite one. Avoid `git pull` there: a fetch by another process can rewrite the shared
31
+ `FETCH_HEAD` mid-pull and fail it with "Cannot fast-forward to multiple
32
+ branches". Confirm the local and fetched remote heads match.
33
+ - If that checkout is dirty, diverged, mid-operation or no longer on the default
34
+ branch, or the merge refuses because local edits would be overwritten, leave it
35
+ and write down its path and the reason as a pending local sync. The landing
36
+ stands, so finish the task and do not ask the user to confirm or wait for the
37
+ checkout to go idle. Never merge, reset, stash or switch branches to make it
38
+ match, and never touch a separate clone or a worktree that does not hold the
39
+ default branch.
31
40
  - Delete the merged remote branch when it is this task's own branch
32
41
  (`git push origin --delete <branch>` when the merge did not); § Cleanup
33
42
  covers branches others may rely on. Before merging a pull request that another open pull
@@ -26,8 +26,9 @@ During integration, never switch, reset or remove the session's own checkout: no
26
26
  `checkout` or `switch` to another ref, no `reset` or branch force-move, and no
27
27
  `worktree remove` on it. Updating the default branch it has checked out
28
28
  (`merge --squash`, or `merge --ff-only` to the fetched remote head after a remote merge)
29
- under the fast-forward conditions in § Landing is expected; report other paths
30
- to their owners.
29
+ under the fast-forward conditions in § Landing is expected, and so is fast-forwarding
30
+ the one other checkout that holds the default branch. Report a checkout that cannot be
31
+ fast-forwarded with its path and reason.
31
32
 
32
33
  After the merge, do not leave the session's own checkout on a landed branch.
33
34
  Fetch first, then:
@@ -41,8 +42,8 @@ Fetch first, then:
41
42
  commits.
42
43
  - When an app manages the checkout, leave it for the app to retire. Report it;
43
44
  it is not a blocker.
44
- - When the tree is dirty or ownership is in doubt, stop: record the
45
- merged commit, the checkout path and the owner wherever you track the task.
45
+ - When the tree is dirty, leave the checkout where it is and record the merged
46
+ commit, the checkout path and the blocker. The landing stands.
46
47
 
47
48
  After each landing, update the task list and any active handoff with the
48
49
  commit, the verification result and the remaining work, then retire the task
@@ -23,7 +23,10 @@ A predecessor plan needs no conversion. Name its `plan.md` as the source
23
23
  `file`; the checker reads each item line of its `## Ledger` section (`- [ ]`,
24
24
  `- [~]`, `- [x]`, `- [-]`) as a record named `L<line>` (`L12`). Other text in
25
25
  the section is not read, so turn any follow-up written there into an item line
26
- before the handoff. Take a copy of the plan as it stood at the handoff,
26
+ before the handoff. Item lines elsewhere in the file are not read either. The
27
+ checker counts them and reports a gap that names the first five lines (the open
28
+ or deferred ones, when there are any), so the receipt is partial and cannot claim complete
29
+ coverage until they move into the ledger section. Take a copy of the plan as it stood at the handoff,
27
30
  because later edits renumber the lines. Each open or deferred line must map to
28
31
  an item; a context-only reason such as "the rest, reopen only if asked" is
29
32
  rejected. When one item absorbs several open or deferred lines, give the item
@@ -57,7 +57,9 @@ registry row, the project and the thread id.
57
57
  ledger or source record still shows it open. An offer answered, completed,
58
58
  deferred or superseded since the handoff was written authorizes nothing. A
59
59
  bare code or `go` in the successor selects an offer only when one listed offer
60
- is open. With several, it asks which one, as § Offers requires.
60
+ is open. With several, it asks which one, as § Offers requires. A thread that
61
+ holds no offer, such as a project Orchestrator, writes the list as "none"; an
62
+ empty list is not left out.
61
63
  - The inventory and read-backs from the skill's thread-move step. The
62
64
  receiving thread re-points to itself each item the host lets it bind:
63
65
  registry rows, scheduled tasks, pull request watches and the messaging name
@@ -56,18 +56,36 @@ function unique(entries, label) {
56
56
  // A plan file (`.md`) is read as its `## Ledger` item lines, one record per item, named `L<line>`;
57
57
  // other text in the section, wrapped continuation lines included, is not read.
58
58
  // Open (`- [ ]`) and deferred (`- [~]`) lines are remembered: they must carry over as items.
59
+ // Item lines elsewhere in the file are not records either, but they are counted and reported as a gap.
59
60
  function ledgerRecords(raw) {
60
61
  const lines = raw.split(/\r?\n/);
61
62
  const start = lines.findIndex((line) => /^#{1,6}\s+Ledger\s*$/i.test(line));
62
63
  if (start < 0) return null;
63
64
  const level = lines[start].match(/^#+/)[0].length;
64
65
  const records = [];
65
- for (let i = start + 1; i < lines.length; i++) {
66
- if (new RegExp(`^#{1,${level}}\\s`).test(lines[i])) break;
66
+ const outside = [];
67
+ let inside = false;
68
+ for (let i = 0; i < lines.length; i++) {
69
+ if (i === start) inside = true;
70
+ else if (inside && new RegExp(`^#{1,${level}}\\s`).test(lines[i])) inside = false;
67
71
  const match = lines[i].match(/^\s*[-*]\s+\[([ x~-])\]\s+(.*)$/i);
68
- if (match) records.push({ id: `L${i + 1}`, text: match[2], mark: match[1] });
72
+ if (!match) continue;
73
+ if (inside) records.push({ id: `L${i + 1}`, text: match[2], mark: match[1] });
74
+ else outside.push({ id: `L${i + 1}`, mark: match[1] });
69
75
  }
70
- return { records };
76
+ return { records, outside };
77
+ }
78
+
79
+ function outsideGap(label, outside) {
80
+ const open = outside.filter((line) => /[ ~]/.test(line.mark));
81
+ const named = open.length > 0 ? open : outside;
82
+ const shown = named
83
+ .slice(0, 5)
84
+ .map((line) => line.id)
85
+ .join(", ");
86
+ const more = named.length > 5 ? ` and ${named.length - 5} more` : "";
87
+ const count = open.length > 0 ? `, ${open.length} of them open or deferred` : "";
88
+ return `${label}: ${outside.length} item line(s) outside the \`## Ledger\` section were not read${count} (${shown}${more}); move them into the ledger section`;
71
89
  }
72
90
 
73
91
  function check(receipt, directory) {
@@ -125,6 +143,8 @@ function check(receipt, directory) {
125
143
  errors.push(`${label}: snapshot must be an object`);
126
144
  continue;
127
145
  }
146
+ if (source.file.endsWith(".md") && data.outside.length > 0)
147
+ gaps.push(outsideGap(label, data.outside));
128
148
  const rows = unique(entries(data.records, `${label}.records`), `${label} record`);
129
149
  if (Number.isSafeInteger(source.expectedRecords) && source.expectedRecords !== rows.size) {
130
150
  gaps.push(`${label}: expected ${source.expectedRecords} records, read ${rows.size}`);
@@ -108,18 +108,38 @@ It does not clear that gate's review-body findings or change the landing workflo
108
108
  Three scripts in `scripts/` implement the landing steps § Landing describes. They never
109
109
  reset, switch or delete a checkout.
110
110
 
111
- - `merge-pr.sh <pr> [--merge] [--remote <name>] [--idle-primary <path>]` squash-merges
111
+ - `merge-pr.sh <pr> [--merge] [--remote <name>]` squash-merges
112
112
  (or merge-commits) an open pull request and deletes its remote head without
113
113
  `gh pr merge --delete-branch`, which removes other sessions' linked worktrees. It
114
114
  refuses while review feedback is open (`check-pr-review.ts`), while another open pull
115
115
  request targets the head, and for a repository listed in `HR_PUSH_LANDED`
116
116
  (space-separated `owner/repo`; those land by fast-forward push, which it prints). The
117
117
  merge passes `--match-head-commit`. It lists worktrees still on the merged branch so
118
- their owners clean up.
119
- - `verify-landed.sh --commit <sha> [--ff [--idle-primary <path>]]` fetches, checks that
120
- the default branch contains the commit, and checks the local default; `--ff`
121
- fast-forwards a clean default checkout. `--preflight --ff` checks eligibility before
122
- landing. After a blocked check, rerun this script, not `merge-pr.sh`.
118
+ their owners clean up. The merge never waits on a local checkout; the local default is
119
+ synchronized afterwards as `verify-landed.sh` describes.
120
+ - `verify-landed.sh --commit <sha> [--ff]` fetches, checks that the default branch
121
+ contains the commit, and checks the local default. `--ff` brings the local default
122
+ current: it moves the branch ref when no checkout has it, and runs
123
+ `merge --ff-only <sha>` in the one checkout that has it when that checkout is on the
124
+ default branch, free of uncommitted changes to tracked files and of an unfinished Git
125
+ operation, and strictly behind. Untracked files may stay; Git refuses a fast-forward
126
+ that would overwrite one. Sessions,
127
+ processes and roster entries are never inputs. Any other checkout is left alone and
128
+ reported on one line, `sync skipped at <path>: <reason>`, with a reason of
129
+ `working tree dirty` (tracked changes), `diverged from <remote>/<default>`, `on branch
130
+ <name>, not <default>`, `unfinished Git operation: <name>`, `fast-forward refused: <why>`,
131
+ `cannot inspect Git state` or `cannot inspect working tree`.
132
+ Exit 0 means landed and current; 1 is a failure; 2 is usage; 3 means landed with a local
133
+ sync pending, which is not a failed landing and needs no question to the user: record
134
+ the path and reason in the task plan. Both scripts use these exit codes. After a
135
+ failure, rerun this script, not `merge-pr.sh`. Both scripts accept the old
136
+ `--idle-primary <path>` flag and ignore it with a note. It takes the next argument as its path unless that
137
+ argument is missing or starts with `-`. `verify-landed.sh` also accepts `--preflight`. Without
138
+ `--commit` and `--branch`, it prints a note and `preflight skipped: it no longer gates landing`, checks
139
+ nothing and exits 0. With either one it exits 2. `merge-pr.sh` rejects `--preflight` with exit 2. Both
140
+ flags will be removed.
123
141
  - `check-landing.py [--path <checkout>] [--base <ref>]` reports whether a checkout's
124
142
  work is in the base branch by content, so squash-merged branches count as landed, and
125
- lists live processes whose working directory is inside the checkout (Linux only).
143
+ lists live processes whose working directory is inside the checkout (Linux only). That
144
+ list is a blocker for removing the worktree only. Landing and syncing the default
145
+ checkout never read it.
@@ -8,7 +8,8 @@ the base. Read-only; never fetches — the caller fetches first.
8
8
 
9
9
  It also lists live processes whose working directory is inside the checkout
10
10
  (Linux /proc only), excluding the checker's ancestors and its pipeline. Each listed process
11
- blocks worktree removal unless its PID is one you recorded when you started it. `own_session`
11
+ blocks worktree removal unless its PID is one you recorded when you started it. The list says nothing
12
+ about landing or about fast-forwarding a default checkout: neither reads it. `own_session`
12
13
  is true only for a process that descends from the nearest agent CLI above the checker (a
13
14
  process whose comm is one of AGENT_COMMS); a process under another session of the same agent
14
15
  host's server, or any process when the checker has no agent ancestor, is false. A process
@@ -15,25 +15,28 @@
15
15
  # empty by default): its own guidance lands by fast-forwarding the base to the head, because a GitHub
16
16
  # merge writes the account's profile name into public history. It prints that push instead.
17
17
  #
18
- # Before merging, verifies that the local default can be synchronized, even if it is current.
19
- # Refuses a dirty/diverged target or a default held in another checkout without --idle-primary.
20
- # After merging, fast-forwards the confirmed local default and fails if it is still stale.
21
- # To update the primary checkout from a task worktree, pass --idle-primary <path>
22
- # using existing ownership evidence or the operator's standing idle direction, unless current
23
- # evidence contradicts it; keep it idle through landing.
24
- # If verification
25
- # fails after "merged", rerun verify-landed.sh with the printed commit, not this script.
18
+ # Landing never waits on a local checkout. After the merge it fast-forwards the checkout that holds
19
+ # the default branch when that checkout is on the default branch, clean, free of an unfinished Git
20
+ # operation and strictly behind (verify-landed.sh), whoever else has it open. Otherwise it leaves the
21
+ # checkout alone, prints `sync skipped at <path>: <reason>` and exits 3: landed, local sync pending.
22
+ # Record the path and reason in the task plan; nothing needs the user. If verification fails after
23
+ # "merged" (exit 1), rerun verify-landed.sh with the printed commit, not this script.
26
24
  #
27
25
  # --merge lands with a merge commit instead of a squash. Use it only for a PR whose second parent
28
26
  # must stay in history, such as an `-s ours` merge that records an upstream reconcile; a squash
29
27
  # would drop that parent. Every other PR is squashed.
30
28
  #
31
- # Usage: bash scripts/merge-pr.sh <pr> [--merge] [--remote <name>] [--idle-primary <path>]
29
+ # Exit status: 0 landed and every local default current; 1 refused or failed; 2 usage; 3 landed, local sync pending.
30
+ # --idle-primary <path> is deprecated: accepted and ignored with a note on stderr, removed in a later release.
31
+ # It takes the next argument as its path unless that argument is missing or starts with "-".
32
+ # --preflight is not accepted here (unknown argument, exit 2); only verify-landed.sh still takes it.
33
+ #
34
+ # Usage: bash scripts/merge-pr.sh <pr> [--merge] [--remote <name>]
32
35
  set -euo pipefail
33
36
 
34
37
  PUSH_LANDED=" ${HR_PUSH_LANDED:-} "
35
38
 
36
- PR="${1:?usage: merge-pr.sh <pr> [--merge] [--remote <name>] [--idle-primary <path>]}"
39
+ PR="${1:?usage: merge-pr.sh <pr> [--merge] [--remote <name>]}"
37
40
  shift
38
41
  REMOTE=origin
39
42
  STRATEGY=--squash
@@ -42,7 +45,9 @@ while [ $# -gt 0 ]; do
42
45
  case "$1" in
43
46
  --merge) STRATEGY=--merge; shift;;
44
47
  --remote) REMOTE="${2:?}"; shift 2;;
45
- --idle-primary) SYNC_ARGS+=(--idle-primary "${2:?}"); shift 2;;
48
+ --idle-primary)
49
+ echo "note: --idle-primary is ignored; the checkout holding the default branch is fast-forwarded when it is clean and strictly behind. The flag will be removed in a later release." >&2
50
+ shift; case "${1:-}" in ''|-*) ;; *) shift;; esac;;
46
51
  *) echo "unknown argument: $1" >&2; exit 2;;
47
52
  esac
48
53
  done
@@ -67,9 +72,6 @@ if [[ "$PUSH_LANDED" == *" $REPO "* ]]; then
67
72
  exit 1
68
73
  fi
69
74
 
70
- bash "$(dirname "${BASH_SOURCE[0]}")/verify-landed.sh" --preflight \
71
- --default "$BASE" --remote "$REMOTE" "${SYNC_ARGS[@]}"
72
-
73
75
  gh pr merge "$PR" "$STRATEGY" --match-head-commit "$OID"
74
76
  SHA="$(gh pr view "$PR" --json mergeCommit --jq '.mergeCommit.oid // empty')"
75
77
  if ! [[ "$SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then
@@ -91,10 +93,13 @@ if [ "$FORK" != true ]; then
91
93
  fi
92
94
  fi
93
95
 
96
+ # Exit 3 (local sync pending) is not a failure: finish the worktree report, then pass it on.
97
+ STATUS=0
94
98
  bash "$(dirname "${BASH_SOURCE[0]}")/verify-landed.sh" --commit "$SHA" --branch "$HEAD" \
95
- --default "$BASE" --remote "$REMOTE" "${SYNC_ARGS[@]}"
99
+ --default "$BASE" --remote "$REMOTE" "${SYNC_ARGS[@]}" || STATUS=$?
96
100
 
97
101
  git worktree list --porcelain | awk -v want="branch refs/heads/$HEAD" '
98
102
  /^worktree / { path = substr($0, 10) }
99
103
  $0 == want { print "still on " substr(want, 19) ": " path " (its owner retires it)" }
100
104
  '
105
+ exit "$STATUS"
@@ -2,25 +2,38 @@
2
2
  # Post-merge landing checks: containment, local-default state, leftover remote branches.
3
3
  # Fetches and verifies both remote containment and local-default equality. A stale local default
4
4
  # fails even when the remote contains the commit. Never resets, switches or deletes.
5
- # With --ff it updates a default checked out nowhere or here with a clean tree.
6
- # --idle-primary <path> additionally permits a fast-forward of this repository's primary checkout:
7
- # use existing ownership evidence or the operator's standing idle direction, unless current
8
- # evidence contradicts it. Git preserves
9
- # unrelated local edits and refuses a fast-forward that would overwrite them. It moves the
10
- # primary to the verified remote commit with `merge --ff-only <sha>` rather than `pull`, because
5
+ #
6
+ # With --ff it brings the local default branch current, whoever else has that checkout open. Which
7
+ # processes, sessions or roster entries sit in a checkout is never an input. The branch moves when
8
+ # it is checked out nowhere (a ref update), or when the one checkout that holds it is on the default
9
+ # branch, free of uncommitted changes to tracked files and of an unfinished Git operation, and
10
+ # strictly behind: then
11
+ # `merge --ff-only <sha>` runs there. It uses the verified remote commit rather than `pull`, because
11
12
  # `pull` re-fetches into the shared FETCH_HEAD, which a concurrent fetch in the same checkout can
12
- # overwrite ("Cannot fast-forward to multiple branches"). Other linked worktrees
13
- # remain their owners' responsibility. After a blocked post-merge check, rerun this script,
14
- # not merge-pr.sh: the PR is already merged.
15
- # --preflight checks synchronization eligibility before landing, without moving local branches.
16
- # It requires --ff and rejects dirty or diverged targets and unconfirmed checkout ownership,
17
- # even when the local default is current. The caller must keep the target idle until landing
18
- # finishes; this check cannot establish session ownership or prevent another process starting.
13
+ # overwrite ("Cannot fast-forward to multiple branches"). Untracked files may stay: Git keeps them
14
+ # and refuses a fast-forward that would overwrite one.
15
+ #
16
+ # A checkout that fails any of those conditions is left alone and reported on one line:
17
+ # sync skipped at <path>: <reason>
18
+ # with a reason of "working tree dirty" (tracked changes), "diverged from <remote>/<default>", "on
19
+ # branch <name>, not <default>", "unfinished Git operation: <name>", "fast-forward refused: <why>",
20
+ # "cannot inspect Git state" or "cannot inspect working tree". A diverged default that no checkout
21
+ # holds is reported at refs/heads/<default>. The landing itself stands; record the path and reason
22
+ # where you track the task and do not ask the user.
23
+ #
24
+ # Exit status: 0 landed and every local default current; 1 a real failure (commit not contained,
25
+ # stale default with nothing skipped, leftover remote branch, fetch failure); 2 usage; 3 landed and
26
+ # verified, but a local checkout was skipped (local sync pending). After a failure, rerun this
27
+ # script, not merge-pr.sh: the PR is already merged.
28
+ #
29
+ # Deprecated, removed in a later release; landing no longer waits on any local checkout.
30
+ # --idle-primary <path> is accepted and ignored with a note on stderr. It takes the next argument as its
31
+ # path unless that argument is missing or starts with "-".
32
+ # --preflight prints a note and "preflight skipped: it no longer gates landing" and exits 0 without
33
+ # checking anything; with --commit or --branch it exits 2.
19
34
  # Usage:
20
35
  # bash scripts/verify-landed.sh --commit <sha> [--default <branch>]
21
- # [--branch <merged-head>] [--remote <name>] [--ff [--idle-primary <path>]]
22
- # bash scripts/verify-landed.sh --preflight --ff [--default <branch>]
23
- # [--remote <name>] [--idle-primary <path>]
36
+ # [--branch <merged-head>] [--remote <name>] [--ff]
24
37
  set -euo pipefail
25
38
 
26
39
  REMOTE=origin
@@ -28,8 +41,7 @@ DEFAULT=""
28
41
  COMMIT=""
29
42
  BRANCH=""
30
43
  FF=0
31
- IDLE_PRIMARY=""
32
- PREFLIGHT=0
44
+ LEGACY_PREFLIGHT=0
33
45
 
34
46
  while [ $# -gt 0 ]; do
35
47
  case "$1" in
@@ -38,24 +50,22 @@ while [ $# -gt 0 ]; do
38
50
  --branch) BRANCH="${2:?}"; shift 2;;
39
51
  --remote) REMOTE="${2:?}"; shift 2;;
40
52
  --ff) FF=1; shift;;
41
- --idle-primary) IDLE_PRIMARY="${2:?}"; shift 2;;
42
- --preflight) PREFLIGHT=1; shift;;
53
+ --idle-primary)
54
+ echo "note: --idle-primary is ignored; the checkout holding the default branch is fast-forwarded when it is clean and strictly behind. The flag will be removed in a later release." >&2
55
+ shift; case "${1:-}" in ''|-*) ;; *) shift;; esac;;
56
+ --preflight)
57
+ LEGACY_PREFLIGHT=1
58
+ echo "note: --preflight is ignored; landing no longer waits on local checkouts. The flag will be removed in a later release." >&2
59
+ shift;;
43
60
  *) echo "unknown argument: $1" >&2; exit 2;;
44
61
  esac
45
62
  done
46
- if [ "$PREFLIGHT" = 1 ]; then
47
- [ "$FF" = 1 ] && [ -z "$COMMIT$BRANCH" ] \
48
- || { echo "--preflight requires --ff and excludes --commit/--branch" >&2; exit 2; }
49
- else
50
- [ -n "$COMMIT" ] || { echo "missing --commit <sha>" >&2; exit 2; }
51
- fi
52
- if [ -n "$IDLE_PRIMARY" ]; then
53
- [ "$FF" = 1 ] || { echo "--idle-primary requires --ff" >&2; exit 2; }
54
- PRIMARY="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
55
- IDLE_PRIMARY="$(git -C "$IDLE_PRIMARY" rev-parse --show-toplevel)"
56
- [ "$IDLE_PRIMARY" = "$PRIMARY" ] \
57
- || { echo "--idle-primary is not this repository's primary checkout: $IDLE_PRIMARY" >&2; exit 2; }
63
+ if [ "$LEGACY_PREFLIGHT" = 1 ]; then
64
+ [ -z "$COMMIT$BRANCH" ] || { echo "--preflight excludes --commit/--branch" >&2; exit 2; }
65
+ echo "preflight skipped: it no longer gates landing"
66
+ exit 0
58
67
  fi
68
+ [ -n "$COMMIT" ] || { echo "missing --commit <sha>" >&2; exit 2; }
59
69
 
60
70
  if [ -z "$DEFAULT" ]; then
61
71
  # The remote's own answer wins; the cached origin/HEAD goes stale when the default branch changes.
@@ -76,48 +86,64 @@ HOLDER="$(printf '%s\n' "$WTLS" | awk -v want="branch refs/heads/$DEFAULT" '
76
86
  ')"
77
87
  HERE="$(git rev-parse --show-toplevel)"
78
88
 
79
- if [ "$PREFLIGHT" = 1 ]; then
80
- # A rebase or bisect detaches HEAD while still reserving its original branch.
81
- if [ -z "$HOLDER" ]; then
82
- while IFS= read -r RECORD; do
83
- case "$RECORD" in worktree\ *) CHECKOUT="${RECORD#worktree }";; *) continue;; esac
84
- [ -d "$CHECKOUT" ] || continue
85
- for ASSOCIATION in rebase-merge/head-name rebase-apply/head-name BISECT_START; do
86
- ASSOCIATION_PATH="$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path "$ASSOCIATION")"
87
- if [ -f "$ASSOCIATION_PATH" ]; then
88
- IFS= read -r ASSOCIATED_BRANCH < "$ASSOCIATION_PATH"
89
- if [ "$ASSOCIATED_BRANCH" = "refs/heads/$DEFAULT" ] || [ "$ASSOCIATED_BRANCH" = "$DEFAULT" ]; then
90
- HOLDER="$CHECKOUT"
91
- break 2
92
- fi
89
+ # A rebase or bisect detaches HEAD while still reserving its original branch.
90
+ if [ -z "$HOLDER" ]; then
91
+ while IFS= read -r RECORD; do
92
+ case "$RECORD" in worktree\ *) CHECKOUT="${RECORD#worktree }";; *) continue;; esac
93
+ [ -d "$CHECKOUT" ] || continue
94
+ for ASSOCIATION in rebase-merge/head-name rebase-apply/head-name BISECT_START; do
95
+ # A checkout whose state cannot be read counts as holding the branch: holder_blocker then skips it.
96
+ ASSOCIATION_PATH="$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path "$ASSOCIATION" 2>/dev/null)" || {
97
+ HOLDER="$CHECKOUT"
98
+ break 2
99
+ }
100
+ if [ -f "$ASSOCIATION_PATH" ]; then
101
+ ASSOCIATED_BRANCH=""
102
+ if ! { IFS= read -r ASSOCIATED_BRANCH < "$ASSOCIATION_PATH" || [ -n "$ASSOCIATED_BRANCH" ]; } 2>/dev/null; then
103
+ HOLDER="$CHECKOUT"
104
+ break 2
93
105
  fi
94
- done
95
- done <<< "$WTLS"
96
- fi
97
- if [ -n "$HOLDER" ]; then
98
- if [ "$HOLDER" != "$HERE" ] && [ "$HOLDER" != "$IDLE_PRIMARY" ]; then
99
- echo "sync blocked at $HOLDER: that checkout has the default branch. If nothing is using it, rerun with --idle-primary <primary-path>" >&2
100
- exit 1
101
- fi
102
- STATUS="$(git -C "$HOLDER" status --porcelain)" \
103
- || { echo "sync blocked at $HOLDER: cannot inspect working tree" >&2; exit 1; }
104
- [ -z "$STATUS" ] \
105
- || { echo "sync blocked at $HOLDER: working tree dirty; leave it for its owner" >&2; exit 1; }
106
- for OPERATION in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply sequencer BISECT_START; do
107
- OPERATION_PATH="$(git -C "$HOLDER" rev-parse --path-format=absolute --git-path "$OPERATION")"
108
- [ ! -e "$OPERATION_PATH" ] \
109
- || { echo "sync blocked at $HOLDER: unfinished Git operation: $OPERATION" >&2; exit 1; }
106
+ if [ "$ASSOCIATED_BRANCH" = "refs/heads/$DEFAULT" ] || [ "$ASSOCIATED_BRANCH" = "$DEFAULT" ]; then
107
+ HOLDER="$CHECKOUT"
108
+ break 2
109
+ fi
110
+ fi
110
111
  done
111
- fi
112
- if git show-ref --verify --quiet "refs/heads/$DEFAULT"; then
113
- git merge-base --is-ancestor "$DEFAULT" "$REMOTE/$DEFAULT" \
114
- || { echo "sync blocked: $DEFAULT cannot fast-forward to $REMOTE/$DEFAULT" >&2; exit 1; }
115
- fi
116
- echo "synchronization preflight passed: $DEFAULT (${HOLDER:-not checked out})"
117
- exit 0
112
+ done <<< "$WTLS"
118
113
  fi
119
114
 
120
115
  FAIL=0
116
+ SKIPPED=0
117
+
118
+ skip() {
119
+ echo "sync skipped at $1: $2"
120
+ SKIPPED=1
121
+ }
122
+
123
+ # Prints why a checkout cannot be fast-forwarded now, or nothing when it can.
124
+ holder_blocker() {
125
+ local path="$1" operation operation_path branch status
126
+ for operation in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply sequencer BISECT_START; do
127
+ operation_path="$(git -C "$path" rev-parse --path-format=absolute --git-path "$operation" 2>/dev/null)" \
128
+ || { echo "cannot inspect Git state"; return; }
129
+ if [ -e "$operation_path" ]; then
130
+ echo "unfinished Git operation: $operation"
131
+ return
132
+ fi
133
+ done
134
+ branch="$(git -C "$path" symbolic-ref --quiet HEAD 2>/dev/null || true)"
135
+ if [ "$branch" != "refs/heads/$DEFAULT" ]; then
136
+ if [ -n "$branch" ]; then
137
+ echo "on branch ${branch#refs/heads/}, not $DEFAULT"
138
+ else
139
+ echo "on branch (detached HEAD), not $DEFAULT"
140
+ fi
141
+ return
142
+ fi
143
+ # Untracked files may stay: Git itself refuses a fast-forward that would overwrite one.
144
+ status="$(git -C "$path" status --porcelain --untracked-files=no 2>/dev/null)" || { echo "cannot inspect working tree"; return; }
145
+ [ -z "$status" ] || echo "working tree dirty"
146
+ }
121
147
 
122
148
  if git merge-base --is-ancestor "$COMMIT" "$REMOTE/$DEFAULT" 2>/dev/null; then
123
149
  echo "contained: $COMMIT in $REMOTE/$DEFAULT"
@@ -133,35 +159,45 @@ if git show-ref --verify --quiet "refs/heads/$DEFAULT"; then
133
159
  echo "local $DEFAULT: current ($LOCAL)"
134
160
  elif git merge-base --is-ancestor "$LOCAL" "$UPSTREAM" 2>/dev/null; then
135
161
  BEHIND="$(git rev-list --count "$LOCAL..$UPSTREAM")"
136
- if [ -z "${HOLDER:-}" ]; then
137
- if [ "$FF" = 1 ] && [ "$FAIL" = 0 ]; then
138
- git update-ref -m "verify-landed: fast-forward $DEFAULT" "refs/heads/$DEFAULT" "$UPSTREAM" "$LOCAL"
139
- echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (not checked out anywhere)"
162
+ if [ "$FF" != 1 ] || [ "$FAIL" != 0 ]; then
163
+ WHY="not updated without --ff"
164
+ [ "$FF" != 1 ] || WHY="not updated: the landing check failed"
165
+ if [ -z "$HOLDER" ]; then
166
+ if [ "$FF" != 1 ]; then
167
+ echo "local $DEFAULT: $BEHIND behind, not checked out anywhere. Safe: git fetch $REMOTE $DEFAULT:$DEFAULT"
168
+ else
169
+ echo "local $DEFAULT: $BEHIND behind, not checked out anywhere; $WHY"
170
+ fi
140
171
  else
141
- echo "local $DEFAULT: $BEHIND behind, not checked out anywhere — safe: git fetch $REMOTE $DEFAULT:$DEFAULT"
172
+ echo "local $DEFAULT: $BEHIND behind, checked out at $HOLDER; $WHY"
142
173
  fi
143
- elif [ "$HOLDER" != "$HERE" ]; then
144
- if [ "$FF" = 1 ] && [ "$FAIL" = 0 ] && [ "$HOLDER" = "$IDLE_PRIMARY" ]; then
145
- [ "$(git -C "$HOLDER" symbolic-ref --quiet --short HEAD)" = "$DEFAULT" ] \
146
- || { echo "primary checkout changed branches; refusing update" >&2; exit 1; }
147
- git -C "$HOLDER" merge --quiet --ff-only "$UPSTREAM"
148
- echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) in confirmed idle primary $HOLDER"
174
+ elif [ -z "$HOLDER" ]; then
175
+ if REF_ERR="$(git update-ref -m "verify-landed: fast-forward $DEFAULT" "refs/heads/$DEFAULT" "$UPSTREAM" "$LOCAL" 2>&1)"; then
176
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (not checked out anywhere)"
149
177
  else
150
- echo "local $DEFAULT: $BEHIND behind, checked out at $HOLDER (not here) — leave for its owner; if you know it is idle: git -C $HOLDER merge --ff-only $UPSTREAM"
151
- echo "If nothing is using that checkout, rerun verify-landed.sh with --ff --idle-primary <primary-path>." >&2
178
+ skip "refs/heads/$DEFAULT" "fast-forward refused: $(printf '%s' "$REF_ERR" | head -n 1)"
152
179
  fi
153
- elif [ -n "$(git status --porcelain)" ]; then
154
- echo "local $DEFAULT: $BEHIND behind but working tree dirty — report, do not touch"
155
- elif [ "$FF" = 1 ] && [ "$FAIL" = 0 ]; then
156
- git merge --quiet --ff-only "$UPSTREAM"
157
- echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (checked out here)"
158
180
  else
159
- echo "local $DEFAULT: $BEHIND behind, clean, checked out here — safe to fast-forward"
181
+ # The state is read right before the merge; Git refuses it if edits would be overwritten.
182
+ REASON="$(holder_blocker "$HOLDER")"
183
+ if [ -n "$REASON" ]; then
184
+ skip "$HOLDER" "$REASON"
185
+ elif MERGE_ERR="$(git -C "$HOLDER" merge --quiet --ff-only "$UPSTREAM" 2>&1)"; then
186
+ if [ "$HOLDER" = "$HERE" ]; then
187
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (checked out here)"
188
+ else
189
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM in $HOLDER"
190
+ fi
191
+ elif printf '%s' "$MERGE_ERR" | grep -q "would be overwritten"; then
192
+ skip "$HOLDER" "fast-forward refused: local edits would be overwritten"
193
+ else
194
+ skip "$HOLDER" "fast-forward refused: $(printf '%s' "$MERGE_ERR" | head -n 1)"
195
+ fi
160
196
  fi
161
197
  else
162
- echo "local $DEFAULT: diverged from $REMOTE/$DEFAULT — report, do not touch"
198
+ skip "${HOLDER:-refs/heads/$DEFAULT}" "diverged from $REMOTE/$DEFAULT"
163
199
  fi
164
- if [ "$(git rev-parse "$DEFAULT")" != "$(git rev-parse "$REMOTE/$DEFAULT")" ]; then
200
+ if [ "$(git rev-parse "$DEFAULT")" != "$(git rev-parse "$REMOTE/$DEFAULT")" ] && [ "$SKIPPED" = 0 ]; then
165
201
  echo "LOCAL DEFAULT NOT CURRENT: $DEFAULT differs from $REMOTE/$DEFAULT; landing incomplete" >&2
166
202
  FAIL=1
167
203
  fi
@@ -181,4 +217,11 @@ if [ -n "$BRANCH" ]; then
181
217
  fi
182
218
  fi
183
219
 
184
- exit "$FAIL"
220
+ if [ "$FAIL" != 0 ]; then
221
+ exit 1
222
+ fi
223
+ if [ "$SKIPPED" = 1 ]; then
224
+ echo "landed; local sync pending (exit 3): record the path and reason above in the task plan"
225
+ exit 3
226
+ fi
227
+ exit 0
@@ -209,7 +209,11 @@ function parsePlan(text) {
209
209
  i > 0 &&
210
210
  (NARRATIVE.test(line) || PROSE_MARKER.test(line) || /^\s*[-*]\s+\[[ ~]\]/.test(line))
211
211
  )
212
- narrative.push({ line: i + 1, text: line.trim() });
212
+ narrative.push({
213
+ line: i + 1,
214
+ text: line.trim(),
215
+ box: /^\s*[-*]\s+\[[ ~]\]/.test(line),
216
+ });
213
217
  });
214
218
  return { status, owner, purpose, project, items, errors, narrative };
215
219
  }
@@ -580,7 +584,15 @@ async function run(options) {
580
584
  };
581
585
  const open = plan.items.filter((item) => item.mark === " " || item.mark === "~");
582
586
  report.open = open.map(row);
583
- if (report.errors.length) return report;
587
+ // A plan that fails its lint still gets every check that reads only its text. The checks that read the
588
+ // host (scratch, checkouts, the audit record, GitHub) wait until the plan is valid.
589
+ const invalid = report.errors.length > 0;
590
+ if (invalid) {
591
+ report.invalidPlan = true;
592
+ report.skipped.push(
593
+ "scratch, checkout, audit record and GitHub checks: they wait until the plan passes its lint",
594
+ );
595
+ }
584
596
  report.awaitingUser = open.filter(awaitsUser).map(row);
585
597
  if (TERMINAL_STATUS.test(plan.status) && open.length)
586
598
  report.findings.push(
@@ -588,7 +600,7 @@ async function run(options) {
588
600
  );
589
601
  const roots = notesRoot(planPath);
590
602
  const scratch = options.scratch ?? (roots ? join(roots.notes, "tmp") : null);
591
- if (scratch) {
603
+ if (scratch && !invalid) {
592
604
  const { owned, unreadable } = ownedScratch(scratch, plan.owner);
593
605
  for (const manifest of unreadable)
594
606
  report.errors.push(
@@ -596,7 +608,7 @@ async function run(options) {
596
608
  );
597
609
  for (const name of owned) accountFor(`scratch ${name}`, [name], plan.items, report.findings);
598
610
  }
599
- for (const path of options.checkouts) {
611
+ for (const path of invalid ? [] : options.checkouts) {
600
612
  const state = checkoutState(path);
601
613
  if (state.error) report.errors.push(state.error);
602
614
  else if (state.dirty || state.unpushed)
@@ -608,7 +620,7 @@ async function run(options) {
608
620
  );
609
621
  }
610
622
  // An app retires its own checkouts, so their state is reported but is not a finding.
611
- for (const path of options.appManaged) {
623
+ for (const path of invalid ? [] : options.appManaged) {
612
624
  const state = checkoutState(path);
613
625
  if (state.error) report.errors.push(state.error);
614
626
  else if (state.dirty || state.unpushed)
@@ -620,20 +632,23 @@ async function run(options) {
620
632
  report.warnings.push(
621
633
  "no `Purpose:` line in the first 40 lines (the session's purpose, one sentence)",
622
634
  );
623
- for (const entry of plan.narrative.slice(0, 5))
624
- report.warnings.push(`L${entry.line} outside the ledger: ${clip(entry.text, 70)}`);
625
- if (plan.narrative.length > 5)
626
- report.warnings.push(`${plan.narrative.length - 5} more narrative marker line(s)`);
635
+ // Every unchecked box outside the ledger is a possible lost item, so each is listed. Prose labels such
636
+ // as "Deferred:" are listed up to five, with a count of the rest. Lines stay in file order.
637
+ let prose = 0;
638
+ for (const entry of plan.narrative)
639
+ if (entry.box || ++prose <= 5)
640
+ report.warnings.push(`L${entry.line} outside the ledger: ${clip(entry.text, 70)}`);
641
+ if (prose > 5) report.warnings.push(`${prose - 5} more narrative marker line(s)`);
627
642
  offerWarnings(plan.items, report.warnings);
628
643
  report.unansweredOffers = unansweredOffers(plan.items);
629
644
  if (report.unansweredOffers.length >= OFFER_LIMIT)
630
645
  report.warnings.push(
631
646
  `${report.unansweredOffers.length} offers are open and unanswered; the offers rule says to present no further offer at ${OFFER_LIMIT} or more. Queue the new decision in a walk-through (hr-what-next, Walk-through) instead`,
632
647
  );
633
- const audit = plan.project && auditWarning(plan.project, roots, options.today);
648
+ const audit = !invalid && plan.project && auditWarning(plan.project, roots, options.today);
634
649
  if (audit) report.warnings.push(audit);
635
650
  report.recheckDue = rechecks(open, options.today, report.warnings);
636
- report.stale = await possiblyFinished(open, options, report);
651
+ if (!invalid) report.stale = await possiblyFinished(open, options, report);
637
652
  if (options.archive && !report.errors.length) {
638
653
  if (!COMPLETED_STATUS.test(plan.status))
639
654
  report.findings.push(`archiving refused: Status is "${plan.status}", not completed`);
@@ -678,7 +693,7 @@ async function main() {
678
693
  console.log(
679
694
  `plan: ${report.plan}\nstatus: ${report.status ?? "?"} | owner: ${report.owner ?? "?"}`,
680
695
  );
681
- if (code !== 1) console.log(`unanswered offers: ${report.unansweredOffers.length}`);
696
+ console.log(`unanswered offers: ${report.unansweredOffers.length}`);
682
697
  for (const [label, rows] of [
683
698
  ["error", report.errors],
684
699
  ["open", report.open],
@@ -696,7 +711,7 @@ async function main() {
696
711
  code === 0
697
712
  ? "result: nothing open"
698
713
  : code === 1
699
- ? "result: invalid input"
714
+ ? `result: invalid input${report.invalidPlan ? " (the checks that read only the plan text still ran)" : ""}`
700
715
  : `result: ${report.open.length} open item(s) (${report.awaitingUser.length} awaiting your yes/no), ${report.findings.length} finding(s)`,
701
716
  );
702
717
  }