@webpieces/ai-hook-rules 0.4.684 → 0.4.686
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/package.json +2 -2
- package/src/core/l2-doc.js +35 -21
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-rows.d.ts +8 -5
- package/src/core/l2-rows.js +26 -17
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +21 -1
- package/src/core/rules/feature-branch-guard.js +21 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/main-freshness.d.ts +35 -0
- package/src/core/rules/main-freshness.js +53 -0
- package/src/core/rules/main-freshness.js.map +1 -0
- package/src/core/rules/merged-branch-bash-guard.js +1 -1
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.js +2 -1
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +5 -3
- package/src/core/rules/read-stale-guard.js +10 -24
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/recovery-allowlist.d.ts +32 -1
- package/src/core/rules/recovery-allowlist.js +79 -21
- package/src/core/rules/recovery-allowlist.js.map +1 -1
- package/src/core/rules/shell-segment-scan.d.ts +10 -1
- package/src/core/rules/shell-segment-scan.js +9 -1
- package/src/core/rules/shell-segment-scan.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +73 -47
- package/src/core/rules/stale-main-bash-guard.js +109 -83
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +4 -3
- package/src/core/rules/stale-main-message.js +4 -3
- package/src/core/rules/stale-main-message.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.686",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.686"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
package/src/core/l2-doc.js
CHANGED
|
@@ -42,7 +42,8 @@ function renderNotDoneBody() {
|
|
|
42
42
|
'That has not always been true, and the section stays here for when it stops being true again:',
|
|
43
43
|
'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',
|
|
44
44
|
'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',
|
|
45
|
-
'row 5\'s Bash half (
|
|
45
|
+
'row 5\'s Bash half (shipped, then moved: Bash on `main` is judged on FRESHNESS at rows 6/7, while',
|
|
46
|
+
'row 5 keeps the unconditional WRITE block) and the DIRTY-TREE',
|
|
46
47
|
'valves on rows 6 and 8 — both closed, because each of those rows cures with',
|
|
47
48
|
'`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',
|
|
48
49
|
'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',
|
|
@@ -111,13 +112,16 @@ function renderHead() {
|
|
|
111
112
|
'Write is neither.',
|
|
112
113
|
'',
|
|
113
114
|
'**The two Bash guards used to differ in polarity, and no longer do.** merged-branch was',
|
|
114
|
-
'default-DENY + allowlist; stale-main was default-ALLOW + blocklist
|
|
115
|
-
'one and allowed by the other for the same reason — "you should not be
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
115
|
+
'default-DENY + allowlist; stale-main was default-ALLOW + blocklist of content readers, so',
|
|
116
|
+
'`pnpm build` was denied by one and allowed by the other for the same reason — "you should not be',
|
|
117
|
+
'working in this tree". A blocklist of readers structurally cannot catch an installer, a formatter',
|
|
118
|
+
'or a codegen step, so both states now use default-DENY plus one shared `RecoveryAllowlist` (the',
|
|
119
|
+
'row 4 skip list). Two skip lists drift, and the half that drifts is the half that wedges a session',
|
|
120
|
+
'on its own cure.',
|
|
121
|
+
'',
|
|
122
|
+
'**What is gated is WHEN that polarity applies, not the polarity.** stale-main asks the cache',
|
|
123
|
+
'whether local `main` is BEHIND (rows 6/7) and default-denies only then; a current `main` — which is',
|
|
124
|
+
'exactly where `pnpm wp-checkout-clean-main` leaves you — is not this guard\'s business at all.',
|
|
121
125
|
'',
|
|
122
126
|
'They remain separate CLASSES because the states they detect are different — one reads the branch',
|
|
123
127
|
'name, the other the cached merged flag — and because each carries its own message.',
|
|
@@ -168,11 +172,17 @@ function renderTable() {
|
|
|
168
172
|
'',
|
|
169
173
|
'### The one rule that explains the tool column',
|
|
170
174
|
'',
|
|
171
|
-
'**`B` tracks `E` everywhere
|
|
175
|
+
'**`B` tracks `E` everywhere except on `main`, where `B` tracks `R` instead — rows 5, 6 and 7.**',
|
|
176
|
+
'',
|
|
177
|
+
'The hazards differ. A WRITE on `main` lands work somewhere unreviewable and unrevertable at ANY',
|
|
178
|
+
'freshness, so row 5 is `E` only and is judged from the branch alone, above the cache divider. A',
|
|
179
|
+
'READ or a BUILD on a CURRENT `main` harms nothing, and blocking it strands the agent immediately',
|
|
180
|
+
'after `pnpm wp-checkout-clean-main` — the command this repo prescribes — put it there. So `B` joins',
|
|
181
|
+
'`R` on the freshness-gated pair: row 6 (behind) blocks, row 7 (current) allows, and "cannot tell"',
|
|
182
|
+
'fails open at row ' + String(l2_rows_1.L2_FAIL_OPEN_ROW) + ' by construction.',
|
|
172
183
|
'',
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'almost always behind.',
|
|
184
|
+
'Inside row 6 they still differ in SHAPE: a Read names exactly one file and is evaluated precisely;',
|
|
185
|
+
'a Bash command is opaque and gets the conservative answer, default-deny plus the row 4 skip list.',
|
|
176
186
|
'',
|
|
177
187
|
'### Why the order of row 5 is the most load-bearing thing here',
|
|
178
188
|
'',
|
|
@@ -180,9 +190,11 @@ function renderTable() {
|
|
|
180
190
|
'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',
|
|
181
191
|
'practice, because the agent discovers the problem within a command or two.',
|
|
182
192
|
'',
|
|
183
|
-
`Row 5 is the exception that must not be relaxed. Put "on \`main\`" BELOW row ${l2_rows_1.L2_FAIL_OPEN_ROW} and
|
|
193
|
+
`Row 5 is the exception that must not be relaxed. Put "on \`main\`" BELOW row ${l2_rows_1.L2_FAIL_OPEN_ROW} and WRITES on \`main\``,
|
|
184
194
|
'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',
|
|
185
|
-
'where another tree can hold the refresh lock indefinitely.',
|
|
195
|
+
'where another tree can hold the refresh lock indefinitely. That is why row 5 is `E` only: the Bash',
|
|
196
|
+
'half asks a question the cache CAN answer late without harm ("is `main` behind?"), so it belongs',
|
|
197
|
+
'below the divider, where not knowing means allowing.',
|
|
186
198
|
'',
|
|
187
199
|
'### Why row 9 can block reads without trapping you',
|
|
188
200
|
'',
|
|
@@ -229,11 +241,11 @@ function renderUseCases() {
|
|
|
229
241
|
'|---|---|---|---|---|',
|
|
230
242
|
...(0, l2_rows_1.allL2UseCases)().map(useCaseRow),
|
|
231
243
|
'',
|
|
232
|
-
'The write
|
|
233
|
-
'another repo on this toolchain,
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
'
|
|
244
|
+
'The incidental-write case under row 6 is the one to read first: `npx expo install` on `main`, in',
|
|
245
|
+
'another repo on this toolchain, modified two tracked files and no guard fired. It is filed under row',
|
|
246
|
+
'6 rather than row 5 because that is the row that judges Bash on `main` today — a command\'s stated',
|
|
247
|
+
'purpose never says whether it also writes, which is why the shape there is default-deny plus the row',
|
|
248
|
+
'4 skip list rather than a blocklist of readers anybody could have enumerated.',
|
|
237
249
|
'',
|
|
238
250
|
];
|
|
239
251
|
}
|
|
@@ -262,7 +274,8 @@ function renderNotes() {
|
|
|
262
274
|
'|---|---|',
|
|
263
275
|
'| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',
|
|
264
276
|
'| make `main` current | `pnpm wp-checkout-clean-main` *(the prescribed form — also reaps dead branches/worktrees and sweeps orphan directories)* · `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only; still allowed, and still the L0 recovery cure, where no `pnpm` bin can be trusted)* |',
|
|
265
|
-
'| orient | `git status\\|log\\|diff\\|branch` · `gh pr view
|
|
277
|
+
'| orient | `git status\\|log\\|diff\\|branch` · `gh` generally (`pr view`, `pr close`, `pr comment`, `api`, `run watch` — it talks to GitHub, not to this tree) |',
|
|
278
|
+
'| talk to the network | `curl` · `wget` — a URL is not this repo. NOT the forms that write a local file: `curl -o`, `wget -O`, `gh repo clone`, `gh pr checkout`, `gh run download`, or any `> file` redirect |',
|
|
266
279
|
'| park work | `git stash` |',
|
|
267
280
|
'| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',
|
|
268
281
|
'',
|
|
@@ -332,7 +345,8 @@ function renderTail() {
|
|
|
332
345
|
'| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',
|
|
333
346
|
'| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',
|
|
334
347
|
'| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',
|
|
335
|
-
'| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `
|
|
348
|
+
'| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `checkFreshness`, `bareCheckoutOfMain` |',
|
|
349
|
+
'| the shared freshness predicate | `ai-hook-rules/src/core/rules/main-freshness.ts` | `containsOriginMain`, `summarize` |',
|
|
336
350
|
'| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',
|
|
337
351
|
'| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',
|
|
338
352
|
'| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',
|
package/src/core/l2-doc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2UseCase, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE, allL2UseCases } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n/**\n * The \"Not done\" body — a table when there are gaps, a SENTENCE when there are none.\n *\n * An empty table renders as a bare header with nothing under it, which reads like a rendering bug\n * rather than like an achievement. \"Every row is honoured\" is a claim worth making in words, and it is\n * the state this section exists to drive the layer towards.\n */\n// webpieces-disable no-function-outside-class -- section builder for renderL2Doc below, in this render module\nfunction renderNotDoneBody(): string[] {\n if (NOT_DONE.length === 0) {\n return [\n '**Nothing. Every row in the table above is a row the guards actually honour today.**',\n '',\n 'That has not always been true, and the section stays here for when it stops being true again:',\n 'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',\n 'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',\n 'row 5\\'s Bash half (now judged from the branch alone, above the cache divider) and the DIRTY-TREE',\n 'valves on rows 6 and 8 — both closed, because each of those rows cures with',\n '`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',\n 'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',\n 'dirty, and the fix was to print both.',\n ];\n }\n return [\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n ];\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction useCaseRow(useCase: L2UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither.',\n '',\n '**The two Bash guards used to differ in polarity, and no longer do.** merged-branch was',\n 'default-DENY + allowlist; stale-main was default-ALLOW + blocklist, so `pnpm build` was denied by',\n 'one and allowed by the other for the same reason — \"you should not be working in this tree\". That',\n 'asymmetry was a consequence of stale-main asking about FRESHNESS, where a blocklist of content',\n 'readers is the right shape. Once it asks about the BRANCH instead (row 5), the right shape is the',\n 'one merged-branch already had, and they now share it: `RecoveryAllowlist`, the row 4 skip list, as',\n 'a single implementation. Two skip lists drift, and the half that drifts is the half that wedges a',\n 'session on its own cure.',\n '',\n 'They remain separate CLASSES because the states they detect are different — one reads the branch',\n 'name, the other the cached merged flag — and because each carries its own message.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere. `R` is judged separately in exactly one place — rows 6/7, on `main`.**',\n '',\n 'A Read names exactly one file, so the guard can evaluate it precisely. A Bash command is opaque, so',\n 'it gets the conservative answer. Reading a CURRENT `main` is fine; the problem is that `main` is',\n 'almost always behind.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and writes on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',\n 'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',\n 'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',\n 'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',\n 'valve in the code either** — both were closed, and \"Not done\" is empty as a result.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L2 use cases',\n '',\n 'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',\n 'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',\n 'longer exists and a row cannot quietly acquire behaviour nothing documents.',\n '',\n '**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',\n 'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',\n 'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',\n 'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',\n 'under. So a case whose row is wrong fails the build rather than misinforming a reader.',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL2UseCases().map(useCaseRow),\n '',\n 'The write-on-main case under row 5 is the one to read beside \"Not done\": it is a real incident from',\n 'another repo on this toolchain, where `npx expo install` on `main` modified two tracked files and no',\n 'guard fired. It is filed under row 5 because row 5 is the row that SHOULD judge it — the table states',\n 'the policy, and \"Not done\" states how far the code has got. That is the arrangement that keeps a gap',\n 'visible instead of letting the doc quietly narrow itself to whatever the code happens to do.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `pnpm wp-checkout-clean-main` *(the prescribed form — also reaps dead branches/worktrees and sweeps orphan directories)* · `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only; still allowed, and still the L0 recovery cure, where no `pnpm` bin can be trusted)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh pr view\\\\|list\\\\|status\\\\|checks` |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n ...renderNotDoneBody(),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `staleContentRead`, `bareCheckoutOfMain` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2UseCase, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE, allL2UseCases } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n/**\n * The \"Not done\" body — a table when there are gaps, a SENTENCE when there are none.\n *\n * An empty table renders as a bare header with nothing under it, which reads like a rendering bug\n * rather than like an achievement. \"Every row is honoured\" is a claim worth making in words, and it is\n * the state this section exists to drive the layer towards.\n */\n// webpieces-disable no-function-outside-class -- section builder for renderL2Doc below, in this render module\nfunction renderNotDoneBody(): string[] {\n if (NOT_DONE.length === 0) {\n return [\n '**Nothing. Every row in the table above is a row the guards actually honour today.**',\n '',\n 'That has not always been true, and the section stays here for when it stops being true again:',\n 'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',\n 'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',\n 'row 5\\'s Bash half (shipped, then moved: Bash on `main` is judged on FRESHNESS at rows 6/7, while',\n 'row 5 keeps the unconditional WRITE block) and the DIRTY-TREE',\n 'valves on rows 6 and 8 — both closed, because each of those rows cures with',\n '`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',\n 'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',\n 'dirty, and the fix was to print both.',\n ];\n }\n return [\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n ];\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction useCaseRow(useCase: L2UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither.',\n '',\n '**The two Bash guards used to differ in polarity, and no longer do.** merged-branch was',\n 'default-DENY + allowlist; stale-main was default-ALLOW + blocklist of content readers, so',\n '`pnpm build` was denied by one and allowed by the other for the same reason — \"you should not be',\n 'working in this tree\". A blocklist of readers structurally cannot catch an installer, a formatter',\n 'or a codegen step, so both states now use default-DENY plus one shared `RecoveryAllowlist` (the',\n 'row 4 skip list). Two skip lists drift, and the half that drifts is the half that wedges a session',\n 'on its own cure.',\n '',\n '**What is gated is WHEN that polarity applies, not the polarity.** stale-main asks the cache',\n 'whether local `main` is BEHIND (rows 6/7) and default-denies only then; a current `main` — which is',\n 'exactly where `pnpm wp-checkout-clean-main` leaves you — is not this guard\\'s business at all.',\n '',\n 'They remain separate CLASSES because the states they detect are different — one reads the branch',\n 'name, the other the cached merged flag — and because each carries its own message.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere except on `main`, where `B` tracks `R` instead — rows 5, 6 and 7.**',\n '',\n 'The hazards differ. A WRITE on `main` lands work somewhere unreviewable and unrevertable at ANY',\n 'freshness, so row 5 is `E` only and is judged from the branch alone, above the cache divider. A',\n 'READ or a BUILD on a CURRENT `main` harms nothing, and blocking it strands the agent immediately',\n 'after `pnpm wp-checkout-clean-main` — the command this repo prescribes — put it there. So `B` joins',\n '`R` on the freshness-gated pair: row 6 (behind) blocks, row 7 (current) allows, and \"cannot tell\"',\n 'fails open at row ' + String(L2_FAIL_OPEN_ROW) + ' by construction.',\n '',\n 'Inside row 6 they still differ in SHAPE: a Read names exactly one file and is evaluated precisely;',\n 'a Bash command is opaque and gets the conservative answer, default-deny plus the row 4 skip list.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and WRITES on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely. That is why row 5 is `E` only: the Bash',\n 'half asks a question the cache CAN answer late without harm (\"is `main` behind?\"), so it belongs',\n 'below the divider, where not knowing means allowing.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',\n 'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',\n 'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',\n 'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',\n 'valve in the code either** — both were closed, and \"Not done\" is empty as a result.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L2 use cases',\n '',\n 'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',\n 'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',\n 'longer exists and a row cannot quietly acquire behaviour nothing documents.',\n '',\n '**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',\n 'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',\n 'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',\n 'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',\n 'under. So a case whose row is wrong fails the build rather than misinforming a reader.',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL2UseCases().map(useCaseRow),\n '',\n 'The incidental-write case under row 6 is the one to read first: `npx expo install` on `main`, in',\n 'another repo on this toolchain, modified two tracked files and no guard fired. It is filed under row',\n '6 rather than row 5 because that is the row that judges Bash on `main` today — a command\\'s stated',\n 'purpose never says whether it also writes, which is why the shape there is default-deny plus the row',\n '4 skip list rather than a blocklist of readers anybody could have enumerated.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `pnpm wp-checkout-clean-main` *(the prescribed form — also reaps dead branches/worktrees and sweeps orphan directories)* · `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only; still allowed, and still the L0 recovery cure, where no `pnpm` bin can be trusted)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh` generally (`pr view`, `pr close`, `pr comment`, `api`, `run watch` — it talks to GitHub, not to this tree) |',\n '| talk to the network | `curl` · `wget` — a URL is not this repo. NOT the forms that write a local file: `curl -o`, `wget -O`, `gh repo clone`, `gh pr checkout`, `gh run download`, or any `> file` redirect |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n ...renderNotDoneBody(),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `checkFreshness`, `bareCheckoutOfMain` |',\n '| the shared freshness predicate | `ai-hook-rules/src/core/rules/main-freshness.ts` | `containsOriginMain`, `summarize` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
|
package/src/core/l2-rows.d.ts
CHANGED
|
@@ -112,13 +112,16 @@ export declare class L2Row {
|
|
|
112
112
|
* scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.
|
|
113
113
|
*
|
|
114
114
|
* THE ORDER OF ROW 5 IS THE MOST LOAD-BEARING THING IN THIS TABLE. Put "on main" BELOW the divider and
|
|
115
|
-
*
|
|
115
|
+
* WRITES on `main` are permitted for the whole first call of every session — and permanently in a
|
|
116
116
|
* multi-worktree repo, where another tree may hold the cache lock indefinitely.
|
|
117
117
|
*
|
|
118
|
-
* `B`
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
118
|
+
* `B` AND `E` PART COMPANY ON `main`, and rows 5/6/7 are where. A WRITE on `main` is wrong at any
|
|
119
|
+
* freshness — the work lands somewhere unreviewable and unrevertable — so row 5 is `E` only, judged on
|
|
120
|
+
* the branch alone, above the divider. A READ or a BUILD on a CURRENT `main` is harmless, and blocking
|
|
121
|
+
* it strands the agent right after `pnpm wp-checkout-clean-main` put it there; so `B` joins `R` on the
|
|
122
|
+
* FRESHNESS-gated pair below the divider (row 6 behind → block, row 7 current → allow), where "cannot
|
|
123
|
+
* tell" fails open at row 11 by construction. `B` and `R` still differ in SHAPE inside row 6: a Read
|
|
124
|
+
* names one file and is judged precisely, a Bash command is opaque and gets default-deny plus row 4.
|
|
122
125
|
*/
|
|
123
126
|
export declare const L2_ROWS: readonly L2Row[];
|
|
124
127
|
/**
|
package/src/core/l2-rows.js
CHANGED
|
@@ -162,13 +162,16 @@ exports.L2Row = L2Row;
|
|
|
162
162
|
* scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.
|
|
163
163
|
*
|
|
164
164
|
* THE ORDER OF ROW 5 IS THE MOST LOAD-BEARING THING IN THIS TABLE. Put "on main" BELOW the divider and
|
|
165
|
-
*
|
|
165
|
+
* WRITES on `main` are permitted for the whole first call of every session — and permanently in a
|
|
166
166
|
* multi-worktree repo, where another tree may hold the cache lock indefinitely.
|
|
167
167
|
*
|
|
168
|
-
* `B`
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
168
|
+
* `B` AND `E` PART COMPANY ON `main`, and rows 5/6/7 are where. A WRITE on `main` is wrong at any
|
|
169
|
+
* freshness — the work lands somewhere unreviewable and unrevertable — so row 5 is `E` only, judged on
|
|
170
|
+
* the branch alone, above the divider. A READ or a BUILD on a CURRENT `main` is harmless, and blocking
|
|
171
|
+
* it strands the agent right after `pnpm wp-checkout-clean-main` put it there; so `B` joins `R` on the
|
|
172
|
+
* FRESHNESS-gated pair below the divider (row 6 behind → block, row 7 current → allow), where "cannot
|
|
173
|
+
* tell" fails open at row 11 by construction. `B` and `R` still differ in SHAPE inside row 6: a Read
|
|
174
|
+
* names one file and is judged precisely, a Bash command is opaque and gets default-deny plus row 4.
|
|
172
175
|
*/
|
|
173
176
|
exports.L2_ROWS = [
|
|
174
177
|
new L2Row(1, ['B', 'R', 'E'], 'on the **global allowlist** (inert command, or a universal cure such as reading/editing `webpieces.config.json`)', exports.L2_ALLOW, '—', [
|
|
@@ -185,26 +188,28 @@ exports.L2_ROWS = [
|
|
|
185
188
|
new L2Row(4, ['B'], 'on the **skip list** — it gets you OUT, or tells you where you are', exports.L2_ALLOW, '—', [
|
|
186
189
|
new L2UseCase(6, '`git status` / `gh pr view` while blocked, to work out where you are', 'any state — orientation is never "working here"', 'ALLOW: metadata tells you where you are without putting stale file CONTENT in context', 'None needed', 'not-a-content-read (cure/build/metadata)'),
|
|
187
190
|
new L2UseCase(7, '`git stash` when `git checkout -b <new> origin/main` refuses because `origin/main` touched the same files you edited', 'on a stale `main` or a merged branch, dirty tree, with an overlapping upstream change', 'ALLOW: the cure for the row that blocked you must itself never be blocked — and this is the residual step that makes rows 6 and 8 safe to block on a dirty tree', 'None needed — then re-run the checkout and `git stash pop`', 'not-a-content-read (cure/build/metadata)'),
|
|
191
|
+
new L2UseCase(28, '`gh pr close 123`, `gh pr comment`, `gh api …` or a `curl` while parked on a stale `main` or a merged branch', 'blocked state, running something that touches GitHub or a URL and nothing in this tree', 'ALLOW: the skip list asks one question — does this read or write repo CONTENT? `gh` talks to GitHub and `curl`/`wget` talk to a network, so the branch state has nothing to say about them. The forms that write a local file (`gh repo clone`, `gh pr checkout`, `curl -o`, any `> file`) are excluded, and `gh pr create`/`merge` remain governed by their own guards', 'None needed', 'not-a-content-read (cure/build/metadata)'),
|
|
188
192
|
new L2UseCase(8, '`pnpm wp-start-upsert-pr` on a branch whose fork point is broken', 'row 9 state, running the tool row 9 prescribes', 'ALLOW: every `wp-*` bin is on the skip list, so no row can block its own remedy', 'None needed', 'merged-branch recovery/inspection (allowlisted)'),
|
|
189
193
|
]),
|
|
190
|
-
new L2Row(5, ['
|
|
194
|
+
new L2Row(5, ['E'], 'on `main`', exports.L2_BLOCK, '`git checkout -b <new> origin/main`', [
|
|
191
195
|
new L2UseCase(9, 'An Edit or Write to any tracked file while `git rev-parse --abbrev-ref HEAD` says `main`', 'on `main`, any freshness', 'BLOCK: decided by one `git rev-parse`, with NO cache read, so it fires on the first tool call of the session', '`git checkout -b <new> origin/main` — uncommitted work comes with you', 'on-main'),
|
|
192
|
-
new L2UseCase(
|
|
193
|
-
new L2UseCase(24, 'A build or a test run on a `main` that is perfectly up to date', 'on `main`, current — no staleness anywhere', 'BLOCK: freshness is not the question for the BLOCK. Reading `main` to plan stays open; what is closed is WORKING here, because the feature branch is the unit of work — and when `main` is behind instead, the reads are out of date too. The cure is a new branch off `origin/main`, which fetches, so it is right in both states', '`git checkout -b <new> origin/main`', 'on-main'),
|
|
194
|
-
new L2UseCase(16, 'Read is blocked, so the session reaches for `cat`, `grep` and `ls` instead — and describes a CI workflow set missing a whole workflow that existed upstream', 'the SIDE DOOR: same tree, different tool', 'BLOCK. This case used to be judged by row 6 (a stale-content blocklist on the Bash side); row 5 now subsumes it, because being on `main` is already the finding and no enumeration of readers is needed. The log used to read "read-stale-guard handled", which is worse than no guard — it looks covered', '`git checkout -b <new> origin/main`', 'on-main'),
|
|
195
|
-
new L2UseCase(25, 'The FIRST command of a session, on `main`, before any cache exists', 'on `main`, cache absent — row 11 would fail open', 'BLOCK anyway: row 5 is ABOVE the cache divider and reads only `git rev-parse`, so it is armed on call #1. This is the case the cache-gated version could never catch', '`git checkout -b <new> origin/main`', 'on-main'),
|
|
196
|
+
new L2UseCase(25, 'The FIRST edit of a session, on `main`, before any cache exists', 'on `main`, cache absent — row 11 would fail open', 'BLOCK anyway: row 5 is ABOVE the cache divider and reads only `git rev-parse`, so it is armed on call #1. This is why the row is `E` only and must never be gated on the cache', '`git checkout -b <new> origin/main`', 'on-main'),
|
|
196
197
|
]),
|
|
197
198
|
new L2Row(exports.L2_FAIL_OPEN_ROW, ['B', 'R', 'E'], '**the state could not be established** — branch undeterminable, no cache yet, the cache holds another branch, `origin/main` unknown, or the forge unreachable', exports.L2_FAIL_OPEN, '— (nothing to fix; the refresher populates the cache for the next call)', [
|
|
198
199
|
new L2UseCase(11, 'The very first tool call of a session is allowed even on a badly stale `main`', 'no cache — the refresher is fire-and-forget and populates it for the NEXT call', 'ALLOW (fail-open), logged as `ALLOW_FAIL_OPEN` so abstentions stay countable', 'None — the second call is judged normally', 'no-sync-cache'),
|
|
199
200
|
new L2UseCase(12, 'Guards quietly stand down on a plane, or when `gh` is unauthenticated or rate-limited', 'the forge could not be asked whether the PR is merged', 'ALLOW (fail-open) logged as `no-forge` — distinct from "asked, and it is not merged", which used to look identical in the trail', 'None — restore network/`gh auth` to re-arm the merged-branch policy', 'no-forge'),
|
|
201
|
+
new L2UseCase(27, 'A build, a `cat` or a `curl` on `main`, on the first Bash call of a session', 'on `main`, cache absent — so whether `main` is behind is UNKNOWN', 'ALLOW (fail-open), logged `ALLOW_FAIL_OPEN`. `B` on `main` is judged by rows 6/7 and therefore lands here when the cache cannot answer; the WRITE half is not, which is why row 5 sits above this divider', 'None — the second call is judged normally', 'no-sync-cache'),
|
|
200
202
|
new L2UseCase(14, 'Mid-rebase, every guard abstains', 'detached HEAD — there is no branch name to judge', 'ALLOW (fail-open), logged LOUDLY when the branch is unresolvable rather than merely detached', 'None — finish or abort the rebase', 'branch-undeterminable'),
|
|
201
203
|
]),
|
|
202
|
-
new L2Row(6, ['R'], 'on `main`, behind `origin/main`', exports.L2_BLOCK, '`git pull origin main`, or `git checkout -b <new> origin/main`', [
|
|
204
|
+
new L2Row(6, ['B', 'R'], 'on `main`, behind `origin/main`', exports.L2_BLOCK, '`git pull origin main`, or `git checkout -b <new> origin/main`', [
|
|
203
205
|
new L2UseCase(13, 'The Read tool refuses a file on a stale `main` while you have UNCOMMITTED edits', 'on `main`, behind `origin/main`, dirty tree', 'BLOCK. This used to fail open, on the argument that the prescribed `git pull` is not a clean fast-forward when the tree is dirty. That was true of the MESSAGE, not the row: the cure cell always offered a second form, and it works dirty', '`git checkout -b <new> origin/main` — uncommitted changes come with you onto the new branch. If git refuses because `origin/main` touched the same files, `git stash` first (never blocked), then retry, then `git stash pop`', 'on-stale-main'),
|
|
204
206
|
new L2UseCase(15, 'The Read tool refuses a file that exists, on a `main` 18 commits behind', 'on `main`, behind `origin/main`, clean tree', 'BLOCK: judged by live ancestry (`git merge-base --is-ancestor`), not hash equality, so a pull takes effect instantly', '`git pull origin main`, or `git checkout -b <new> origin/main`', 'on-stale-main'),
|
|
207
|
+
new L2UseCase(16, 'Read is blocked, so the session reaches for `cat`, `grep` and `ls` instead — and describes a CI workflow set missing a whole workflow that existed upstream', 'the SIDE DOOR: same tree, same staleness, different tool', 'BLOCK: `B` is judged here beside `R`, so closing the Read tool no longer opens a shell-shaped hole. The log used to read "read-stale-guard handled", which is worse than no guard — it looks covered', '`git checkout -b <new> origin/main`', 'on-stale-main'),
|
|
208
|
+
new L2UseCase(10, 'A Bash command that WRITES tracked files as a side effect — `npx expo install`, a formatter, codegen, `sed -i`, a `>` redirect', 'on a `main` known to be BEHIND, and the write is incidental to a command whose stated purpose is something else', 'BLOCK: inside this row `B` is default-DENY plus row 4\'s skip list, never a blocklist of readers — a command nobody thought to enumerate is caught by not being on the list, which is the only shape that could have caught this one', '`git checkout -b <new> origin/main` BEFORE running anything that may write', 'on-stale-main'),
|
|
205
209
|
]),
|
|
206
|
-
new L2Row(7, ['R'], 'on `main`, current', exports.L2_ALLOW, '—', [
|
|
207
|
-
new L2UseCase(17, 'Reading files on a `main` you just pulled', 'on `main`, and `origin/main` is an ancestor of HEAD', 'ALLOW:
|
|
210
|
+
new L2Row(7, ['B', 'R'], 'on `main`, current', exports.L2_ALLOW, '—', [
|
|
211
|
+
new L2UseCase(17, 'Reading files on a `main` you just pulled', 'on `main`, and `origin/main` is an ancestor of HEAD', 'ALLOW: ancestry, not hash equality, so the allow arrives the instant the pull lands rather than when the detached refresher next runs', 'None needed', 'local-main-contains-origin (up to date)'),
|
|
212
|
+
new L2UseCase(24, '`curl`, `gh pr close` or a test run, immediately after `pnpm wp-checkout-clean-main` landed you on a perfectly current `main`', 'on `main`, current — no staleness anywhere', 'ALLOW. This used to BLOCK, from the branch alone: the tool the repo prescribes put the agent here, and the guard whose name says STALE then refused everything off a narrow allowlist for a reason that had nothing to do with staleness. WRITES here are still blocked, by row 5 — that hazard is real at any freshness', 'None needed', 'local-main-contains-origin (up to date)'),
|
|
208
213
|
]),
|
|
209
214
|
new L2Row(8, ['B', 'R', 'E'], 'on a branch whose PR is **already merged**', exports.L2_BLOCK, '`git fetch origin main && git checkout -b <new> origin/main`', [
|
|
210
215
|
new L2UseCase(18, 'You keep working on the branch after its PR merged, and the next PR reopens code review already landed', 'branch whose PR is merged — `merged` is monotonic, so the cached flag is trusted with no TTL', 'BLOCK across all three tools', '`git fetch origin main && git checkout -b <new> origin/main`', 'already-merged PR#'),
|
|
@@ -250,9 +255,10 @@ const EXACT_REASON_ROWS = {
|
|
|
250
255
|
// Row 4 — the skip list, in its two live spellings.
|
|
251
256
|
'merged-branch recovery/inspection (allowlisted)': 4,
|
|
252
257
|
'not-a-content-read (cure/build/metadata)': 4,
|
|
253
|
-
// Row 5 — never
|
|
258
|
+
// Row 5 — never WRITE on main, at any freshness. `E` only; the Bash half is rows 6/7.
|
|
254
259
|
'on-main': 5,
|
|
255
|
-
// Row 6/7 —
|
|
260
|
+
// Row 6/7 — freshness, for `B` and `R` alike. Both guards log these two literals: read-stale-guard
|
|
261
|
+
// for the Read tool, stale-main-bash-guard for Bash. Same cache, same ancestry test, one verdict.
|
|
256
262
|
'on-stale-main': 6,
|
|
257
263
|
'local-main-contains-origin (up to date)': 7,
|
|
258
264
|
// Row 9 — the two unhealthy-fork states.
|
|
@@ -330,8 +336,11 @@ exports.L2NotDone = L2NotDone;
|
|
|
330
336
|
exports.NOT_DONE = [
|
|
331
337
|
// EMPTY, and that is the goal state: every row in the table is a row the guards actually honour.
|
|
332
338
|
//
|
|
333
|
-
// It held three entries. Row 5's `B` half shipped
|
|
334
|
-
//
|
|
339
|
+
// It held three entries. Row 5's `B` half shipped and then MOVED: on `main`, a write is judged from
|
|
340
|
+
// the branch alone (row 5, above the divider) while Bash is judged on freshness beside the Read tool
|
|
341
|
+
// (rows 6/7), because a build on a CURRENT `main` harms nothing and denying it stranded agents on
|
|
342
|
+
// the very `main` `pnpm wp-checkout-clean-main` had just handed them. Rows 6 and 8 held DIRTY-TREE
|
|
343
|
+
// valves, and both are now closed — each of
|
|
335
344
|
// those rows cures with `git checkout -b <new> origin/main`, which carries uncommitted changes onto
|
|
336
345
|
// the new branch, so a dirty tree never trapped anybody. The row 6 entry claimed the dirty argument
|
|
337
346
|
// "has teeth" there because its cure is `git pull`; that was a fact about the MESSAGE, which printed
|