@webpieces/ai-hook-rules 0.4.646 → 0.4.648
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/guard-index-doc.d.ts +63 -0
- package/src/core/guard-index-doc.js +172 -0
- package/src/core/guard-index-doc.js.map +1 -0
- package/src/core/l2-doc.js +53 -16
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-rows.js +17 -7
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +24 -21
- package/src/core/rules/read-stale-guard.js +41 -49
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +4 -5
- package/src/core/rules/stale-main-bash-guard.js +4 -5
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +27 -15
- package/src/core/rules/stale-main-message.js +38 -33
- 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.648",
|
|
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.648"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Opening marker of the generated block, as it appears in GUARD_MATRIX.md. */
|
|
2
|
+
export declare const GUARD_INDEX_BEGIN = "<!-- BEGIN GENERATED \u2014 GuardIndexDoc.render() in ai-hook-rules/src/core/guard-index-doc.ts; run `pnpm guards:generate` -->";
|
|
3
|
+
/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */
|
|
4
|
+
export declare const GUARD_INDEX_END = "<!-- END GENERATED \u2014 hand-written prose resumes here -->";
|
|
5
|
+
/** How much of a layer's doc is rendered from code. Data-only → a class, per CLAUDE.md. */
|
|
6
|
+
export declare class LayerGeneration {
|
|
7
|
+
/** `L0`, `L1`, … as the doc prints it. */
|
|
8
|
+
readonly layer: string;
|
|
9
|
+
/** The question this layer answers, for the layer table. */
|
|
10
|
+
readonly goal: string;
|
|
11
|
+
/** The `webpieces.config.json` key, or '' when the layer has none on purpose. */
|
|
12
|
+
readonly configKey: string;
|
|
13
|
+
/** Repo-relative path of the layer's doc. */
|
|
14
|
+
readonly doc: string;
|
|
15
|
+
/** The array the doc is rendered FROM, or '' when nothing is generated yet. */
|
|
16
|
+
readonly source: string;
|
|
17
|
+
/** The status cell, verbatim. */
|
|
18
|
+
readonly status: string;
|
|
19
|
+
constructor(
|
|
20
|
+
/** `L0`, `L1`, … as the doc prints it. */
|
|
21
|
+
layer: string,
|
|
22
|
+
/** The question this layer answers, for the layer table. */
|
|
23
|
+
goal: string,
|
|
24
|
+
/** The `webpieces.config.json` key, or '' when the layer has none on purpose. */
|
|
25
|
+
configKey: string,
|
|
26
|
+
/** Repo-relative path of the layer's doc. */
|
|
27
|
+
doc: string,
|
|
28
|
+
/** The array the doc is rendered FROM, or '' when nothing is generated yet. */
|
|
29
|
+
source: string,
|
|
30
|
+
/** The status cell, verbatim. */
|
|
31
|
+
status: string);
|
|
32
|
+
/** The config-key cell — layers with no key say so, and say it is deliberate. */
|
|
33
|
+
keyCell(): string;
|
|
34
|
+
sourceCell(): string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* THE FIVE LAYERS, and how much of each doc is machine-owned.
|
|
38
|
+
*
|
|
39
|
+
* Adding a layer, or converting one to generated, is an edit HERE and nowhere else — the index table,
|
|
40
|
+
* the layer table and the one-command sentence all render from this array, so they cannot disagree
|
|
41
|
+
* with each other the way the hand-written version did.
|
|
42
|
+
*/
|
|
43
|
+
export declare const LAYER_GENERATION: readonly LayerGeneration[];
|
|
44
|
+
/**
|
|
45
|
+
* The generated block of GUARD_MATRIX.md, and the splice that puts it there.
|
|
46
|
+
*
|
|
47
|
+
* Same class shape as L0ToolingDoc — renderer, extractor and splicer as one unit, driven by one spec.
|
|
48
|
+
*/
|
|
49
|
+
export declare class GuardIndexDoc {
|
|
50
|
+
/** The whole generated block, WITHOUT the markers — those belong to the file, not the renderer. */
|
|
51
|
+
render(): string;
|
|
52
|
+
/**
|
|
53
|
+
* The generated text of `doc`, exactly as committed. Throws when a marker is missing or doubled —
|
|
54
|
+
* a silently-unspliced doc is the drift this arrangement exists to end.
|
|
55
|
+
*/
|
|
56
|
+
extract(doc: string): string;
|
|
57
|
+
/** `doc` with the generated block replaced by today's render. Preserves every byte outside it. */
|
|
58
|
+
splice(doc: string): string;
|
|
59
|
+
private preamble;
|
|
60
|
+
private statusTable;
|
|
61
|
+
private layerTable;
|
|
62
|
+
private generatedWholeCount;
|
|
63
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// GUARD_MATRIX.md's generation-status block, rendered from the one array that knows.
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS FIXES IS IN THE FILE ITSELF. GUARD_MATRIX.md is the INDEX — the page that tells you
|
|
6
|
+
// which layer docs are generated and which are hand-written — and it was hand-written, so it went out
|
|
7
|
+
// of date about generation. It ended up contradicting itself inside ten lines: a blockquote asserting
|
|
8
|
+
// "L1's table ... generated" and "EVERY REMAINING LAYER FILE IS HAND-WRITTEN TODAY", directly above a
|
|
9
|
+
// table listing L2 as generated and byte-locked. Both were once true; L2 was converted and only one of
|
|
10
|
+
// the two places was updated.
|
|
11
|
+
//
|
|
12
|
+
// A doc that describes generation is exactly the doc that must be generated, because it is the one
|
|
13
|
+
// place a reader goes to find out whether to trust the others. So the STATUS is data here, the block
|
|
14
|
+
// between the markers is spliced from it, and a spec byte-locks the result — the same treatment
|
|
15
|
+
// guards/L0-tooling.md gets, and for the same reason: everything outside the markers stays
|
|
16
|
+
// hand-written prose that the splice preserves byte for byte.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IS DELIBERATELY NOT GENERATED: the rest of GUARD_MATRIX.md — the action codebook, the launch
|
|
19
|
+
// guarantee, the two rules that constrain guard changes. Those are prose about DESIGN, not facts about
|
|
20
|
+
// the code, and a generator that owns them would be inventing a schema for essays.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.GuardIndexDoc = exports.LAYER_GENERATION = exports.LayerGeneration = exports.GUARD_INDEX_END = exports.GUARD_INDEX_BEGIN = void 0;
|
|
24
|
+
/** Opening marker of the generated block, as it appears in GUARD_MATRIX.md. */
|
|
25
|
+
exports.GUARD_INDEX_BEGIN = '<!-- BEGIN GENERATED — GuardIndexDoc.render() in ai-hook-rules/src/core/guard-index-doc.ts; run `pnpm guards:generate` -->';
|
|
26
|
+
/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */
|
|
27
|
+
exports.GUARD_INDEX_END = '<!-- END GENERATED — hand-written prose resumes here -->';
|
|
28
|
+
/** How much of a layer's doc is rendered from code. Data-only → a class, per CLAUDE.md. */
|
|
29
|
+
class LayerGeneration {
|
|
30
|
+
layer;
|
|
31
|
+
goal;
|
|
32
|
+
configKey;
|
|
33
|
+
doc;
|
|
34
|
+
source;
|
|
35
|
+
status;
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/max-params -- the five cells of one status row
|
|
37
|
+
constructor(
|
|
38
|
+
/** `L0`, `L1`, … as the doc prints it. */
|
|
39
|
+
layer,
|
|
40
|
+
/** The question this layer answers, for the layer table. */
|
|
41
|
+
goal,
|
|
42
|
+
/** The `webpieces.config.json` key, or '' when the layer has none on purpose. */
|
|
43
|
+
configKey,
|
|
44
|
+
/** Repo-relative path of the layer's doc. */
|
|
45
|
+
doc,
|
|
46
|
+
/** The array the doc is rendered FROM, or '' when nothing is generated yet. */
|
|
47
|
+
source,
|
|
48
|
+
/** The status cell, verbatim. */
|
|
49
|
+
status) {
|
|
50
|
+
this.layer = layer;
|
|
51
|
+
this.goal = goal;
|
|
52
|
+
this.configKey = configKey;
|
|
53
|
+
this.doc = doc;
|
|
54
|
+
this.source = source;
|
|
55
|
+
this.status = status;
|
|
56
|
+
}
|
|
57
|
+
/** The config-key cell — layers with no key say so, and say it is deliberate. */
|
|
58
|
+
keyCell() {
|
|
59
|
+
return this.configKey === '' ? '*(none — deliberate, see below)*' : `\`${this.configKey}\``;
|
|
60
|
+
}
|
|
61
|
+
sourceCell() {
|
|
62
|
+
return this.source === '' ? '—' : `\`${this.source}\``;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.LayerGeneration = LayerGeneration;
|
|
66
|
+
/**
|
|
67
|
+
* THE FIVE LAYERS, and how much of each doc is machine-owned.
|
|
68
|
+
*
|
|
69
|
+
* Adding a layer, or converting one to generated, is an edit HERE and nowhere else — the index table,
|
|
70
|
+
* the layer table and the one-command sentence all render from this array, so they cannot disagree
|
|
71
|
+
* with each other the way the hand-written version did.
|
|
72
|
+
*/
|
|
73
|
+
exports.LAYER_GENERATION = [
|
|
74
|
+
new LayerGeneration('L0', 'Is webpieces itself trustworthy right now?', '', 'guards/L0-tooling.md', 'L0_FAULTS + L0_ALLOWLIST', '**PARTLY generated, byte-locked** — only the block between the `L0_DOC_BEGIN` / `L0_DOC_END` markers; every byte outside it is hand-written prose the splice preserves'),
|
|
75
|
+
new LayerGeneration('L1', 'Is this call ours to judge, are the versions in sync, and is git run from the root?', '', 'guards/L1-location.md', 'L1_ROWS', '**generated whole, byte-locked** — table, use cases and cures all render from the array the runner dispatches on'),
|
|
76
|
+
new LayerGeneration('L2', 'May I work here, and is what I read current?', 'branch-state-guard', 'guards/L2-branch-state.md', 'L2_ROWS', '**generated whole, byte-locked** — table, use cases and the "Not done" gaps all render from the rows'),
|
|
77
|
+
new LayerGeneration('L3', 'Which dead branches and worktrees get reaped?', 'branch-creation-guard', 'guards/L3-branch-cleanup.md', '', '**hand-written** — not yet converted, so it CAN go out of date; the code is the authority'),
|
|
78
|
+
new LayerGeneration('L4', 'Does every merge and PR go through the gated flow?', 'pr-lifecycle-guard', 'guards/L4-pr-lifecycle.md', '', '**hand-written** — not yet converted, so it CAN go out of date; the code is the authority'),
|
|
79
|
+
];
|
|
80
|
+
/**
|
|
81
|
+
* The generated block of GUARD_MATRIX.md, and the splice that puts it there.
|
|
82
|
+
*
|
|
83
|
+
* Same class shape as L0ToolingDoc — renderer, extractor and splicer as one unit, driven by one spec.
|
|
84
|
+
*/
|
|
85
|
+
class GuardIndexDoc {
|
|
86
|
+
/** The whole generated block, WITHOUT the markers — those belong to the file, not the renderer. */
|
|
87
|
+
render() {
|
|
88
|
+
return [
|
|
89
|
+
...this.preamble(),
|
|
90
|
+
...this.statusTable(),
|
|
91
|
+
...this.layerTable(),
|
|
92
|
+
].join('\n');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The generated text of `doc`, exactly as committed. Throws when a marker is missing or doubled —
|
|
96
|
+
* a silently-unspliced doc is the drift this arrangement exists to end.
|
|
97
|
+
*/
|
|
98
|
+
extract(doc) {
|
|
99
|
+
const opens = doc.split(exports.GUARD_INDEX_BEGIN).length - 1;
|
|
100
|
+
const closes = doc.split(exports.GUARD_INDEX_END).length - 1;
|
|
101
|
+
if (opens !== 1 || closes !== 1) {
|
|
102
|
+
throw new Error(`GUARD_MATRIX.md must carry exactly one BEGIN/END marker pair, found ${String(opens)}/${String(closes)}`);
|
|
103
|
+
}
|
|
104
|
+
const afterBegin = doc.slice(doc.indexOf(exports.GUARD_INDEX_BEGIN) + exports.GUARD_INDEX_BEGIN.length);
|
|
105
|
+
return afterBegin.slice(0, afterBegin.indexOf(exports.GUARD_INDEX_END)).replace(/^\n/, '').replace(/\n$/, '');
|
|
106
|
+
}
|
|
107
|
+
/** `doc` with the generated block replaced by today's render. Preserves every byte outside it. */
|
|
108
|
+
splice(doc) {
|
|
109
|
+
const head = doc.slice(0, doc.indexOf(exports.GUARD_INDEX_BEGIN) + exports.GUARD_INDEX_BEGIN.length);
|
|
110
|
+
const tail = doc.slice(doc.indexOf(exports.GUARD_INDEX_END));
|
|
111
|
+
// extract() is called for its VALIDATION — one marker pair — before anything is rewritten.
|
|
112
|
+
this.extract(doc);
|
|
113
|
+
return `${head}\n${this.render()}\n${tail}`;
|
|
114
|
+
}
|
|
115
|
+
preamble() {
|
|
116
|
+
return [
|
|
117
|
+
'> **GENERATED — do not hand-edit between the markers.** Rendered by `GuardIndexDoc.render()`',
|
|
118
|
+
'> from `LAYER_GENERATION` (`ai-hook-rules/src/core/guard-index-doc.ts`); regenerate with',
|
|
119
|
+
'> `pnpm guards:generate`. A spec byte-locks it.',
|
|
120
|
+
'>',
|
|
121
|
+
'> This block used to be prose, and it drifted about the one subject it exists to report: it',
|
|
122
|
+
'> claimed every layer but L0 and L1 was hand-written, ten lines above a table listing L2 as',
|
|
123
|
+
'> generated. The index that tells you which docs to trust is the last place that can afford',
|
|
124
|
+
'> to be wrong, so it is data now.',
|
|
125
|
+
'',
|
|
126
|
+
'## Generation status',
|
|
127
|
+
'',
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
statusTable() {
|
|
131
|
+
return [
|
|
132
|
+
'| layer | source of truth | doc |',
|
|
133
|
+
'|---|---|---|',
|
|
134
|
+
...exports.LAYER_GENERATION.map((entry) => `| ${entry.layer} | ${entry.sourceCell()} | ${entry.status} |`),
|
|
135
|
+
'',
|
|
136
|
+
`One command regenerates every generated artifact: \`pnpm guards:generate\`. It rewrites the`,
|
|
137
|
+
`${this.generatedWholeCount()} whole-file docs, SPLICES the marked blocks in L0's doc and in this file, writes the`,
|
|
138
|
+
'AI-facing copy of the L2 matrix, and renders the POSIX-sh shim template.',
|
|
139
|
+
'',
|
|
140
|
+
'For a WHOLE-file layer the split is: **row data** lives in the array, **prose** lives as literal',
|
|
141
|
+
'lines inside the renderer, there is no third place, and the byte-lock spec fails on any hand',
|
|
142
|
+
'edit. **The two SPLICED files are the exception**: everything outside their marker pairs is',
|
|
143
|
+
'hand-written prose the splice preserves byte for byte, and the spec deliberately permits you to',
|
|
144
|
+
'edit it.',
|
|
145
|
+
'',
|
|
146
|
+
'**L1 and L2 differ in one way worth knowing.** L1 DISPATCHES from its rows — the runner takes the',
|
|
147
|
+
'first matching row and switches on its `blockId`, so deleting a row deletes a block. L2\'s four',
|
|
148
|
+
'guard classes each own their own ladder (they diverge in polarity, quantifier and empty-command',
|
|
149
|
+
'handling, deliberately), so L2 joins to its rows by REASON instead: every decision-log line carries',
|
|
150
|
+
'`row=<n>` derived from the reason the guard logged, plus the `cure=` that row prescribed, and a spec',
|
|
151
|
+
'asserts the reason map is exhaustive against the guard sources.',
|
|
152
|
+
'',
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
layerTable() {
|
|
156
|
+
return [
|
|
157
|
+
'## The layers',
|
|
158
|
+
'',
|
|
159
|
+
'| layer | goal — the question it answers | config key | doc |',
|
|
160
|
+
'|---|---|---|---|',
|
|
161
|
+
...exports.LAYER_GENERATION.map((entry) => `| **${entry.layer}** | ${entry.goal} | ${entry.keyCell()} | [${entry.layer}](${entry.doc}) |`),
|
|
162
|
+
'',
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
// Rendered into the prose so the sentence cannot claim a count the array disagrees with — the exact
|
|
166
|
+
// failure mode this file is fixing ("One command regenerates all three" survived L2's conversion).
|
|
167
|
+
generatedWholeCount() {
|
|
168
|
+
return String(exports.LAYER_GENERATION.filter((entry) => entry.status.includes('generated whole')).length);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
exports.GuardIndexDoc = GuardIndexDoc;
|
|
172
|
+
//# sourceMappingURL=guard-index-doc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard-index-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/guard-index-doc.ts"],"names":[],"mappings":"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","sourcesContent":["// ---------------------------------------------------------------------------\n// GUARD_MATRIX.md's generation-status block, rendered from the one array that knows.\n//\n// THE BUG THIS FIXES IS IN THE FILE ITSELF. GUARD_MATRIX.md is the INDEX — the page that tells you\n// which layer docs are generated and which are hand-written — and it was hand-written, so it went out\n// of date about generation. It ended up contradicting itself inside ten lines: a blockquote asserting\n// \"L1's table ... generated\" and \"EVERY REMAINING LAYER FILE IS HAND-WRITTEN TODAY\", directly above a\n// table listing L2 as generated and byte-locked. Both were once true; L2 was converted and only one of\n// the two places was updated.\n//\n// A doc that describes generation is exactly the doc that must be generated, because it is the one\n// place a reader goes to find out whether to trust the others. So the STATUS is data here, the block\n// between the markers is spliced from it, and a spec byte-locks the result — the same treatment\n// guards/L0-tooling.md gets, and for the same reason: everything outside the markers stays\n// hand-written prose that the splice preserves byte for byte.\n//\n// WHAT IS DELIBERATELY NOT GENERATED: the rest of GUARD_MATRIX.md — the action codebook, the launch\n// guarantee, the two rules that constrain guard changes. Those are prose about DESIGN, not facts about\n// the code, and a generator that owns them would be inventing a schema for essays.\n// ---------------------------------------------------------------------------\n\n/** Opening marker of the generated block, as it appears in GUARD_MATRIX.md. */\nexport const GUARD_INDEX_BEGIN = '<!-- BEGIN GENERATED — GuardIndexDoc.render() in ai-hook-rules/src/core/guard-index-doc.ts; run `pnpm guards:generate` -->';\n\n/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */\nexport const GUARD_INDEX_END = '<!-- END GENERATED — hand-written prose resumes here -->';\n\n/** How much of a layer's doc is rendered from code. Data-only → a class, per CLAUDE.md. */\nexport class LayerGeneration {\n // eslint-disable-next-line @typescript-eslint/max-params -- the five cells of one status row\n constructor(\n /** `L0`, `L1`, … as the doc prints it. */\n readonly layer: string,\n /** The question this layer answers, for the layer table. */\n readonly goal: string,\n /** The `webpieces.config.json` key, or '' when the layer has none on purpose. */\n readonly configKey: string,\n /** Repo-relative path of the layer's doc. */\n readonly doc: string,\n /** The array the doc is rendered FROM, or '' when nothing is generated yet. */\n readonly source: string,\n /** The status cell, verbatim. */\n readonly status: string,\n ) {}\n\n /** The config-key cell — layers with no key say so, and say it is deliberate. */\n keyCell(): string {\n return this.configKey === '' ? '*(none — deliberate, see below)*' : `\\`${this.configKey}\\``;\n }\n\n sourceCell(): string {\n return this.source === '' ? '—' : `\\`${this.source}\\``;\n }\n}\n\n/**\n * THE FIVE LAYERS, and how much of each doc is machine-owned.\n *\n * Adding a layer, or converting one to generated, is an edit HERE and nowhere else — the index table,\n * the layer table and the one-command sentence all render from this array, so they cannot disagree\n * with each other the way the hand-written version did.\n */\nexport const LAYER_GENERATION: readonly LayerGeneration[] = [\n new LayerGeneration('L0', 'Is webpieces itself trustworthy right now?', '', 'guards/L0-tooling.md',\n 'L0_FAULTS + L0_ALLOWLIST',\n '**PARTLY generated, byte-locked** — only the block between the `L0_DOC_BEGIN` / `L0_DOC_END` markers; every byte outside it is hand-written prose the splice preserves'),\n new LayerGeneration('L1', 'Is this call ours to judge, are the versions in sync, and is git run from the root?', '', 'guards/L1-location.md',\n 'L1_ROWS',\n '**generated whole, byte-locked** — table, use cases and cures all render from the array the runner dispatches on'),\n new LayerGeneration('L2', 'May I work here, and is what I read current?', 'branch-state-guard', 'guards/L2-branch-state.md',\n 'L2_ROWS',\n '**generated whole, byte-locked** — table, use cases and the \"Not done\" gaps all render from the rows'),\n new LayerGeneration('L3', 'Which dead branches and worktrees get reaped?', 'branch-creation-guard', 'guards/L3-branch-cleanup.md',\n '',\n '**hand-written** — not yet converted, so it CAN go out of date; the code is the authority'),\n new LayerGeneration('L4', 'Does every merge and PR go through the gated flow?', 'pr-lifecycle-guard', 'guards/L4-pr-lifecycle.md',\n '',\n '**hand-written** — not yet converted, so it CAN go out of date; the code is the authority'),\n];\n\n/**\n * The generated block of GUARD_MATRIX.md, and the splice that puts it there.\n *\n * Same class shape as L0ToolingDoc — renderer, extractor and splicer as one unit, driven by one spec.\n */\nexport class GuardIndexDoc {\n /** The whole generated block, WITHOUT the markers — those belong to the file, not the renderer. */\n render(): string {\n return [\n ...this.preamble(),\n ...this.statusTable(),\n ...this.layerTable(),\n ].join('\\n');\n }\n\n /**\n * The generated text of `doc`, exactly as committed. Throws when a marker is missing or doubled —\n * a silently-unspliced doc is the drift this arrangement exists to end.\n */\n extract(doc: string): string {\n const opens = doc.split(GUARD_INDEX_BEGIN).length - 1;\n const closes = doc.split(GUARD_INDEX_END).length - 1;\n if (opens !== 1 || closes !== 1) {\n throw new Error(`GUARD_MATRIX.md must carry exactly one BEGIN/END marker pair, found ${String(opens)}/${String(closes)}`);\n }\n const afterBegin = doc.slice(doc.indexOf(GUARD_INDEX_BEGIN) + GUARD_INDEX_BEGIN.length);\n return afterBegin.slice(0, afterBegin.indexOf(GUARD_INDEX_END)).replace(/^\\n/, '').replace(/\\n$/, '');\n }\n\n /** `doc` with the generated block replaced by today's render. Preserves every byte outside it. */\n splice(doc: string): string {\n const head = doc.slice(0, doc.indexOf(GUARD_INDEX_BEGIN) + GUARD_INDEX_BEGIN.length);\n const tail = doc.slice(doc.indexOf(GUARD_INDEX_END));\n // extract() is called for its VALIDATION — one marker pair — before anything is rewritten.\n this.extract(doc);\n return `${head}\\n${this.render()}\\n${tail}`;\n }\n\n private preamble(): string[] {\n return [\n '> **GENERATED — do not hand-edit between the markers.** Rendered by `GuardIndexDoc.render()`',\n '> from `LAYER_GENERATION` (`ai-hook-rules/src/core/guard-index-doc.ts`); regenerate with',\n '> `pnpm guards:generate`. A spec byte-locks it.',\n '>',\n '> This block used to be prose, and it drifted about the one subject it exists to report: it',\n '> claimed every layer but L0 and L1 was hand-written, ten lines above a table listing L2 as',\n '> generated. The index that tells you which docs to trust is the last place that can afford',\n '> to be wrong, so it is data now.',\n '',\n '## Generation status',\n '',\n ];\n }\n\n private statusTable(): string[] {\n return [\n '| layer | source of truth | doc |',\n '|---|---|---|',\n ...LAYER_GENERATION.map((entry: LayerGeneration): string =>\n `| ${entry.layer} | ${entry.sourceCell()} | ${entry.status} |`),\n '',\n `One command regenerates every generated artifact: \\`pnpm guards:generate\\`. It rewrites the`,\n `${this.generatedWholeCount()} whole-file docs, SPLICES the marked blocks in L0's doc and in this file, writes the`,\n 'AI-facing copy of the L2 matrix, and renders the POSIX-sh shim template.',\n '',\n 'For a WHOLE-file layer the split is: **row data** lives in the array, **prose** lives as literal',\n 'lines inside the renderer, there is no third place, and the byte-lock spec fails on any hand',\n 'edit. **The two SPLICED files are the exception**: everything outside their marker pairs is',\n 'hand-written prose the splice preserves byte for byte, and the spec deliberately permits you to',\n 'edit it.',\n '',\n '**L1 and L2 differ in one way worth knowing.** L1 DISPATCHES from its rows — the runner takes the',\n 'first matching row and switches on its `blockId`, so deleting a row deletes a block. L2\\'s four',\n 'guard classes each own their own ladder (they diverge in polarity, quantifier and empty-command',\n 'handling, deliberately), so L2 joins to its rows by REASON instead: every decision-log line carries',\n '`row=<n>` derived from the reason the guard logged, plus the `cure=` that row prescribed, and a spec',\n 'asserts the reason map is exhaustive against the guard sources.',\n '',\n ];\n }\n\n private layerTable(): string[] {\n return [\n '## The layers',\n '',\n '| layer | goal — the question it answers | config key | doc |',\n '|---|---|---|---|',\n ...LAYER_GENERATION.map((entry: LayerGeneration): string =>\n `| **${entry.layer}** | ${entry.goal} | ${entry.keyCell()} | [${entry.layer}](${entry.doc}) |`),\n '',\n ];\n }\n\n // Rendered into the prose so the sentence cannot claim a count the array disagrees with — the exact\n // failure mode this file is fixing (\"One command regenerates all three\" survived L2's conversion).\n private generatedWholeCount(): string {\n return String(LAYER_GENERATION.filter((entry: LayerGeneration): boolean =>\n entry.status.includes('generated whole')).length);\n }\n}\n"]}
|
package/src/core/l2-doc.js
CHANGED
|
@@ -26,6 +26,39 @@ function tableRow(row) {
|
|
|
26
26
|
function notDoneRow(entry) {
|
|
27
27
|
return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The "Not done" body — a table when there are gaps, a SENTENCE when there are none.
|
|
31
|
+
*
|
|
32
|
+
* An empty table renders as a bare header with nothing under it, which reads like a rendering bug
|
|
33
|
+
* rather than like an achievement. "Every row is honoured" is a claim worth making in words, and it is
|
|
34
|
+
* the state this section exists to drive the layer towards.
|
|
35
|
+
*/
|
|
36
|
+
// webpieces-disable no-function-outside-class -- section builder for renderL2Doc below, in this render module
|
|
37
|
+
function renderNotDoneBody() {
|
|
38
|
+
if (l2_rows_1.NOT_DONE.length === 0) {
|
|
39
|
+
return [
|
|
40
|
+
'**Nothing. Every row in the table above is a row the guards actually honour today.**',
|
|
41
|
+
'',
|
|
42
|
+
'That has not always been true, and the section stays here for when it stops being true again:',
|
|
43
|
+
'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',
|
|
44
|
+
'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',
|
|
45
|
+
'row 5\'s Bash half (now judged from the branch alone, above the cache divider) and the DIRTY-TREE',
|
|
46
|
+
'valves on rows 6 and 8 — both closed, because each of those rows cures with',
|
|
47
|
+
'`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',
|
|
48
|
+
'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',
|
|
49
|
+
'dirty, and the fix was to print both.',
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
return [
|
|
53
|
+
'Each row below describes INTENT the code has not caught up with. They are listed rather than',
|
|
54
|
+
'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',
|
|
55
|
+
`them currently exits at row ${l2_rows_1.L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,
|
|
56
|
+
'',
|
|
57
|
+
'| row | the gap | why it has not shipped |',
|
|
58
|
+
'|---|---|---|',
|
|
59
|
+
...l2_rows_1.NOT_DONE.map(notDoneRow),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
29
62
|
// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module
|
|
30
63
|
function useCaseRow(useCase) {
|
|
31
64
|
return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;
|
|
@@ -75,11 +108,19 @@ function renderHead() {
|
|
|
75
108
|
'| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',
|
|
76
109
|
'',
|
|
77
110
|
'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',
|
|
78
|
-
'Write is neither.
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'
|
|
82
|
-
'
|
|
111
|
+
'Write is neither.',
|
|
112
|
+
'',
|
|
113
|
+
'**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, so `pnpm build` was denied by',
|
|
115
|
+
'one and allowed by the other for the same reason — "you should not be working in this tree". That',
|
|
116
|
+
'asymmetry was a consequence of stale-main asking about FRESHNESS, where a blocklist of content',
|
|
117
|
+
'readers is the right shape. Once it asks about the BRANCH instead (row 5), the right shape is the',
|
|
118
|
+
'one merged-branch already had, and they now share it: `RecoveryAllowlist`, the row 4 skip list, as',
|
|
119
|
+
'a single implementation. Two skip lists drift, and the half that drifts is the half that wedges a',
|
|
120
|
+
'session on its own cure.',
|
|
121
|
+
'',
|
|
122
|
+
'They remain separate CLASSES because the states they detect are different — one reads the branch',
|
|
123
|
+
'name, the other the cached merged flag — and because each carries its own message.',
|
|
83
124
|
'',
|
|
84
125
|
'## The cache',
|
|
85
126
|
'',
|
|
@@ -159,10 +200,12 @@ function renderTable() {
|
|
|
159
200
|
'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',
|
|
160
201
|
'clears it.',
|
|
161
202
|
'',
|
|
162
|
-
'Row 6
|
|
163
|
-
'which genuinely is not a clean fast-forward on a dirty tree.
|
|
164
|
-
'
|
|
165
|
-
'
|
|
203
|
+
'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure is `git pull`,',
|
|
204
|
+
'which genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',
|
|
205
|
+
'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',
|
|
206
|
+
'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',
|
|
207
|
+
'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',
|
|
208
|
+
'valve in the code either** — both were closed, and "Not done" is empty as a result.',
|
|
166
209
|
'',
|
|
167
210
|
];
|
|
168
211
|
}
|
|
@@ -256,13 +299,7 @@ function renderTail() {
|
|
|
256
299
|
return [
|
|
257
300
|
'## Not done — rows the guards do not yet honour',
|
|
258
301
|
'',
|
|
259
|
-
|
|
260
|
-
'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',
|
|
261
|
-
`them currently exits at row ${l2_rows_1.L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,
|
|
262
|
-
'',
|
|
263
|
-
'| row | the gap | why it has not shipped |',
|
|
264
|
-
'|---|---|---|',
|
|
265
|
-
...l2_rows_1.NOT_DONE.map(notDoneRow),
|
|
302
|
+
...renderNotDoneBody(),
|
|
266
303
|
'',
|
|
267
304
|
'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',
|
|
268
305
|
'and the doc follows — it cannot rot into a list of things that were fixed years ago.',
|
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// 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. 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// 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 | `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"]}
|
|
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 | `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 ...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"]}
|
package/src/core/l2-rows.js
CHANGED
|
@@ -184,7 +184,7 @@ exports.L2_ROWS = [
|
|
|
184
184
|
]),
|
|
185
185
|
new L2Row(4, ['B'], 'on the **skip list** — it gets you OUT, or tells you where you are', exports.L2_ALLOW, '—', [
|
|
186
186
|
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
|
-
new L2UseCase(7, '`git stash`
|
|
187
|
+
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)'),
|
|
188
188
|
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
189
|
]),
|
|
190
190
|
new L2Row(5, ['B', 'E'], 'on `main`', exports.L2_BLOCK, '`git checkout -b <new> origin/main`', [
|
|
@@ -194,13 +194,13 @@ exports.L2_ROWS = [
|
|
|
194
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
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
196
|
]),
|
|
197
|
-
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, the forge unreachable
|
|
197
|
+
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
198
|
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
199
|
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'),
|
|
200
|
-
new L2UseCase(13, 'A stale-`main` read is allowed because the tree is dirty', 'on `main`, behind `origin/main`, with local modifications', 'ALLOW (fail-open): the prescribed `git pull` is not a clean fast-forward on a dirty tree', '`git stash` → `git pull origin main` → `git stash pop`', 'dirty-tree-on-main'),
|
|
201
200
|
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'),
|
|
202
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`', [
|
|
203
|
+
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
204
|
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'),
|
|
205
205
|
]),
|
|
206
206
|
new L2Row(7, ['R'], 'on `main`, current', exports.L2_ALLOW, '—', [
|
|
@@ -208,6 +208,7 @@ exports.L2_ROWS = [
|
|
|
208
208
|
]),
|
|
209
209
|
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
210
|
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#'),
|
|
211
|
+
new L2UseCase(26, 'You have uncommitted edits on a branch whose PR just merged', 'merged branch, dirty tree', 'BLOCK. This used to fail open too, and that valve never had an argument behind it — row 8\'s cure carries uncommitted work onto the fresh branch, so nothing was ever trapped. It was drift from the documented design, which `read-stale-guard`\'s own class comment still described correctly', '`git fetch origin main && git checkout -b <new> origin/main` — your edits come with you', 'already-merged PR#'),
|
|
211
212
|
new L2UseCase(19, 'A shell-only session sails through on a merged branch', 'merged branch, Bash only — both FILE guards are file-scoped, so Bash reached neither', 'BLOCK: `merged-branch-bash-guard` exists because `branchAlreadyMerged` was being computed and logged on that very path, then thrown away', '`git fetch origin main && git checkout -b <new> origin/main`', 'already-merged PR#'),
|
|
212
213
|
]),
|
|
213
214
|
new L2Row(9, ['B', 'R', 'E'], 'no fork point with `origin/main`, or `origin/main` moved and collided with your files', exports.L2_BLOCK, '`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is open', [
|
|
@@ -267,8 +268,8 @@ const EXACT_REASON_ROWS = {
|
|
|
267
268
|
'no-sync-cache': exports.L2_FAIL_OPEN_ROW,
|
|
268
269
|
'stale-cross-branch-cache': exports.L2_FAIL_OPEN_ROW,
|
|
269
270
|
'origin-main-unknown': exports.L2_FAIL_OPEN_ROW,
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
// `dirty-tree-on-main` and `dirty-merged-branch` used to live here. Both valves are deleted: rows
|
|
272
|
+
// 6 and 8 now block on a dirty tree, because each row's cure carries uncommitted work with you.
|
|
272
273
|
'no-forge': exports.L2_FAIL_OPEN_ROW,
|
|
273
274
|
// The config-edit bypass logged by the hook adapter before any guard runs — row 1, same universal
|
|
274
275
|
// cure as the config READ above.
|
|
@@ -327,7 +328,16 @@ exports.L2NotDone = L2NotDone;
|
|
|
327
328
|
* of these, so the trail never claims the strict row fired.
|
|
328
329
|
*/
|
|
329
330
|
exports.NOT_DONE = [
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
// EMPTY, and that is the goal state: every row in the table is a row the guards actually honour.
|
|
332
|
+
//
|
|
333
|
+
// It held three entries. Row 5's `B` half shipped (on `main` is now judged from the branch alone,
|
|
334
|
+
// above the cache divider). Rows 6 and 8 held DIRTY-TREE valves, and both are now closed — each of
|
|
335
|
+
// those rows cures with `git checkout -b <new> origin/main`, which carries uncommitted changes onto
|
|
336
|
+
// the new branch, so a dirty tree never trapped anybody. The row 6 entry claimed the dirty argument
|
|
337
|
+
// "has teeth" there because its cure is `git pull`; that was a fact about the MESSAGE, which printed
|
|
338
|
+
// only the pull, and the fix was to print both cures rather than to suppress the block.
|
|
339
|
+
//
|
|
340
|
+
// Keep this array. An empty "Not done" is a claim worth making explicitly — the doc says so in as
|
|
341
|
+
// many words — and the next divergence between a row and its code belongs here, not in prose.
|
|
332
342
|
];
|
|
333
343
|
//# sourceMappingURL=l2-rows.js.map
|