@webpieces/ai-hook-rules 0.4.636 → 0.4.638
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 +11 -4
- package/package.json +2 -2
- package/src/adapters/hook-core.js +4 -3
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/setup.js +72 -6
- package/src/bin/setup.js.map +1 -1
- package/src/core/custom-rule-adapter.js +3 -1
- package/src/core/custom-rule-adapter.js.map +1 -1
- package/src/core/decision-log.d.ts +38 -5
- package/src/core/decision-log.js +45 -6
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l1-doc.js +6 -3
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l2-doc.d.ts +7 -0
- package/src/core/l2-doc.js +274 -0
- package/src/core/l2-doc.js.map +1 -0
- package/src/core/l2-rows.d.ts +97 -0
- package/src/core/l2-rows.js +229 -0
- package/src/core/l2-rows.js.map +1 -0
- package/src/core/load-rules.d.ts +8 -2
- package/src/core/load-rules.js +51 -31
- package/src/core/load-rules.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +8 -1
- package/src/core/main-sync-refresh.js +9 -2
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/main-sync-timeout.d.ts +32 -0
- package/src/core/main-sync-timeout.js +93 -0
- package/src/core/main-sync-timeout.js.map +1 -0
- package/src/core/rules/branch-creation-guard.js +1 -1
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/catch-error-pattern.js +1 -1
- package/src/core/rules/catch-error-pattern.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +3 -3
- package/src/core/rules/feature-branch-guard.js +17 -4
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/index.d.ts +15 -1
- package/src/core/rules/index.js +18 -10
- package/src/core/rules/index.js.map +1 -1
- package/src/core/rules/match-rule.js +4 -1
- package/src/core/rules/match-rule.js.map +1 -1
- package/src/core/rules/max-file-lines.js +1 -1
- package/src/core/rules/max-file-lines.js.map +1 -1
- package/src/core/rules/merge-in-progress-guard.d.ts +9 -3
- package/src/core/rules/merge-in-progress-guard.js +9 -4
- package/src/core/rules/merge-in-progress-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +3 -3
- package/src/core/rules/merged-branch-bash-guard.js +17 -6
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/no-any-unknown.js +1 -1
- package/src/core/rules/no-any-unknown.js.map +1 -1
- package/src/core/rules/no-custom-css.js +1 -1
- package/src/core/rules/no-custom-css.js.map +1 -1
- package/src/core/rules/no-destructure.js +1 -1
- package/src/core/rules/no-destructure.js.map +1 -1
- package/src/core/rules/no-implicit-any.js +1 -1
- package/src/core/rules/no-implicit-any.js.map +1 -1
- package/src/core/rules/no-js-files.js +1 -1
- package/src/core/rules/no-js-files.js.map +1 -1
- package/src/core/rules/no-process-exit-outside-main.js +1 -1
- package/src/core/rules/no-process-exit-outside-main.js.map +1 -1
- package/src/core/rules/no-symbol-di-tokens.js +1 -1
- package/src/core/rules/no-symbol-di-tokens.js.map +1 -1
- package/src/core/rules/no-unmanaged-exceptions.js +1 -1
- package/src/core/rules/no-unmanaged-exceptions.js.map +1 -1
- package/src/core/rules/pr-creation-or-push-guard.d.ts +10 -3
- package/src/core/rules/pr-creation-or-push-guard.js +10 -4
- package/src/core/rules/pr-creation-or-push-guard.js.map +1 -1
- package/src/core/rules/pr-merge-guard.d.ts +3 -3
- package/src/core/rules/pr-merge-guard.js +2 -1
- package/src/core/rules/pr-merge-guard.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +3 -3
- package/src/core/rules/read-stale-guard.js +15 -6
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/redirect-how-to-merge-main.d.ts +3 -3
- package/src/core/rules/redirect-how-to-merge-main.js +1 -1
- package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
- package/src/core/rules/require-return-type.js +1 -1
- package/src/core/rules/require-return-type.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +3 -3
- package/src/core/rules/stale-main-bash-guard.js +5 -4
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/throw-cause-required.js +1 -1
- package/src/core/rules/throw-cause-required.js.map +1 -1
- package/src/core/rules/validate-ts-in-src.js +1 -1
- package/src/core/rules/validate-ts-in-src.js.map +1 -1
- package/src/core/rules/whole-repo-build-guard.js +6 -2
- package/src/core/rules/whole-repo-build-guard.js.map +1 -1
- package/src/core/runner.js +41 -22
- package/src/core/runner.js.map +1 -1
- package/src/core/sync-main.d.ts +9 -0
- package/src/core/sync-main.js +17 -1
- package/src/core/sync-main.js.map +1 -1
- package/src/core/types.d.ts +11 -0
- package/src/core/types.js.map +1 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderL2Doc = renderL2Doc;
|
|
4
|
+
const l2_rows_1 = require("./l2-rows");
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// guards/L2-branch-state.md, rendered from L2_ROWS.
|
|
7
|
+
//
|
|
8
|
+
// Same arrangement as l1-doc.renderL1Doc(): one join('\n') of literal markdown lines with the ROW DATA
|
|
9
|
+
// interpolated from the array. Everything that is not row data is a literal line here, because that is
|
|
10
|
+
// the half a generator cannot own.
|
|
11
|
+
//
|
|
12
|
+
// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and
|
|
13
|
+
// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and
|
|
14
|
+
// carried its own warning that it could drift; it did, in three places at once (it proposed
|
|
15
|
+
// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and
|
|
16
|
+
// docs/plans/guard-layer-toggles.md proposed a third).
|
|
17
|
+
//
|
|
18
|
+
// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it
|
|
19
|
+
// without the package's transitive dependencies.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module
|
|
22
|
+
function tableRow(row) {
|
|
23
|
+
return `| ${row.num} | \`${row.toolCell()}\` | ${row.state} | ${row.action.label} | ${row.cure} |`;
|
|
24
|
+
}
|
|
25
|
+
// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module
|
|
26
|
+
function notDoneRow(entry) {
|
|
27
|
+
return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Render guards/L2-branch-state.md.
|
|
31
|
+
*
|
|
32
|
+
* Split into sections purely to stay inside the method-line budget — the join order is what makes them
|
|
33
|
+
* one file, so keep them adjacent and keep the byte-lock test as the arbiter.
|
|
34
|
+
*/
|
|
35
|
+
// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads
|
|
36
|
+
function renderL2Doc() {
|
|
37
|
+
return [
|
|
38
|
+
...renderHead(),
|
|
39
|
+
...renderTable(),
|
|
40
|
+
...renderNotes(),
|
|
41
|
+
...renderTail(),
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module
|
|
45
|
+
function renderHead() {
|
|
46
|
+
return [
|
|
47
|
+
'# L2 — branch state',
|
|
48
|
+
'',
|
|
49
|
+
'**Goal: may I work here, and is what I read current?**',
|
|
50
|
+
'',
|
|
51
|
+
'**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',
|
|
52
|
+
'`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',
|
|
53
|
+
'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',
|
|
54
|
+
'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is "read the',
|
|
55
|
+
'file, yes; `cat` the same file, no" — the same information, opposite verdicts, chosen by nobody.',
|
|
56
|
+
'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',
|
|
57
|
+
'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',
|
|
58
|
+
'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',
|
|
59
|
+
'',
|
|
60
|
+
'**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',
|
|
61
|
+
'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',
|
|
62
|
+
'`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',
|
|
63
|
+
'`ai-hook-rules/src/core/sync-main.ts`.',
|
|
64
|
+
'',
|
|
65
|
+
'## The four classes, and why there are four',
|
|
66
|
+
'',
|
|
67
|
+
'| | Write/Edit | Read | Bash |',
|
|
68
|
+
'|---|---|---|---|',
|
|
69
|
+
'| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',
|
|
70
|
+
'| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',
|
|
71
|
+
'',
|
|
72
|
+
'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',
|
|
73
|
+
'Write is neither. And the two Bash guards are not De Morgan duals — they differ in **polarity**',
|
|
74
|
+
'(merged is default-DENY + allowlist, stale-main is default-ALLOW + blocklist), in **quantifier**',
|
|
75
|
+
'(`every` segment must pass vs `some` segment triggers) and on the **empty command** (denies vs',
|
|
76
|
+
'allows). `pnpm build` is denied by one and allowed by the other. No single parameterised function',
|
|
77
|
+
'serves both, which is why the classes stay four while the switch became one.',
|
|
78
|
+
'',
|
|
79
|
+
'## The cache',
|
|
80
|
+
'',
|
|
81
|
+
'`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',
|
|
82
|
+
'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',
|
|
83
|
+
'one — so the first tool call of every session sees no cache and takes row ' + String(l2_rows_1.L2_FAIL_OPEN_ROW) + '. That is intended,',
|
|
84
|
+
'and it is why the on-main write block (row 5) must not depend on the cache.',
|
|
85
|
+
'',
|
|
86
|
+
'The file holds a **map of branch → status**, so every worktree\'s guards stay armed. Before that it',
|
|
87
|
+
'held one branch\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',
|
|
88
|
+
'rest abstained, thrashing as the lock changed hands.',
|
|
89
|
+
'',
|
|
90
|
+
'**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',
|
|
91
|
+
'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',
|
|
92
|
+
'--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',
|
|
93
|
+
'merged flag, which is safe only because "merged" is monotonic.',
|
|
94
|
+
'',
|
|
95
|
+
'**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',
|
|
96
|
+
'one refresher writing one cache. It used to be declared four times and read four times, which,',
|
|
97
|
+
'with the refresher\'s at-most-once-per-process latch and two config-blind callers ahead of the',
|
|
98
|
+
'guards, meant at most one of the four values could ever reach a spawn.',
|
|
99
|
+
'',
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
// The legend and the table itself — this is the ROW DATA.
|
|
103
|
+
// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module
|
|
104
|
+
function renderTable() {
|
|
105
|
+
return [
|
|
106
|
+
'## Table — one table, ordered, first match wins',
|
|
107
|
+
'',
|
|
108
|
+
'**Tools:** `B` Bash · `R` Read · `E` Write/Edit',
|
|
109
|
+
'',
|
|
110
|
+
'| # | tools | state | act | cure |',
|
|
111
|
+
'|---|---|---|---|---|',
|
|
112
|
+
...l2_rows_1.L2_ROWS.map(tableRow),
|
|
113
|
+
'',
|
|
114
|
+
`Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,
|
|
115
|
+
`marker-file scan, and row 5 is one \`git rev-parse\`. Row ${l2_rows_1.L2_FAIL_OPEN_ROW} is the divider: everything below it`,
|
|
116
|
+
`reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,
|
|
117
|
+
`another branch, or the forge could not be reached, evaluation STOPS at row ${l2_rows_1.L2_FAIL_OPEN_ROW} and ALLOWS.`,
|
|
118
|
+
'',
|
|
119
|
+
`Row ${l2_rows_1.L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,
|
|
120
|
+
'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',
|
|
121
|
+
'would silently re-point every reference. L1 does the same with its row 8.',
|
|
122
|
+
'',
|
|
123
|
+
'### The one rule that explains the tool column',
|
|
124
|
+
'',
|
|
125
|
+
'**`B` tracks `E` everywhere. `R` is judged separately in exactly one place — rows 6/7, on `main`.**',
|
|
126
|
+
'',
|
|
127
|
+
'A Read names exactly one file, so the guard can evaluate it precisely. A Bash command is opaque, so',
|
|
128
|
+
'it gets the conservative answer. Reading a CURRENT `main` is fine; the problem is that `main` is',
|
|
129
|
+
'almost always behind.',
|
|
130
|
+
'',
|
|
131
|
+
'### Why the order of row 5 is the most load-bearing thing here',
|
|
132
|
+
'',
|
|
133
|
+
'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',
|
|
134
|
+
'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',
|
|
135
|
+
'practice, because the agent discovers the problem within a command or two.',
|
|
136
|
+
'',
|
|
137
|
+
`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\``,
|
|
138
|
+
'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',
|
|
139
|
+
'where another tree can hold the refresh lock indefinitely.',
|
|
140
|
+
'',
|
|
141
|
+
'### Why row 9 can block reads without trapping you',
|
|
142
|
+
'',
|
|
143
|
+
'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',
|
|
144
|
+
'read to resolve the conflict. It does not, because **row 3 comes first**:',
|
|
145
|
+
'',
|
|
146
|
+
'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',
|
|
147
|
+
'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',
|
|
148
|
+
'strict.',
|
|
149
|
+
'',
|
|
150
|
+
'### Why row 8 can block reads on a dirty tree',
|
|
151
|
+
'',
|
|
152
|
+
'`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',
|
|
153
|
+
'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',
|
|
154
|
+
'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',
|
|
155
|
+
'clears it.',
|
|
156
|
+
'',
|
|
157
|
+
'Row 6 is the only place the dirty argument ever had teeth, because there the cure is `git pull`,',
|
|
158
|
+
'which genuinely is not a clean fast-forward on a dirty tree. Even there, `git stash` → `git pull` →',
|
|
159
|
+
'`git stash pop` works. **So there is no dirty row anywhere** — see "Not done" for where the code',
|
|
160
|
+
'still disagrees.',
|
|
161
|
+
'',
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
// How the log joins to this table, and the skip list. Prose plus the reason→row contract.
|
|
165
|
+
// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module
|
|
166
|
+
function renderNotes() {
|
|
167
|
+
return [
|
|
168
|
+
'## How a log line joins to a row',
|
|
169
|
+
'',
|
|
170
|
+
'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',
|
|
171
|
+
'`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means "this call was judged',
|
|
172
|
+
'by row 8" and you read the state, the verdict and the cure straight off this page.',
|
|
173
|
+
'',
|
|
174
|
+
'**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',
|
|
175
|
+
'matching row and switches on it, so deleting an L1 row deletes a block. L2\'s four classes each own',
|
|
176
|
+
'their own ladder (see "The four classes" above for why they cannot be one function), and',
|
|
177
|
+
'`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',
|
|
178
|
+
'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',
|
|
179
|
+
'with no row fails the build rather than logging `row=-` forever.',
|
|
180
|
+
'',
|
|
181
|
+
'## The skip list (row 4)',
|
|
182
|
+
'',
|
|
183
|
+
'Principle: **these get you OUT or tell you where you are.** They are not "working here".',
|
|
184
|
+
'',
|
|
185
|
+
'| group | commands |',
|
|
186
|
+
'|---|---|',
|
|
187
|
+
'| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',
|
|
188
|
+
'| make `main` current | `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only)* |',
|
|
189
|
+
'| orient | `git status\\|log\\|diff\\|branch` · `gh pr view\\|list\\|status\\|checks` |',
|
|
190
|
+
'| park work | `git stash` |',
|
|
191
|
+
'| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',
|
|
192
|
+
'',
|
|
193
|
+
'**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',
|
|
194
|
+
'`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',
|
|
195
|
+
'`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',
|
|
196
|
+
'',
|
|
197
|
+
'## Cannot tell — everything that lands on row ' + String(l2_rows_1.L2_FAIL_OPEN_ROW),
|
|
198
|
+
'',
|
|
199
|
+
'| state | expected? | treatment |',
|
|
200
|
+
'|---|---|---|',
|
|
201
|
+
'| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',
|
|
202
|
+
'| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',
|
|
203
|
+
'| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',
|
|
204
|
+
'| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',
|
|
205
|
+
'| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',
|
|
206
|
+
'',
|
|
207
|
+
'**Do NOT block to capture these cases.** `cache-absent` fires on every session\'s first call;',
|
|
208
|
+
'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',
|
|
209
|
+
'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',
|
|
210
|
+
'avoid.',
|
|
211
|
+
'',
|
|
212
|
+
'`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',
|
|
213
|
+
'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',
|
|
214
|
+
'"this branch has no merged PR" and by "we could not ask", and both logged a plain ALLOW — so from',
|
|
215
|
+
'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',
|
|
216
|
+
'standing down.',
|
|
217
|
+
'',
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
// The gaps between the table and the code, and the code anchors.
|
|
221
|
+
// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module
|
|
222
|
+
function renderTail() {
|
|
223
|
+
return [
|
|
224
|
+
'## Not done — rows the guards do not yet honour',
|
|
225
|
+
'',
|
|
226
|
+
'Each row below describes INTENT the code has not caught up with. They are listed rather than',
|
|
227
|
+
'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',
|
|
228
|
+
`them currently exits at row ${l2_rows_1.L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,
|
|
229
|
+
'',
|
|
230
|
+
'| row | the gap | why it has not shipped |',
|
|
231
|
+
'|---|---|---|',
|
|
232
|
+
...l2_rows_1.NOT_DONE.map(notDoneRow),
|
|
233
|
+
'',
|
|
234
|
+
'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',
|
|
235
|
+
'and the doc follows — it cannot rot into a list of things that were fixed years ago.',
|
|
236
|
+
'',
|
|
237
|
+
'## Incidents these guards exist because of',
|
|
238
|
+
'',
|
|
239
|
+
'- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',
|
|
240
|
+
' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',
|
|
241
|
+
' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',
|
|
242
|
+
' agent\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',
|
|
243
|
+
' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',
|
|
244
|
+
' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',
|
|
245
|
+
' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',
|
|
246
|
+
'- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',
|
|
247
|
+
' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',
|
|
248
|
+
' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',
|
|
249
|
+
' existed upstream. *The logs read "read-stale-guard handled", which is worse than no guard: it',
|
|
250
|
+
' looks covered.*',
|
|
251
|
+
'- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',
|
|
252
|
+
' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',
|
|
253
|
+
' loaded and logged on that very path.**',
|
|
254
|
+
'',
|
|
255
|
+
'---',
|
|
256
|
+
'',
|
|
257
|
+
'',
|
|
258
|
+
'## Code anchors',
|
|
259
|
+
'',
|
|
260
|
+
'| section | file | symbol |',
|
|
261
|
+
'|---|---|---|',
|
|
262
|
+
'| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',
|
|
263
|
+
'| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',
|
|
264
|
+
'| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',
|
|
265
|
+
'| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `staleContentRead`, `bareCheckoutOfMain` |',
|
|
266
|
+
'| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',
|
|
267
|
+
'| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',
|
|
268
|
+
'| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',
|
|
269
|
+
'| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',
|
|
270
|
+
'| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',
|
|
271
|
+
'',
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=l2-doc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":";;AAoCA,kCAOC;AA3CD,uCAAkF;AAElF,8EAA8E;AAC9E,oDAAoD;AACpD,EAAE;AACF,uGAAuG;AACvG,uGAAuG;AACvG,mCAAmC;AACnC,EAAE;AACF,uGAAuG;AACvG,qGAAqG;AACrG,4FAA4F;AAC5F,2FAA2F;AAC3F,uDAAuD;AACvD,EAAE;AACF,sGAAsG;AACtG,iDAAiD;AACjD,8EAA8E;AAE9E,iHAAiH;AACjH,SAAS,QAAQ,CAAC,GAAU;IACxB,OAAO,KAAK,GAAG,CAAC,GAAG,QAAQ,GAAG,CAAC,QAAQ,EAAE,QAAQ,GAAG,CAAC,KAAK,MAAM,GAAG,CAAC,MAAM,CAAC,KAAK,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC;AACvG,CAAC;AAED,iHAAiH;AACjH,SAAS,UAAU,CAAC,KAAgB;IAChC,OAAO,KAAK,KAAK,CAAC,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC;AAC5D,CAAC;AAED;;;;;GAKG;AACH,6GAA6G;AAC7G,SAAgB,WAAW;IACvB,OAAO;QACH,GAAG,UAAU,EAAE;QACf,GAAG,WAAW,EAAE;QAChB,GAAG,WAAW,EAAE;QAChB,GAAG,UAAU,EAAE;KAClB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACjB,CAAC;AAED,iHAAiH;AACjH,SAAS,UAAU;IACf,OAAO;QACH,qBAAqB;QACrB,EAAE;QACF,wDAAwD;QACxD,EAAE;QACF,0FAA0F;QAC1F,mGAAmG;QACnG,iGAAiG;QACjG,kGAAkG;QAClG,kGAAkG;QAClG,oGAAoG;QACpG,iGAAiG;QACjG,sGAAsG;QACtG,EAAE;QACF,oHAAoH;QACpH,uEAAuE;QACvE,iFAAiF;QACjF,wCAAwC;QACxC,EAAE;QACF,6CAA6C;QAC7C,EAAE;QACF,gCAAgC;QAChC,mBAAmB;QACnB,wGAAwG;QACxG,4GAA4G;QAC5G,EAAE;QACF,kGAAkG;QAClG,iGAAiG;QACjG,kGAAkG;QAClG,gGAAgG;QAChG,mGAAmG;QACnG,8EAA8E;QAC9E,EAAE;QACF,cAAc;QACd,EAAE;QACF,2FAA2F;QAC3F,iGAAiG;QACjG,4EAA4E,GAAG,MAAM,CAAC,0BAAgB,CAAC,GAAG,qBAAqB;QAC/H,6EAA6E;QAC7E,EAAE;QACF,qGAAqG;QACrG,oGAAoG;QACpG,sDAAsD;QACtD,EAAE;QACF,gGAAgG;QAChG,6FAA6F;QAC7F,iGAAiG;QACjG,gEAAgE;QAChE,EAAE;QACF,mGAAmG;QACnG,gGAAgG;QAChG,gGAAgG;QAChG,wEAAwE;QACxE,EAAE;KACL,CAAC;AACN,CAAC;AAED,0DAA0D;AAC1D,kHAAkH;AAClH,SAAS,WAAW;IAChB,OAAO;QACH,iDAAiD;QACjD,EAAE;QACF,iDAAiD;QACjD,EAAE;QACF,oCAAoC;QACpC,uBAAuB;QACvB,GAAG,iBAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;QACxB,EAAE;QACF,8FAA8F;QAC9F,6DAA6D,0BAAgB,sCAAsC;QACnH,8FAA8F;QAC9F,8EAA8E,0BAAgB,cAAc;QAC5G,EAAE;QACF,OAAO,0BAAgB,uFAAuF;QAC9G,qGAAqG;QACrG,2EAA2E;QAC3E,EAAE;QACF,gDAAgD;QAChD,EAAE;QACF,qGAAqG;QACrG,EAAE;QACF,qGAAqG;QACrG,kGAAkG;QAClG,uBAAuB;QACvB,EAAE;QACF,gEAAgE;QAChE,EAAE;QACF,qGAAqG;QACrG,oGAAoG;QACpG,4EAA4E;QAC5E,EAAE;QACF,gFAAgF,0BAAgB,yBAAyB;QACzH,qGAAqG;QACrG,4DAA4D;QAC5D,EAAE;QACF,oDAAoD;QACpD,EAAE;QACF,iGAAiG;QACjG,2EAA2E;QAC3E,EAAE;QACF,+FAA+F;QAC/F,mGAAmG;QACnG,SAAS;QACT,EAAE;QACF,+CAA+C;QAC/C,EAAE;QACF,mGAAmG;QACnG,mGAAmG;QACnG,iGAAiG;QACjG,YAAY;QACZ,EAAE;QACF,kGAAkG;QAClG,qGAAqG;QACrG,kGAAkG;QAClG,kBAAkB;QAClB,EAAE;KACL,CAAC;AACN,CAAC;AAED,0FAA0F;AAC1F,iHAAiH;AACjH,SAAS,WAAW;IAChB,OAAO;QACH,kCAAkC;QAClC,EAAE;QACF,iGAAiG;QACjG,qGAAqG;QACrG,oFAAoF;QACpF,EAAE;QACF,qGAAqG;QACrG,qGAAqG;QACrG,0FAA0F;QAC1F,mGAAmG;QACnG,qGAAqG;QACrG,kEAAkE;QAClE,EAAE;QACF,0BAA0B;QAC1B,EAAE;QACF,0FAA0F;QAC1F,EAAE;QACF,sBAAsB;QACtB,WAAW;QACX,0JAA0J;QAC1J,kHAAkH;QAClH,yFAAyF;QACzF,6BAA6B;QAC7B,6FAA6F;QAC7F,EAAE;QACF,qGAAqG;QACrG,qGAAqG;QACrG,oGAAoG;QACpG,EAAE;QACF,gDAAgD,GAAG,MAAM,CAAC,0BAAgB,CAAC;QAC3E,EAAE;QACF,mCAAmC;QACnC,eAAe;QACf,gJAAgJ;QAChJ,iFAAiF;QACjF,yHAAyH;QACzH,mFAAmF;QACnF,iHAAiH;QACjH,EAAE;QACF,+FAA+F;QAC/F,gGAAgG;QAChG,qGAAqG;QACrG,QAAQ;QACR,EAAE;QACF,6FAA6F;QAC7F,sGAAsG;QACtG,mGAAmG;QACnG,kGAAkG;QAClG,gBAAgB;QAChB,EAAE;KACL,CAAC;AACN,CAAC;AAED,iEAAiE;AACjE,gHAAgH;AAChH,SAAS,UAAU;IACf,OAAO;QACH,iDAAiD;QACjD,EAAE;QACF,8FAA8F;QAC9F,qGAAqG;QACrG,+BAA+B,0BAAgB,yDAAyD;QACxG,EAAE;QACF,4CAA4C;QAC5C,eAAe;QACf,GAAG,kBAAQ,CAAC,GAAG,CAAC,UAAU,CAAC;QAC3B,EAAE;QACF,sGAAsG;QACtG,sFAAsF;QACtF,EAAE;QACF,4CAA4C;QAC5C,EAAE;QACF,mGAAmG;QACnG,oGAAoG;QACpG,kGAAkG;QAClG,kGAAkG;QAClG,qGAAqG;QACrG,iGAAiG;QACjG,qGAAqG;QACrG,mGAAmG;QACnG,8FAA8F;QAC9F,mGAAmG;QACnG,iGAAiG;QACjG,mBAAmB;QACnB,qGAAqG;QACrG,iGAAiG;QACjG,0CAA0C;QAC1C,EAAE;QACF,KAAK;QACL,EAAE;QACF,EAAE;QACF,iBAAiB;QACjB,EAAE;QACF,6BAA6B;QAC7B,eAAe;QACf,oHAAoH;QACpH,qFAAqF;QACrF,8GAA8G;QAC9G,0HAA0H;QAC1H,oIAAoI;QACpI,4IAA4I;QAC5I,+EAA+E;QAC/E,uIAAuI;QACvI,wIAAwI;QACxI,EAAE;KACL,CAAC;AACN,CAAC","sourcesContent":["import { L2Row, L2NotDone, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE } 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 * 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 ...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. And the two Bash guards are not De Morgan duals — they differ in **polarity**',\n '(merged is default-DENY + allowlist, stale-main is default-ALLOW + blocklist), in **quantifier**',\n '(`every` segment must pass vs `some` segment triggers) and on the **empty command** (denies vs',\n 'allows). `pnpm build` is denied by one and allowed by the other. No single parameterised function',\n 'serves both, which is why the classes stay four while the switch became one.',\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 is the only place the dirty argument ever had teeth, because there the cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. Even there, `git stash` → `git pull` →',\n '`git stash pop` works. **So there is no dirty row anywhere** — see \"Not done\" for where the code',\n 'still disagrees.',\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 -- third 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 | `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only)* |',\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 '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 '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"]}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Which tools a row covers. `B` Bash · `R` Read · `E` Write/Edit. */
|
|
2
|
+
export type L2Tool = 'B' | 'R' | 'E';
|
|
3
|
+
/** What L2 does with a row — the same action codebook every layer reports in (GUARD_MATRIX.md). */
|
|
4
|
+
export type L2ActionKind = 'allow' | 'exempt' | 'block' | 'fail-open';
|
|
5
|
+
/**
|
|
6
|
+
* The TERMINAL fail-open row, and the one number in this table that is not from the 1-10 design.
|
|
7
|
+
*
|
|
8
|
+
* Everything in rows 6-10 needs the main-sync cache, and the cache is written by a fire-and-forget
|
|
9
|
+
* refresher that populates it for the NEXT call — so the first tool call of every session has none.
|
|
10
|
+
* "Stop here and ALLOW" was written as a DIVIDER in the design table, i.e. as prose between two blocks
|
|
11
|
+
* of rows. Prose cannot be stamped into a log line, and this is the single most frequently taken exit
|
|
12
|
+
* in the whole layer (every session's first call, every unreadable branch, every unreachable forge), so
|
|
13
|
+
* it is a row with a number like any other.
|
|
14
|
+
*
|
|
15
|
+
* It is 11 rather than 6-with-a-renumber because row numbers are IDENTITY here: they are printed in the
|
|
16
|
+
* doc and logged as `row=`, so shifting 6-10 down would silently re-point every reference. The doc
|
|
17
|
+
* prints it in its true position, between rows 5 and 6, with its number shown — same treatment L1 gives
|
|
18
|
+
* row 8, which is printed third and numbered 8.
|
|
19
|
+
*/
|
|
20
|
+
export declare const L2_FAIL_OPEN_ROW = 11;
|
|
21
|
+
/** The `act` cell: the doc's literal label, plus the machine-readable kind behind it. */
|
|
22
|
+
export declare class L2Action {
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly kind: L2ActionKind;
|
|
25
|
+
constructor(label: string, kind: L2ActionKind);
|
|
26
|
+
}
|
|
27
|
+
export declare const L2_ALLOW: L2Action;
|
|
28
|
+
export declare const L2_EXEMPT: L2Action;
|
|
29
|
+
export declare const L2_BLOCK: L2Action;
|
|
30
|
+
export declare const L2_FAIL_OPEN: L2Action;
|
|
31
|
+
/**
|
|
32
|
+
* One row of L2's decision table.
|
|
33
|
+
*
|
|
34
|
+
* `cure` is rendered verbatim into the doc and is LITERAL by policy: L0's cure-reachability discipline
|
|
35
|
+
* says a message pointing at documentation for its own remedy cannot be tested, and it caught a fault
|
|
36
|
+
* prescribing a bin that had been renamed away. `—` is the only legal non-command cure, and only on a
|
|
37
|
+
* row that allows.
|
|
38
|
+
*/
|
|
39
|
+
export declare class L2Row {
|
|
40
|
+
readonly num: number;
|
|
41
|
+
readonly tools: readonly L2Tool[];
|
|
42
|
+
/** The `state` cell, verbatim. */
|
|
43
|
+
readonly state: string;
|
|
44
|
+
readonly action: L2Action;
|
|
45
|
+
/** The `cure` cell, verbatim. `—` when the row allows. */
|
|
46
|
+
readonly cure: string;
|
|
47
|
+
constructor(num: number, tools: readonly L2Tool[],
|
|
48
|
+
/** The `state` cell, verbatim. */
|
|
49
|
+
state: string, action: L2Action,
|
|
50
|
+
/** The `cure` cell, verbatim. `—` when the row allows. */
|
|
51
|
+
cure: string);
|
|
52
|
+
/** `B R E`, the doc's own spelling of the tool cell. */
|
|
53
|
+
toolCell(): string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* THE ELEVEN L2 ROWS, in first-match-wins order.
|
|
57
|
+
*
|
|
58
|
+
* Rows 1-5 need NO cache and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a marker-file
|
|
59
|
+
* scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.
|
|
60
|
+
*
|
|
61
|
+
* THE ORDER OF ROW 5 IS THE MOST LOAD-BEARING THING IN THIS TABLE. Put "on main" BELOW the divider and
|
|
62
|
+
* writes on `main` are permitted for the whole first call of every session — and permanently in a
|
|
63
|
+
* multi-worktree repo, where another tree may hold the cache lock indefinitely.
|
|
64
|
+
*
|
|
65
|
+
* `B` tracks `E` everywhere; `R` is judged separately in exactly ONE place, rows 6/7 on `main`. A Read
|
|
66
|
+
* names exactly one file so the guard can evaluate it precisely; a Bash command is opaque and gets the
|
|
67
|
+
* conservative answer. Reading a CURRENT `main` is fine — the problem is that `main` is almost always
|
|
68
|
+
* behind.
|
|
69
|
+
*/
|
|
70
|
+
export declare const L2_ROWS: readonly L2Row[];
|
|
71
|
+
/**
|
|
72
|
+
* The row a logged reason belongs to, or null when nothing claims it.
|
|
73
|
+
*
|
|
74
|
+
* Null rather than a default row: a reason with no row is a HOLE in the table, and defaulting it to
|
|
75
|
+
* "fail-open" would hide exactly the drift the exhaustiveness spec exists to catch. The guards render
|
|
76
|
+
* null as `row=-`, so an unmapped reason is visible in the log too, not only in CI.
|
|
77
|
+
*/
|
|
78
|
+
export declare function l2RowForReason(reason: string): number | null;
|
|
79
|
+
/** Every reason string this table claims, for the exhaustiveness spec. */
|
|
80
|
+
export declare function l2MappedReasons(): readonly string[];
|
|
81
|
+
/** One documented gap between a row and what the guards actually do today. Data-only. */
|
|
82
|
+
export declare class L2NotDone {
|
|
83
|
+
readonly row: number;
|
|
84
|
+
readonly gap: string;
|
|
85
|
+
readonly why: string;
|
|
86
|
+
constructor(row: number, gap: string, why: string);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* WHERE THE TABLE AND THE CODE DISAGREE, stated rather than papered over.
|
|
90
|
+
*
|
|
91
|
+
* The L1 precedent is `## Not done — \`o\` is not exempt yet`: a row the runner cannot reach, named in
|
|
92
|
+
* the generated doc with the reason it has not shipped. The same treatment applies here, and it is what
|
|
93
|
+
* makes it safe to publish a table the guards do not yet dispatch from — a reader is told exactly which
|
|
94
|
+
* rows describe intent rather than behaviour, and the log's `row=` stamps land on row 11 for every one
|
|
95
|
+
* of these, so the trail never claims the strict row fired.
|
|
96
|
+
*/
|
|
97
|
+
export declare const NOT_DONE: readonly L2NotDone[];
|