@webpieces/ai-hook-rules 0.4.633 → 0.4.635
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/bin/shim-audit-log.d.ts +63 -17
- package/src/bin/shim-audit-log.js +90 -20
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/core/l0-fault-codes.d.ts +6 -0
- package/src/core/l0-fault-codes.js +9 -3
- package/src/core/l0-fault-codes.js.map +1 -1
- package/src/core/l0-tooling-doc.d.ts +44 -0
- package/src/core/l0-tooling-doc.js +238 -0
- package/src/core/l0-tooling-doc.js.map +1 -0
- package/src/core/rules/redirect-how-to-merge-main.js +7 -2
- package/src/core/rules/redirect-how-to-merge-main.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.635",
|
|
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.635"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -6,22 +6,26 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export declare const SHIM_LOG_MAX_BYTES: number;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* One verdict label the shim can record, WITH what it means. Data-only → a class, per CLAUDE.md.
|
|
10
10
|
*
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The meaning travels with the label because guards/L0-tooling.md renders this table rather than
|
|
12
|
+
* restating it: a bare `string[]` left the meanings in prose, and the prose is what went stale (the
|
|
13
|
+
* hand-written doc documented `DENY-UNDECLARED` for releases while this array did not list it at all).
|
|
14
|
+
*/
|
|
15
|
+
export declare class ShimLogVerdict {
|
|
16
|
+
readonly label: string;
|
|
17
|
+
readonly means: string;
|
|
18
|
+
constructor(label: string, means: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW
|
|
20
|
-
* DENY fault X, not on the allowlist → BLOCK_AI_CURE
|
|
21
|
-
* DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE
|
|
22
|
-
* DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE
|
|
23
|
+
* The ALLOW-* and DENY-* labels are the ones this log has always used and are kept verbatim, so
|
|
24
|
+
* anything already grepping them keeps working. `PASS-BIN-*` is the healthy case the log used to be
|
|
25
|
+
* silent about, and `DENY-UNDECLARED` is fault U's — emitted by the shim since U existed, but missing
|
|
26
|
+
* from this array until the generated doc started reading it.
|
|
23
27
|
*/
|
|
24
|
-
export declare const SHIM_LOG_VERDICTS: readonly [
|
|
28
|
+
export declare const SHIM_LOG_VERDICTS: readonly ShimLogVerdict[];
|
|
25
29
|
/**
|
|
26
30
|
* The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than
|
|
27
31
|
* retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or
|
|
@@ -30,6 +34,48 @@ export declare const SHIM_LOG_VERDICTS: readonly ["PASS-BIN-ALLOW", "PASS-BIN-BL
|
|
|
30
34
|
* field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.
|
|
31
35
|
*/
|
|
32
36
|
export declare const SHIM_LOG_FAULTS: readonly ["D", "X", "U", "K", "-"];
|
|
37
|
+
/**
|
|
38
|
+
* One FIELD of the audit line: how it reads on disk, the sh expression that produces it, and what it
|
|
39
|
+
* answers. Data-only → a class, per CLAUDE.md.
|
|
40
|
+
*/
|
|
41
|
+
export declare class ShimLogField {
|
|
42
|
+
readonly label: string;
|
|
43
|
+
/** The sh word spliced into the printf below — the ONE place this field's value is spelled. */
|
|
44
|
+
readonly shValue: string;
|
|
45
|
+
readonly means: string;
|
|
46
|
+
/**
|
|
47
|
+
* True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from
|
|
48
|
+
* `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders
|
|
49
|
+
* with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves
|
|
50
|
+
* the line one field shorter rather than leaving a stray tab behind.
|
|
51
|
+
*/
|
|
52
|
+
readonly optional: boolean;
|
|
53
|
+
constructor(label: string,
|
|
54
|
+
/** The sh word spliced into the printf below — the ONE place this field's value is spelled. */
|
|
55
|
+
shValue: string, means: string,
|
|
56
|
+
/**
|
|
57
|
+
* True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from
|
|
58
|
+
* `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders
|
|
59
|
+
* with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves
|
|
60
|
+
* the line one field shorter rather than leaving a stray tab behind.
|
|
61
|
+
*/
|
|
62
|
+
optional?: boolean);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* THE LINE, as data. The printf below is BUILT from this array and guards/L0-tooling.md RENDERS it, so
|
|
66
|
+
* a field cannot be added, dropped or reordered without both the shim and the doc changing with it.
|
|
67
|
+
*
|
|
68
|
+
* That is not decoration: `shim=`/`bin=` were inserted mid-line (deliberately breaking positional
|
|
69
|
+
* readers rather than appending where a stale parser keeps working), then `layer=`/`row=` joined them,
|
|
70
|
+
* and the hand-written doc went on describing a 7-field line with no `U` in its fault set the whole time.
|
|
71
|
+
*/
|
|
72
|
+
export declare const SHIM_LOG_FIELDS: readonly ShimLogField[];
|
|
73
|
+
/**
|
|
74
|
+
* The writer's `printf`, assembled from SHIM_LOG_FIELDS — one `%s` per field, in the same order, and a
|
|
75
|
+
* tab after every field EXCEPT an optional one (which carries its own). Retyping either half is what
|
|
76
|
+
* let the format and its documentation disagree, so neither half is retyped anywhere.
|
|
77
|
+
*/
|
|
78
|
+
export declare const SHIM_LOG_PRINTF: string;
|
|
33
79
|
/**
|
|
34
80
|
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
35
81
|
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
@@ -54,13 +100,13 @@ export declare const RESOLVE_LOG_DIR_SH = "wp_resolve_log_dir() {\n _wp_rp=\"$(
|
|
|
54
100
|
/**
|
|
55
101
|
* Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.
|
|
56
102
|
*
|
|
57
|
-
* FORMAT
|
|
58
|
-
*
|
|
59
|
-
*
|
|
103
|
+
* FORMAT: SHIM_LOG_FIELDS, tab-separated, append-only — that array IS the format, and SHIM_LOG_PRINTF
|
|
104
|
+
* is built from it, so neither this docblock nor guards/L0-tooling.md can describe a line the shim does
|
|
105
|
+
* not write.
|
|
60
106
|
*
|
|
61
107
|
* `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:
|
|
62
108
|
* the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise
|
|
63
|
-
* unreadable), the second says which of the
|
|
109
|
+
* unreadable), the second says which of the sh-side faults the shim detected. The verdict
|
|
64
110
|
* keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\t'` still
|
|
65
111
|
* finds what it always found.
|
|
66
112
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WP_LOG_SH = exports.RESOLVE_LOG_DIR_SH = exports.SHIM_LOG_FAULTS = exports.SHIM_LOG_VERDICTS = exports.SHIM_LOG_MAX_BYTES = void 0;
|
|
3
|
+
exports.WP_LOG_SH = exports.RESOLVE_LOG_DIR_SH = exports.SHIM_LOG_PRINTF = exports.SHIM_LOG_FIELDS = exports.ShimLogField = exports.SHIM_LOG_FAULTS = exports.SHIM_LOG_VERDICTS = exports.ShimLogVerdict = exports.SHIM_LOG_MAX_BYTES = void 0;
|
|
4
4
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
5
|
const log_streams_1 = require("../core/log-streams");
|
|
6
6
|
const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
@@ -39,24 +39,39 @@ const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
|
39
39
|
*/
|
|
40
40
|
exports.SHIM_LOG_MAX_BYTES = 512 * 1024;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
42
|
+
* One verdict label the shim can record, WITH what it means. Data-only → a class, per CLAUDE.md.
|
|
43
43
|
*
|
|
44
|
-
* The
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* The meaning travels with the label because guards/L0-tooling.md renders this table rather than
|
|
45
|
+
* restating it: a bare `string[]` left the meanings in prose, and the prose is what went stale (the
|
|
46
|
+
* hand-written doc documented `DENY-UNDECLARED` for releases while this array did not list it at all).
|
|
47
|
+
*/
|
|
48
|
+
class ShimLogVerdict {
|
|
49
|
+
label;
|
|
50
|
+
means;
|
|
51
|
+
constructor(label, means) {
|
|
52
|
+
this.label = label;
|
|
53
|
+
this.means = means;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.ShimLogVerdict = ShimLogVerdict;
|
|
57
|
+
/**
|
|
58
|
+
* The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.
|
|
47
59
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW
|
|
53
|
-
* DENY fault X, not on the allowlist → BLOCK_AI_CURE
|
|
54
|
-
* DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE
|
|
55
|
-
* DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE
|
|
60
|
+
* The ALLOW-* and DENY-* labels are the ones this log has always used and are kept verbatim, so
|
|
61
|
+
* anything already grepping them keeps working. `PASS-BIN-*` is the healthy case the log used to be
|
|
62
|
+
* silent about, and `DENY-UNDECLARED` is fault U's — emitted by the shim since U existed, but missing
|
|
63
|
+
* from this array until the generated doc started reading it.
|
|
56
64
|
*/
|
|
57
65
|
exports.SHIM_LOG_VERDICTS = [
|
|
58
|
-
'PASS-BIN-ALLOW', '
|
|
59
|
-
'
|
|
66
|
+
new ShimLogVerdict('PASS-BIN-ALLOW', 'no sh-side fault; the bin ran and exited 0 — matrix row 1, handed down to L1'),
|
|
67
|
+
new ShimLogVerdict('PASS-BIN-BLOCK', 'no sh-side fault; the bin ran and exited 2 — matrix row 1, a LATER layer blocked'),
|
|
68
|
+
new ShimLogVerdict('ALLOW-READ', 'allowlist entry 1 (any Read) — PASS, but terminal here (the bin never ran)'),
|
|
69
|
+
new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),
|
|
70
|
+
new ShimLogVerdict('ALLOW-CURE', 'a Bash entry of the allowlist matched — ALLOW'),
|
|
71
|
+
new ShimLogVerdict('DENY', 'fault X, not on the allowlist — BLOCK_AI_CURE'),
|
|
72
|
+
new ShimLogVerdict('DENY-UNDECLARED', 'fault U, not on the allowlist — BLOCK_AI_CURE'),
|
|
73
|
+
new ShimLogVerdict('DENY-STALE', 'fault D, not on the allowlist — BLOCK_AI_CURE'),
|
|
74
|
+
new ShimLogVerdict('DENY-BROKEN', 'fault K, not on the allowlist — BLOCK_AI_CURE'),
|
|
60
75
|
];
|
|
61
76
|
/**
|
|
62
77
|
* The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than
|
|
@@ -66,6 +81,61 @@ exports.SHIM_LOG_VERDICTS = [
|
|
|
66
81
|
* field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.
|
|
67
82
|
*/
|
|
68
83
|
exports.SHIM_LOG_FAULTS = [...l0_fault_codes_1.L0_SH_FAULT_CODES, l0_fault_codes_1.L0_FAULT_NONE];
|
|
84
|
+
/**
|
|
85
|
+
* One FIELD of the audit line: how it reads on disk, the sh expression that produces it, and what it
|
|
86
|
+
* answers. Data-only → a class, per CLAUDE.md.
|
|
87
|
+
*/
|
|
88
|
+
class ShimLogField {
|
|
89
|
+
label;
|
|
90
|
+
shValue;
|
|
91
|
+
means;
|
|
92
|
+
optional;
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
94
|
+
constructor(label,
|
|
95
|
+
/** The sh word spliced into the printf below — the ONE place this field's value is spelled. */
|
|
96
|
+
shValue, means,
|
|
97
|
+
/**
|
|
98
|
+
* True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from
|
|
99
|
+
* `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders
|
|
100
|
+
* with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves
|
|
101
|
+
* the line one field shorter rather than leaving a stray tab behind.
|
|
102
|
+
*/
|
|
103
|
+
optional = false) {
|
|
104
|
+
this.label = label;
|
|
105
|
+
this.shValue = shValue;
|
|
106
|
+
this.means = means;
|
|
107
|
+
this.optional = optional;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.ShimLogField = ShimLogField;
|
|
111
|
+
/**
|
|
112
|
+
* THE LINE, as data. The printf below is BUILT from this array and guards/L0-tooling.md RENDERS it, so
|
|
113
|
+
* a field cannot be added, dropped or reordered without both the shim and the doc changing with it.
|
|
114
|
+
*
|
|
115
|
+
* That is not decoration: `shim=`/`bin=` were inserted mid-line (deliberately breaking positional
|
|
116
|
+
* readers rather than appending where a stale parser keeps working), then `layer=`/`row=` joined them,
|
|
117
|
+
* and the hand-written doc went on describing a 7-field line with no `U` in its fault set the whole time.
|
|
118
|
+
*/
|
|
119
|
+
exports.SHIM_LOG_FIELDS = [
|
|
120
|
+
new ShimLogField('<iso-ts>', `"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)"`, 'when the shim judged the call, local time with offset'),
|
|
121
|
+
new ShimLogField('<bin-name>', '"$BIN_NAME"', 'WHICH hook ran - wp-ai-guards-hook or wp-ai-rules-hook; Claude Code runs them in parallel'),
|
|
122
|
+
new ShimLogField('<tool>', '"$TOOL"', 'the PreToolUse tool name (Bash, Read, Write, Edit, …)'),
|
|
123
|
+
new ShimLogField('tree=<name|primary>', '"tree=$WP_TREE"', 'git\'s own name for the worktree the CALL was made in, derived from the payload\'s cwd'),
|
|
124
|
+
new ShimLogField(`layer=${l0_fault_codes_1.L0_LAYER}`, `"layer=${l0_fault_codes_1.L0_LAYER}"`, 'the layer that judged it — constant here, and the first half of the join key a deny cites'),
|
|
125
|
+
new ShimLogField(`row=<${l0_fault_codes_1.L0_ROW_HANDED_DOWN}|${l0_fault_codes_1.L0_ROW_ALLOWLISTED}|${l0_fault_codes_1.L0_ROW_BLOCKED}>`, '"row=$_wp_row"', 'WHICH row of the three-row matrix this call took, read off the verdict (hand-down / allowlisted / blocked)'),
|
|
126
|
+
new ShimLogField('shim=<root>', '"shim=$ROOT"', 'WHICH COPY of ai-hook.sh ran, resolved from $0 — against tree= it is the straddle detector'),
|
|
127
|
+
new ShimLogField('bin=<root>', '"$_wp_bin"', 'WHICH TREE supplied the binary — printed ONLY when it differs from shim=, so its presence IS the borrow', true),
|
|
128
|
+
new ShimLogField(`fault=<${exports.SHIM_LOG_FAULTS.join('|')}>`, '"fault=$1"', 'the sh-side L0 fault, or `-`; S/C/Y are the binary\'s and are stamped on ITS streams'),
|
|
129
|
+
new ShimLogField('<VERDICT>', '"$2"', 'one of the verdict labels below — kept adjacent to the command'),
|
|
130
|
+
new ShimLogField('<command>', '"$CMD_LOG"', 'the command PREFIX (the audit spelling; the DECISION reads $CMD, which fails closed on a quote)'),
|
|
131
|
+
];
|
|
132
|
+
/**
|
|
133
|
+
* The writer's `printf`, assembled from SHIM_LOG_FIELDS — one `%s` per field, in the same order, and a
|
|
134
|
+
* tab after every field EXCEPT an optional one (which carries its own). Retyping either half is what
|
|
135
|
+
* let the format and its documentation disagree, so neither half is retyped anywhere.
|
|
136
|
+
*/
|
|
137
|
+
exports.SHIM_LOG_PRINTF = `printf '${exports.SHIM_LOG_FIELDS.map((f, i) => '%s' + (i === exports.SHIM_LOG_FIELDS.length - 1 ? '' : (f.optional ? '' : '\\t'))).join('')}\\n' `
|
|
138
|
+
+ `${exports.SHIM_LOG_FIELDS.map((f) => f.shValue).join(' ')} >> "$_wp_f"`;
|
|
69
139
|
/**
|
|
70
140
|
* Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +
|
|
71
141
|
* `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.
|
|
@@ -122,13 +192,13 @@ exports.RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {
|
|
|
122
192
|
/**
|
|
123
193
|
* Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.
|
|
124
194
|
*
|
|
125
|
-
* FORMAT
|
|
126
|
-
*
|
|
127
|
-
*
|
|
195
|
+
* FORMAT: SHIM_LOG_FIELDS, tab-separated, append-only — that array IS the format, and SHIM_LOG_PRINTF
|
|
196
|
+
* is built from it, so neither this docblock nor guards/L0-tooling.md can describe a line the shim does
|
|
197
|
+
* not write.
|
|
128
198
|
*
|
|
129
199
|
* `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:
|
|
130
200
|
* the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise
|
|
131
|
-
* unreadable), the second says which of the
|
|
201
|
+
* unreadable), the second says which of the sh-side faults the shim detected. The verdict
|
|
132
202
|
* keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\t'` still
|
|
133
203
|
* finds what it always found.
|
|
134
204
|
*
|
|
@@ -198,7 +268,7 @@ wp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label
|
|
|
198
268
|
# what distinguishes it from the ~50 constant bytes 'bin=' used to spend above.
|
|
199
269
|
_wp_row=${l0_fault_codes_1.L0_ROW_HANDED_DOWN}
|
|
200
270
|
case "$2" in ALLOW*) _wp_row=${l0_fault_codes_1.L0_ROW_ALLOWLISTED} ;; DENY*) _wp_row=${l0_fault_codes_1.L0_ROW_BLOCKED} ;; esac
|
|
201
|
-
|
|
271
|
+
${exports.SHIM_LOG_PRINTF}
|
|
202
272
|
} 2>/dev/null || true
|
|
203
273
|
}`;
|
|
204
274
|
//# sourceMappingURL=shim-audit-log.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,qDAAqD;AAErD,2DAEgC;AAEhC,8EAA8E;AAC9E,mDAAmD;AACnD,8FAA8F;AAC9F,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,mDAAmD;AACnD,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;;;GAMG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,kCAAiB,EAAE,8BAAa,CAAU,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;;;;qCAiBzC,gCAAiB,IAAI,6BAAc;;;;;;;;+BAQzC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACU,QAAA,SAAS,GAAG;;;;EAIvB,0BAAkB;;;;;;;;;0BASM,4BAAc;;;;;;;;;;;;;;;;sBAgBlB,MAAM,CAAC,0BAAkB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA6BlC,mCAAkB;mCACG,mCAAkB,sBAAsB,+BAAc;;;EAGvF,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\n\nimport {\n L0_FAULT_NONE, L0_SH_FAULT_CODES, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED, L0_ROW_HANDED_DOWN,\n} from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n WP_PRIMARY_LOG_DIR=\"$WP_LOG_DIR\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n # The PRIMARY clone's log dir, resolved on both branches. A deny that has to tell a human WHERE the\n # audit trail is (the inverse-drift escalation in shim.ts) must be able to name both the tree it is\n # standing in and the primary — a subagent has no reach into the second one, so the deny has to quote\n # that path rather than send anyone to go and look.\n WP_PRIMARY_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$WP_PRIMARY_LOG_DIR\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (10 fields, tab-separated, append-only; 11 with the optional `bin=`):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> layer=L0 row=<1|2|3> shim=<root>\n * [bin=<root>] fault=<D|X|U|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\nWP_PRIMARY_LOG_DIR=\"\"\nWP_TAB=\"$(printf '\\\\t')\" # one real tab, so the OPTIONAL bin= field can carry its own separator\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$_wp_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n # shim= and bin= are the two facts this log could not previously answer, and they are the ones that\n # decide whether a tree was governed by its OWN release or a borrowed one:\n # shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every\n # worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy\n # that runs is the SESSION ROOT's. Logged rather than assumed, on EVERY line: compared against\n # tree= it is the STRADDLE detector (tree=agent-X shim=<repo> = standing in one tree, judged by\n # another), and that pair varies constantly.\n # bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer.\n #\n # bin= IS PRINTED ONLY WHEN IT DIFFERS FROM shim=, so its mere PRESENCE is the diagnostic (\"the binary\n # came from a different tree than the shim\") instead of ~50 bytes repeated on every line. Measured\n # across 549 logged lines: it differed on 39, every one a worktree agent's first few calls before it\n # ran pnpm install — after that they matched for the rest of that agent's life. And since the hooks\n # went ABSOLUTE, shim= is always the MAIN tree, so the two can now only differ when the main tree\n # itself has no node_modules (a fresh clone before install). ~7% of lines then, near 0% going forward.\n # A unit test asserts the field appears if and only if the roots differ, so it cannot quietly become\n # unconditional noise again.\n _wp_bin=\"\"\n [ \"$BIN_ROOT\" != \"$ROOT\" ] && _wp_bin=\"bin=$BIN_ROOT$WP_TAB\"\n # layer= and row= are the JOIN KEYS, and they are here so the join is REAL rather than promised.\n # Every L0 deny now opens '[<guard>] (layer=L0 fault=<code> row=<n>)' and cites \"the same coordinates\n # the audit line carries\" — which was true of the JS half (MATRIX_L0_BLOCK, via decision-log) and\n # FALSE of this one, which carried 'fault=' alone. Fixing the message instead of the line would have\n # left 'grep 'layer=L0 row=3'' finding one half of L0 and silently missing the other four faults.\n #\n # 'row=' is NOT a constant: it is the row of the three-row matrix this call actually took, read off\n # the verdict — hand-down, allowlisted, or blocked — exactly as L1 logs 'row=' from L1_ROWS. That is\n # what distinguishes it from the ~50 constant bytes 'bin=' used to spend above.\n _wp_row=${L0_ROW_HANDED_DOWN}\n case \"$2\" in ALLOW*) _wp_row=${L0_ROW_ALLOWLISTED} ;; DENY*) _wp_row=${L0_ROW_BLOCKED} ;; esac\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"layer=L0\" \"row=$_wp_row\" \"shim=$ROOT\" \"$_wp_bin\" \"fault=$1\" \"$2\" \"$CMD_LOG\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
|
|
1
|
+
{"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,qDAAqD;AAErD,2DAEgC;AAEhC,8EAA8E;AAC9E,mDAAmD;AACnD,8FAA8F;AAC9F,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,mDAAmD;AACnD,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;GAMG;AACH,MAAa,cAAc;IAEV;IACA;IAFb,YACa,KAAa,EACb,KAAa;QADb,UAAK,GAAL,KAAK,CAAQ;QACb,UAAK,GAAL,KAAK,CAAQ;IACvB,CAAC;CACP;AALD,wCAKC;AAED;;;;;;;GAOG;AACU,QAAA,iBAAiB,GAA8B;IACxD,IAAI,cAAc,CAAC,gBAAgB,EAAE,8EAA8E,CAAC;IACpH,IAAI,cAAc,CAAC,gBAAgB,EAAE,kFAAkF,CAAC;IACxH,IAAI,cAAc,CAAC,YAAY,EAAE,4EAA4E,CAAC;IAC9G,IAAI,cAAc,CAAC,cAAc,EAAE,iFAAiF,CAAC;IACrH,IAAI,cAAc,CAAC,YAAY,EAAE,+CAA+C,CAAC;IACjF,IAAI,cAAc,CAAC,MAAM,EAAE,+CAA+C,CAAC;IAC3E,IAAI,cAAc,CAAC,iBAAiB,EAAE,+CAA+C,CAAC;IACtF,IAAI,cAAc,CAAC,YAAY,EAAE,+CAA+C,CAAC;IACjF,IAAI,cAAc,CAAC,aAAa,EAAE,+CAA+C,CAAC;CACrF,CAAC;AAEF;;;;;;GAMG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,kCAAiB,EAAE,8BAAa,CAAU,CAAC;AAE9E;;;GAGG;AACH,MAAa,YAAY;IAGR;IAEA;IACA;IAOA;IAZb,yDAAyD;IACzD,YACa,KAAa;IACtB,+FAA+F;IACtF,OAAe,EACf,KAAa;IACtB;;;;;OAKG;IACM,WAAoB,KAAK;QAVzB,UAAK,GAAL,KAAK,CAAQ;QAEb,YAAO,GAAP,OAAO,CAAQ;QACf,UAAK,GAAL,KAAK,CAAQ;QAOb,aAAQ,GAAR,QAAQ,CAAiB;IACnC,CAAC;CACP;AAfD,oCAeC;AAED;;;;;;;GAOG;AACU,QAAA,eAAe,GAA4B;IACpD,IAAI,YAAY,CAAC,UAAU,EAAE,8CAA8C,EACvE,uDAAuD,CAAC;IAC5D,IAAI,YAAY,CAAC,YAAY,EAAE,aAAa,EACxC,2FAA2F,CAAC;IAChG,IAAI,YAAY,CAAC,QAAQ,EAAE,SAAS,EAAE,uDAAuD,CAAC;IAC9F,IAAI,YAAY,CAAC,qBAAqB,EAAE,iBAAiB,EACrD,wFAAwF,CAAC;IAC7F,IAAI,YAAY,CAAC,SAAS,yBAAQ,EAAE,EAAE,UAAU,yBAAQ,GAAG,EACvD,2FAA2F,CAAC;IAChG,IAAI,YAAY,CAAC,QAAQ,mCAAkB,IAAI,mCAAkB,IAAI,+BAAc,GAAG,EAAE,gBAAgB,EACpG,4GAA4G,CAAC;IACjH,IAAI,YAAY,CAAC,aAAa,EAAE,cAAc,EAC1C,4FAA4F,CAAC;IACjG,IAAI,YAAY,CAAC,YAAY,EAAE,YAAY,EACvC,yGAAyG,EACzG,IAAI,CAAC;IACT,IAAI,YAAY,CAAC,UAAU,uBAAe,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,YAAY,EACjE,sFAAsF,CAAC;IAC3F,IAAI,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,gEAAgE,CAAC;IACvG,IAAI,YAAY,CAAC,WAAW,EAAE,YAAY,EACtC,iGAAiG,CAAC;CACzG,CAAC;AAEF;;;;GAIG;AACU,QAAA,eAAe,GACxB,WAAW,uBAAe,CAAC,GAAG,CAC1B,CAAC,CAAe,EAAE,CAAS,EAAU,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,uBAAe,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CACrH,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO;MACf,GAAG,uBAAe,CAAC,GAAG,CAAC,CAAC,CAAe,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,cAAc,CAAC;AAE7F;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;;;;qCAiBzC,gCAAiB,IAAI,6BAAc;;;;;;;;+BAQzC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACU,QAAA,SAAS,GAAG;;;;EAIvB,0BAAkB;;;;;;;;;0BASM,4BAAc;;;;;;;;;;;;;;;;sBAgBlB,MAAM,CAAC,0BAAkB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA6BlC,mCAAkB;mCACG,mCAAkB,sBAAsB,+BAAc;MACnF,uBAAe;;EAEnB,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\n\nimport {\n L0_FAULT_NONE, L0_LAYER, L0_SH_FAULT_CODES, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED, L0_ROW_HANDED_DOWN,\n} from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * One verdict label the shim can record, WITH what it means. Data-only → a class, per CLAUDE.md.\n *\n * The meaning travels with the label because guards/L0-tooling.md renders this table rather than\n * restating it: a bare `string[]` left the meanings in prose, and the prose is what went stale (the\n * hand-written doc documented `DENY-UNDECLARED` for releases while this array did not list it at all).\n */\nexport class ShimLogVerdict {\n constructor(\n readonly label: string,\n readonly means: string,\n ) {}\n}\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The ALLOW-* and DENY-* labels are the ones this log has always used and are kept verbatim, so\n * anything already grepping them keeps working. `PASS-BIN-*` is the healthy case the log used to be\n * silent about, and `DENY-UNDECLARED` is fault U's — emitted by the shim since U existed, but missing\n * from this array until the generated doc started reading it.\n */\nexport const SHIM_LOG_VERDICTS: readonly ShimLogVerdict[] = [\n new ShimLogVerdict('PASS-BIN-ALLOW', 'no sh-side fault; the bin ran and exited 0 — matrix row 1, handed down to L1'),\n new ShimLogVerdict('PASS-BIN-BLOCK', 'no sh-side fault; the bin ran and exited 2 — matrix row 1, a LATER layer blocked'),\n new ShimLogVerdict('ALLOW-READ', 'allowlist entry 1 (any Read) — PASS, but terminal here (the bin never ran)'),\n new ShimLogVerdict('ALLOW-CONFIG', 'allowlist entry 2 (a Write/Edit of webpieces.config.json) — PASS, terminal here'),\n new ShimLogVerdict('ALLOW-CURE', 'a Bash entry of the allowlist matched — ALLOW'),\n new ShimLogVerdict('DENY', 'fault X, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-UNDECLARED', 'fault U, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-STALE', 'fault D, not on the allowlist — BLOCK_AI_CURE'),\n new ShimLogVerdict('DENY-BROKEN', 'fault K, not on the allowlist — BLOCK_AI_CURE'),\n];\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * One FIELD of the audit line: how it reads on disk, the sh expression that produces it, and what it\n * answers. Data-only → a class, per CLAUDE.md.\n */\nexport class ShimLogField {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly label: string,\n /** The sh word spliced into the printf below — the ONE place this field's value is spelled. */\n readonly shValue: string,\n readonly means: string,\n /**\n * True for a field that is printed only SOMETIMES (`bin=`, and only when it differs from\n * `shim=`). Such a field carries its OWN trailing tab in its sh value and therefore renders\n * with NO separator of its own — `%s%s` glues it to the next field, so an empty value leaves\n * the line one field shorter rather than leaving a stray tab behind.\n */\n readonly optional: boolean = false,\n ) {}\n}\n\n/**\n * THE LINE, as data. The printf below is BUILT from this array and guards/L0-tooling.md RENDERS it, so\n * a field cannot be added, dropped or reordered without both the shim and the doc changing with it.\n *\n * That is not decoration: `shim=`/`bin=` were inserted mid-line (deliberately breaking positional\n * readers rather than appending where a stale parser keeps working), then `layer=`/`row=` joined them,\n * and the hand-written doc went on describing a 7-field line with no `U` in its fault set the whole time.\n */\nexport const SHIM_LOG_FIELDS: readonly ShimLogField[] = [\n new ShimLogField('<iso-ts>', `\"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\"`,\n 'when the shim judged the call, local time with offset'),\n new ShimLogField('<bin-name>', '\"$BIN_NAME\"',\n 'WHICH hook ran - wp-ai-guards-hook or wp-ai-rules-hook; Claude Code runs them in parallel'),\n new ShimLogField('<tool>', '\"$TOOL\"', 'the PreToolUse tool name (Bash, Read, Write, Edit, …)'),\n new ShimLogField('tree=<name|primary>', '\"tree=$WP_TREE\"',\n 'git\\'s own name for the worktree the CALL was made in, derived from the payload\\'s cwd'),\n new ShimLogField(`layer=${L0_LAYER}`, `\"layer=${L0_LAYER}\"`,\n 'the layer that judged it — constant here, and the first half of the join key a deny cites'),\n new ShimLogField(`row=<${L0_ROW_HANDED_DOWN}|${L0_ROW_ALLOWLISTED}|${L0_ROW_BLOCKED}>`, '\"row=$_wp_row\"',\n 'WHICH row of the three-row matrix this call took, read off the verdict (hand-down / allowlisted / blocked)'),\n new ShimLogField('shim=<root>', '\"shim=$ROOT\"',\n 'WHICH COPY of ai-hook.sh ran, resolved from $0 — against tree= it is the straddle detector'),\n new ShimLogField('bin=<root>', '\"$_wp_bin\"',\n 'WHICH TREE supplied the binary — printed ONLY when it differs from shim=, so its presence IS the borrow',\n true),\n new ShimLogField(`fault=<${SHIM_LOG_FAULTS.join('|')}>`, '\"fault=$1\"',\n 'the sh-side L0 fault, or `-`; S/C/Y are the binary\\'s and are stamped on ITS streams'),\n new ShimLogField('<VERDICT>', '\"$2\"', 'one of the verdict labels below — kept adjacent to the command'),\n new ShimLogField('<command>', '\"$CMD_LOG\"',\n 'the command PREFIX (the audit spelling; the DECISION reads $CMD, which fails closed on a quote)'),\n];\n\n/**\n * The writer's `printf`, assembled from SHIM_LOG_FIELDS — one `%s` per field, in the same order, and a\n * tab after every field EXCEPT an optional one (which carries its own). Retyping either half is what\n * let the format and its documentation disagree, so neither half is retyped anywhere.\n */\nexport const SHIM_LOG_PRINTF =\n `printf '${SHIM_LOG_FIELDS.map(\n (f: ShimLogField, i: number): string => '%s' + (i === SHIM_LOG_FIELDS.length - 1 ? '' : (f.optional ? '' : '\\\\t')),\n ).join('')}\\\\n' `\n + `${SHIM_LOG_FIELDS.map((f: ShimLogField): string => f.shValue).join(' ')} >> \"$_wp_f\"`;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n WP_PRIMARY_LOG_DIR=\"$WP_LOG_DIR\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n # The PRIMARY clone's log dir, resolved on both branches. A deny that has to tell a human WHERE the\n # audit trail is (the inverse-drift escalation in shim.ts) must be able to name both the tree it is\n # standing in and the primary — a subagent has no reach into the second one, so the deny has to quote\n # that path rather than send anyone to go and look.\n WP_PRIMARY_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$WP_PRIMARY_LOG_DIR\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT: SHIM_LOG_FIELDS, tab-separated, append-only — that array IS the format, and SHIM_LOG_PRINTF\n * is built from it, so neither this docblock nor guards/L0-tooling.md can describe a line the shim does\n * not write.\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the sh-side faults the shim detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\nWP_PRIMARY_LOG_DIR=\"\"\nWP_TAB=\"$(printf '\\\\t')\" # one real tab, so the OPTIONAL bin= field can carry its own separator\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$_wp_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n # shim= and bin= are the two facts this log could not previously answer, and they are the ones that\n # decide whether a tree was governed by its OWN release or a borrowed one:\n # shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every\n # worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy\n # that runs is the SESSION ROOT's. Logged rather than assumed, on EVERY line: compared against\n # tree= it is the STRADDLE detector (tree=agent-X shim=<repo> = standing in one tree, judged by\n # another), and that pair varies constantly.\n # bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer.\n #\n # bin= IS PRINTED ONLY WHEN IT DIFFERS FROM shim=, so its mere PRESENCE is the diagnostic (\"the binary\n # came from a different tree than the shim\") instead of ~50 bytes repeated on every line. Measured\n # across 549 logged lines: it differed on 39, every one a worktree agent's first few calls before it\n # ran pnpm install — after that they matched for the rest of that agent's life. And since the hooks\n # went ABSOLUTE, shim= is always the MAIN tree, so the two can now only differ when the main tree\n # itself has no node_modules (a fresh clone before install). ~7% of lines then, near 0% going forward.\n # A unit test asserts the field appears if and only if the roots differ, so it cannot quietly become\n # unconditional noise again.\n _wp_bin=\"\"\n [ \"$BIN_ROOT\" != \"$ROOT\" ] && _wp_bin=\"bin=$BIN_ROOT$WP_TAB\"\n # layer= and row= are the JOIN KEYS, and they are here so the join is REAL rather than promised.\n # Every L0 deny now opens '[<guard>] (layer=L0 fault=<code> row=<n>)' and cites \"the same coordinates\n # the audit line carries\" — which was true of the JS half (MATRIX_L0_BLOCK, via decision-log) and\n # FALSE of this one, which carried 'fault=' alone. Fixing the message instead of the line would have\n # left 'grep 'layer=L0 row=3'' finding one half of L0 and silently missing the other four faults.\n #\n # 'row=' is NOT a constant: it is the row of the three-row matrix this call actually took, read off\n # the verdict — hand-down, allowlisted, or blocked — exactly as L1 logs 'row=' from L1_ROWS. That is\n # what distinguishes it from the ~50 constant bytes 'bin=' used to spend above.\n _wp_row=${L0_ROW_HANDED_DOWN}\n case \"$2\" in ALLOW*) _wp_row=${L0_ROW_ALLOWLISTED} ;; DENY*) _wp_row=${L0_ROW_BLOCKED} ;; esac\n ${SHIM_LOG_PRINTF}\n } 2>/dev/null || true\n}`;\n"]}
|
|
@@ -49,6 +49,12 @@ export type L0FaultCode = typeof L0_SH_FAULT_CODES[number] | typeof L0_JS_FAULT_
|
|
|
49
49
|
* L1 row would (see L1_ROWS' header).
|
|
50
50
|
*/
|
|
51
51
|
export declare const L0_FAULT_NAMES: Readonly<Record<L0FaultCode, string>>;
|
|
52
|
+
/**
|
|
53
|
+
* The LAYER token — the other half of every join key, spelled once. The deny header, the matrix
|
|
54
|
+
* citation, the sh audit line and guards/L0-tooling.md all read it from here, so `grep layer=L0` cannot
|
|
55
|
+
* miss an artifact that typed the token itself.
|
|
56
|
+
*/
|
|
57
|
+
export declare const L0_LAYER = "L0";
|
|
52
58
|
/**
|
|
53
59
|
* The three rows of L0's decision matrix, by number — the numbers `renderGuardMatrixDoc` prints, the
|
|
54
60
|
* numbers the audit line's `row=` carries, and the numbers a deny cites. L0's matrix has no genuine
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// rule engine.
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.L0_ROW_BLOCKED = exports.L0_ROW_ALLOWLISTED = exports.L0_ROW_HANDED_DOWN = exports.L0_FAULT_NAMES = exports.L0_JS_FAULT_CODES = exports.L0_SH_FAULT_CODES = exports.L0_FAULT_NONE = exports.L0_FAULT_CONFIG_OUT_OF_SYNC = exports.L0_FAULT_CONFIG_MISSING = exports.L0_FAULT_SHIM_STALE = exports.L0_FAULT_BIN_BROKEN = exports.L0_FAULT_UNDECLARED = exports.L0_FAULT_BIN_MISSING = exports.L0_FAULT_DRIFT = void 0;
|
|
23
|
+
exports.L0_ROW_BLOCKED = exports.L0_ROW_ALLOWLISTED = exports.L0_ROW_HANDED_DOWN = exports.L0_LAYER = exports.L0_FAULT_NAMES = exports.L0_JS_FAULT_CODES = exports.L0_SH_FAULT_CODES = exports.L0_FAULT_NONE = exports.L0_FAULT_CONFIG_OUT_OF_SYNC = exports.L0_FAULT_CONFIG_MISSING = exports.L0_FAULT_SHIM_STALE = exports.L0_FAULT_BIN_BROKEN = exports.L0_FAULT_UNDECLARED = exports.L0_FAULT_BIN_MISSING = exports.L0_FAULT_DRIFT = void 0;
|
|
24
24
|
exports.l0GuardHeader = l0GuardHeader;
|
|
25
25
|
exports.l0MatrixCitation = l0MatrixCitation;
|
|
26
26
|
/** `D` — version drift: the root package.json pin != the installed version. Decided in `sh`. */
|
|
@@ -78,6 +78,12 @@ exports.L0_FAULT_NAMES = {
|
|
|
78
78
|
[exports.L0_FAULT_CONFIG_MISSING]: 'config-missing',
|
|
79
79
|
[exports.L0_FAULT_CONFIG_OUT_OF_SYNC]: 'config-out-of-sync',
|
|
80
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* The LAYER token — the other half of every join key, spelled once. The deny header, the matrix
|
|
83
|
+
* citation, the sh audit line and guards/L0-tooling.md all read it from here, so `grep layer=L0` cannot
|
|
84
|
+
* miss an artifact that typed the token itself.
|
|
85
|
+
*/
|
|
86
|
+
exports.L0_LAYER = 'L0';
|
|
81
87
|
/**
|
|
82
88
|
* The three rows of L0's decision matrix, by number — the numbers `renderGuardMatrixDoc` prints, the
|
|
83
89
|
* numbers the audit line's `row=` carries, and the numbers a deny cites. L0's matrix has no genuine
|
|
@@ -102,7 +108,7 @@ exports.L0_ROW_BLOCKED = '3';
|
|
|
102
108
|
*/
|
|
103
109
|
// webpieces-disable no-function-outside-class -- pure string builder over this leaf module's own constants; it must stay importable by the dependency-free shim renderer.
|
|
104
110
|
function l0GuardHeader(fault, detail) {
|
|
105
|
-
return `[${exports.L0_FAULT_NAMES[fault]}] (layer
|
|
111
|
+
return `[${exports.L0_FAULT_NAMES[fault]}] (layer=${exports.L0_LAYER} fault=${fault} row=${exports.L0_ROW_BLOCKED}, ${detail})`;
|
|
106
112
|
}
|
|
107
113
|
/**
|
|
108
114
|
* The one-line citation of WHICH matrix row was taken and on what dimension values — L1's pattern
|
|
@@ -110,6 +116,6 @@ function l0GuardHeader(fault, detail) {
|
|
|
110
116
|
*/
|
|
111
117
|
// webpieces-disable no-function-outside-class -- sibling of l0GuardHeader in this leaf codebook module.
|
|
112
118
|
function l0MatrixCitation(fault) {
|
|
113
|
-
return `matrix row ${exports.L0_ROW_BLOCKED}: fault=${fault} present / on the allowlist? no -> BLOCK. Those are the same coordinates the audit line carries (layer
|
|
119
|
+
return `matrix row ${exports.L0_ROW_BLOCKED}: fault=${fault} present / on the allowlist? no -> BLOCK. Those are the same coordinates the audit line carries (layer=${exports.L0_LAYER} row=${exports.L0_ROW_BLOCKED} fault=${fault}) and the same row webpieces.guard-matrix.md prints.`;
|
|
114
120
|
}
|
|
115
121
|
//# sourceMappingURL=l0-fault-codes.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"l0-fault-codes.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-fault-codes.ts"],"names":[],"mappings":";AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,EAAE;AACF,oGAAoG;AACpG,gGAAgG;AAChG,sGAAsG;AACtG,uGAAuG;AACvG,qGAAqG;AACrG,2DAA2D;AAC3D,EAAE;AACF,sGAAsG;AACtG,6FAA6F;AAC7F,uFAAuF;AACvF,EAAE;AACF,mGAAmG;AACnG,wGAAwG;AACxG,uGAAuG;AACvG,oGAAoG;AACpG,eAAe;AACf,8EAA8E;;;AAyH9E,sCAEC;AAOD,4CAEC;AAlID,gGAAgG;AACnF,QAAA,cAAc,GAAG,GAAG,CAAC;AAElC,oGAAoG;AACvF,QAAA,oBAAoB,GAAG,GAAG,CAAC;AAExC,iGAAiG;AACpF,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,oFAAoF;AACvE,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,uGAAuG;AAC1F,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,2EAA2E;AAC9D,QAAA,uBAAuB,GAAG,GAAG,CAAC;AAE3C,yFAAyF;AAC5E,QAAA,2BAA2B,GAAG,GAAG,CAAC;AAE/C;;;;;GAKG;AACU,QAAA,aAAa,GAAG,GAAG,CAAC;AAEjC;;;GAGG;AACU,QAAA,iBAAiB,GAAG;IAC7B,sBAAc,EAAE,4BAAoB,EAAE,2BAAmB,EAAE,2BAAmB;CACxE,CAAC;AAEX;;;;;GAKG;AACU,QAAA,iBAAiB,GAAG;IAC7B,2BAAmB,EAAE,+BAAuB,EAAE,mCAA2B;CACnE,CAAC;AA6BX;;;;;;;;GAQG;AACU,QAAA,cAAc,GAA0C;IACjE,CAAC,sBAAc,CAAC,EAAE,eAAe;IACjC,CAAC,4BAAoB,CAAC,EAAE,mBAAmB;IAC3C,CAAC,2BAAmB,CAAC,EAAE,sBAAsB;IAC7C,CAAC,2BAAmB,CAAC,EAAE,mBAAmB;IAC1C,CAAC,2BAAmB,CAAC,EAAE,sBAAsB;IAC7C,CAAC,+BAAuB,CAAC,EAAE,gBAAgB;IAC3C,CAAC,mCAA2B,CAAC,EAAE,oBAAoB;CACtD,CAAC;AAEF;;;;;;;;GAQG;AACU,QAAA,kBAAkB,GAAG,GAAG,CAAC;AAEtC,0GAA0G;AAC7F,QAAA,kBAAkB,GAAG,GAAG,CAAC;AAEtC,oGAAoG;AACvF,QAAA,cAAc,GAAG,GAAG,CAAC;AAElC;;;;;;;GAOG;AACH,0KAA0K;AAC1K,SAAgB,aAAa,CAAC,KAAkB,EAAE,MAAc;IAC5D,OAAO,IAAI,sBAAc,CAAC,KAAK,CAAC,qBAAqB,KAAK,QAAQ,sBAAc,KAAK,MAAM,GAAG,CAAC;AACnG,CAAC;AAED;;;GAGG;AACH,wGAAwG;AACxG,SAAgB,gBAAgB,CAAC,KAAkB;IAC/C,OAAO,cAAc,sBAAc,WAAW,KAAK,iHAAiH,sBAAc,UAAU,KAAK,sDAAsD,CAAC;AAC5P,CAAC","sourcesContent":["// ---------------------------------------------------------------------------\n// THE L0 FAULT CODEBOOK — one letter per fault, declared HERE and nowhere else.\n//\n// Both halves of L0 stamp `fault=<code>` onto their audit lines: the POSIX `sh` shim writes D/X/U/K\n// (shim-audit-log.ts), and the guard bin writes S/C/Y in JS (decision-log.ts, via runner.ts and\n// hook-core.ts). The whole value of that field is that ONE grep — `grep 'fault=S'` — spans the entire\n// trail, and that the faults actually observed can be diffed against `L0_FAULTS`. Both properties hold\n// only while every emitter spells the letters the SAME way, and a hand-retyped 'S' in one emitter is\n// exactly the drift this module exists to make impossible.\n//\n// It used to be retyped: the shim assigned `WP_FAULT=X` as a literal, SHIM_LOG_FAULTS listed the four\n// sh-side letters again, and L0_FAULTS listed all seven a third time. Three spellings of one\n// vocabulary, held together by a unit test that could only notice AFTER they diverged.\n//\n// This module is a LEAF — no imports at all — on purpose. `l0-matrix.ts` (core) builds `L0_FAULTS`\n// from these constants and `shim-audit-log.ts` / `shim.ts` (bin) render them into the shim; parking the\n// constants in either of those two would make the other one a core↔bin import cycle. It also keeps the\n// shim renderer dependency-light, which it must be: it has to work on a tree too broken to load the\n// rule engine.\n// ---------------------------------------------------------------------------\n\n/** `D` — version drift: the root package.json pin != the installed version. Decided in `sh`. */\nexport const L0_FAULT_DRIFT = 'D';\n\n/** `X` — the guard bin is missing (fresh clone, new worktree, package removed). Decided in `sh`. */\nexport const L0_FAULT_BIN_MISSING = 'X';\n\n/** `U` — the bin is missing AND nothing declares the package, so an install is a no-op. `sh`. */\nexport const L0_FAULT_UNDECLARED = 'U';\n\n/** `K` — the bin is present but CRASHED (corrupt node_modules). Decided in `sh`. */\nexport const L0_FAULT_BIN_BROKEN = 'K';\n\n/** `S` — the committed `.claude/webpieces/ai-hook.sh` != `renderShim()`. Decided in the bin, in JS. */\nexport const L0_FAULT_SHIM_STALE = 'S';\n\n/** `C` — `webpieces.config.json` is missing. Decided in the bin, in JS. */\nexport const L0_FAULT_CONFIG_MISSING = 'C';\n\n/** `Y` — a loaded rule has no `webpieces.config.json` key. Decided in the bin, in JS. */\nexport const L0_FAULT_CONFIG_OUT_OF_SYNC = 'Y';\n\n/**\n * No fault AT THIS LAYER — the value every audit line carries when nothing fired.\n *\n * Never a claim that nothing was wrong: a `fault=-` line from the `sh` shim only says the sh half found\n * nothing, and the bin it then exec'd may still have blocked on S/C/Y and stamped its own line.\n */\nexport const L0_FAULT_NONE = '-';\n\n/**\n * The faults decided in POSIX `sh`, BEFORE the bin runs — a stale, missing or broken validator cannot\n * be trusted to validate itself. In first-match-wins order.\n */\nexport const L0_SH_FAULT_CODES = [\n L0_FAULT_DRIFT, L0_FAULT_BIN_MISSING, L0_FAULT_UNDECLARED, L0_FAULT_BIN_BROKEN,\n] as const;\n\n/**\n * The faults decided INSIDE the guard bin, in JS. These reached the audit trail with no fault label at\n * all until the JS emitters started stamping them: an `S` storm that blocked an agent for ~20 tool\n * calls left two lines in the `rejections/` stream, both attributed to a downstream rule, and nothing\n * anywhere identifying L0.\n */\nexport const L0_JS_FAULT_CODES = [\n L0_FAULT_SHIM_STALE, L0_FAULT_CONFIG_MISSING, L0_FAULT_CONFIG_OUT_OF_SYNC,\n] as const;\n\n// ---------------------------------------------------------------------------\n// THE JOIN KEYS — the three artifacts that describe one L0 event, and the coordinates that line them up.\n//\n// There are three, and until now they could not be grepped together:\n// 1. THE DENY the agent reads in the moment (shim-deny-reason.ts for S; DENY_REASON_SH for D/X/U/K)\n// 2. THE AUDIT LINE (`.webpieces/logs/**`), which carries `layer=` `row=` `fault=`\n// 3. THE MATRIX DOC (webpieces.guard-matrix.md, rendered from L0_FAULTS + L0_ALLOWLIST)\n//\n// The deny had NONE of them: no fault letter, no row, and — the highest-value omission — no guard NAME,\n// while every L1/L2 deny opens `[<rule-name>] (N violations)`. So a transcript could not be debugged\n// against the log after the fact, and a reader could not find the matrix row by eye.\n//\n// These constants are that vocabulary, declared HERE for the same reason the letters are: this module is\n// a LEAF with no imports, so `core/l0-matrix.ts` (the doc), `bin/shim*.ts` (the denies) and\n// `core/decision-log.ts` (the log) can all reach it without an import cycle. Retyping a name in any one\n// of them is the drift this file exists to make impossible.\n// ---------------------------------------------------------------------------\n\n/**\n * EVERY L0 fault code, as a type. The two arrays above are the halves; this is their union, and it is\n * what makes `L0_FAULT_NAMES` TOTAL — a `Record<string, …>` would have forced a `?? 'unknown'` fallback\n * at every read, which is shim shape #4 (a runtime default standing in for a type that could have\n * expressed the invariant). With the union, a new fault added without a name is a COMPILE error, and\n * neither reader needs a defensive branch.\n */\nexport type L0FaultCode = typeof L0_SH_FAULT_CODES[number] | typeof L0_JS_FAULT_CODES[number];\n\n/**\n * The stable, human-readable GUARD NAME per fault code — what goes in the deny's `[…]` header, in the\n * matrix doc's own `guard` column, and nowhere else in a second spelling.\n *\n * L1 prints `[stale-main-bash-guard] (1 violation)` and L0 printed nothing comparable; the names below\n * are deliberately in that same kebab shape so the two layers read as one system. They are IDENTITY, not\n * prose: renaming one silently breaks a grep that spans all three artifacts, exactly as renumbering an\n * L1 row would (see L1_ROWS' header).\n */\nexport const L0_FAULT_NAMES: Readonly<Record<L0FaultCode, string>> = {\n [L0_FAULT_DRIFT]: 'version-drift',\n [L0_FAULT_BIN_MISSING]: 'guard-bin-missing',\n [L0_FAULT_UNDECLARED]: 'guard-pkg-undeclared',\n [L0_FAULT_BIN_BROKEN]: 'guard-bin-crashed',\n [L0_FAULT_SHIM_STALE]: 'managed-hook-surface',\n [L0_FAULT_CONFIG_MISSING]: 'config-missing',\n [L0_FAULT_CONFIG_OUT_OF_SYNC]: 'config-out-of-sync',\n};\n\n/**\n * The three rows of L0's decision matrix, by number — the numbers `renderGuardMatrixDoc` prints, the\n * numbers the audit line's `row=` carries, and the numbers a deny cites. L0's matrix has no genuine\n * second dimension: every branch reduces to `fault present?` x `on the allowlist?`.\n *\n * All three are named because all three are LOGGED: the sh half writes a line for the healthy hand-down\n * too (`PASS-BIN-ALLOW`), which is the line that tells \"the guard ran and found nothing\" apart from\n * \"the guard never ran\".\n */\nexport const L0_ROW_HANDED_DOWN = '1';\n\n/** Row 2: a fault is present but the call is on the L0 allowlist — a cure, a Read, or the config edit. */\nexport const L0_ROW_ALLOWLISTED = '2';\n\n/** Row 3: a fault is present and the call is NOT on the allowlist. The one row that ever BLOCKS. */\nexport const L0_ROW_BLOCKED = '3';\n\n/**\n * The `[guard-name] (layer=L0 fault=<code> row=<n>)` header every L0 deny opens with, after the ❌ line.\n *\n * ONE builder, called by the JS denies directly and interpolated into the POSIX-sh denies at render\n * time by renderShim() — so the sh half cannot spell the coordinates differently from the JS half even\n * though it cannot import anything at runtime. `detail` is the per-fault count that mirrors\n * formatReport's `(N violations)`.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over this leaf module's own constants; it must stay importable by the dependency-free shim renderer.\nexport function l0GuardHeader(fault: L0FaultCode, detail: string): string {\n return `[${L0_FAULT_NAMES[fault]}] (layer=L0 fault=${fault} row=${L0_ROW_BLOCKED}, ${detail})`;\n}\n\n/**\n * The one-line citation of WHICH matrix row was taken and on what dimension values — L1's pattern\n * (its deny cites \"`w` / `n` / `n` - row 8\"), in L0's own two columns.\n */\n// webpieces-disable no-function-outside-class -- sibling of l0GuardHeader in this leaf codebook module.\nexport function l0MatrixCitation(fault: L0FaultCode): string {\n return `matrix row ${L0_ROW_BLOCKED}: fault=${fault} present / on the allowlist? no -> BLOCK. Those are the same coordinates the audit line carries (layer=L0 row=${L0_ROW_BLOCKED} fault=${fault}) and the same row webpieces.guard-matrix.md prints.`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"l0-fault-codes.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-fault-codes.ts"],"names":[],"mappings":";AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,EAAE;AACF,oGAAoG;AACpG,gGAAgG;AAChG,sGAAsG;AACtG,uGAAuG;AACvG,qGAAqG;AACrG,2DAA2D;AAC3D,EAAE;AACF,sGAAsG;AACtG,6FAA6F;AAC7F,uFAAuF;AACvF,EAAE;AACF,mGAAmG;AACnG,wGAAwG;AACxG,uGAAuG;AACvG,oGAAoG;AACpG,eAAe;AACf,8EAA8E;;;AAgI9E,sCAEC;AAOD,4CAEC;AAzID,gGAAgG;AACnF,QAAA,cAAc,GAAG,GAAG,CAAC;AAElC,oGAAoG;AACvF,QAAA,oBAAoB,GAAG,GAAG,CAAC;AAExC,iGAAiG;AACpF,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,oFAAoF;AACvE,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,uGAAuG;AAC1F,QAAA,mBAAmB,GAAG,GAAG,CAAC;AAEvC,2EAA2E;AAC9D,QAAA,uBAAuB,GAAG,GAAG,CAAC;AAE3C,yFAAyF;AAC5E,QAAA,2BAA2B,GAAG,GAAG,CAAC;AAE/C;;;;;GAKG;AACU,QAAA,aAAa,GAAG,GAAG,CAAC;AAEjC;;;GAGG;AACU,QAAA,iBAAiB,GAAG;IAC7B,sBAAc,EAAE,4BAAoB,EAAE,2BAAmB,EAAE,2BAAmB;CACxE,CAAC;AAEX;;;;;GAKG;AACU,QAAA,iBAAiB,GAAG;IAC7B,2BAAmB,EAAE,+BAAuB,EAAE,mCAA2B;CACnE,CAAC;AA6BX;;;;;;;;GAQG;AACU,QAAA,cAAc,GAA0C;IACjE,CAAC,sBAAc,CAAC,EAAE,eAAe;IACjC,CAAC,4BAAoB,CAAC,EAAE,mBAAmB;IAC3C,CAAC,2BAAmB,CAAC,EAAE,sBAAsB;IAC7C,CAAC,2BAAmB,CAAC,EAAE,mBAAmB;IAC1C,CAAC,2BAAmB,CAAC,EAAE,sBAAsB;IAC7C,CAAC,+BAAuB,CAAC,EAAE,gBAAgB;IAC3C,CAAC,mCAA2B,CAAC,EAAE,oBAAoB;CACtD,CAAC;AAEF;;;;GAIG;AACU,QAAA,QAAQ,GAAG,IAAI,CAAC;AAE7B;;;;;;;;GAQG;AACU,QAAA,kBAAkB,GAAG,GAAG,CAAC;AAEtC,0GAA0G;AAC7F,QAAA,kBAAkB,GAAG,GAAG,CAAC;AAEtC,oGAAoG;AACvF,QAAA,cAAc,GAAG,GAAG,CAAC;AAElC;;;;;;;GAOG;AACH,0KAA0K;AAC1K,SAAgB,aAAa,CAAC,KAAkB,EAAE,MAAc;IAC5D,OAAO,IAAI,sBAAc,CAAC,KAAK,CAAC,YAAY,gBAAQ,UAAU,KAAK,QAAQ,sBAAc,KAAK,MAAM,GAAG,CAAC;AAC5G,CAAC;AAED;;;GAGG;AACH,wGAAwG;AACxG,SAAgB,gBAAgB,CAAC,KAAkB;IAC/C,OAAO,cAAc,sBAAc,WAAW,KAAK,0GAA0G,gBAAQ,QAAQ,sBAAc,UAAU,KAAK,sDAAsD,CAAC;AACrQ,CAAC","sourcesContent":["// ---------------------------------------------------------------------------\n// THE L0 FAULT CODEBOOK — one letter per fault, declared HERE and nowhere else.\n//\n// Both halves of L0 stamp `fault=<code>` onto their audit lines: the POSIX `sh` shim writes D/X/U/K\n// (shim-audit-log.ts), and the guard bin writes S/C/Y in JS (decision-log.ts, via runner.ts and\n// hook-core.ts). The whole value of that field is that ONE grep — `grep 'fault=S'` — spans the entire\n// trail, and that the faults actually observed can be diffed against `L0_FAULTS`. Both properties hold\n// only while every emitter spells the letters the SAME way, and a hand-retyped 'S' in one emitter is\n// exactly the drift this module exists to make impossible.\n//\n// It used to be retyped: the shim assigned `WP_FAULT=X` as a literal, SHIM_LOG_FAULTS listed the four\n// sh-side letters again, and L0_FAULTS listed all seven a third time. Three spellings of one\n// vocabulary, held together by a unit test that could only notice AFTER they diverged.\n//\n// This module is a LEAF — no imports at all — on purpose. `l0-matrix.ts` (core) builds `L0_FAULTS`\n// from these constants and `shim-audit-log.ts` / `shim.ts` (bin) render them into the shim; parking the\n// constants in either of those two would make the other one a core↔bin import cycle. It also keeps the\n// shim renderer dependency-light, which it must be: it has to work on a tree too broken to load the\n// rule engine.\n// ---------------------------------------------------------------------------\n\n/** `D` — version drift: the root package.json pin != the installed version. Decided in `sh`. */\nexport const L0_FAULT_DRIFT = 'D';\n\n/** `X` — the guard bin is missing (fresh clone, new worktree, package removed). Decided in `sh`. */\nexport const L0_FAULT_BIN_MISSING = 'X';\n\n/** `U` — the bin is missing AND nothing declares the package, so an install is a no-op. `sh`. */\nexport const L0_FAULT_UNDECLARED = 'U';\n\n/** `K` — the bin is present but CRASHED (corrupt node_modules). Decided in `sh`. */\nexport const L0_FAULT_BIN_BROKEN = 'K';\n\n/** `S` — the committed `.claude/webpieces/ai-hook.sh` != `renderShim()`. Decided in the bin, in JS. */\nexport const L0_FAULT_SHIM_STALE = 'S';\n\n/** `C` — `webpieces.config.json` is missing. Decided in the bin, in JS. */\nexport const L0_FAULT_CONFIG_MISSING = 'C';\n\n/** `Y` — a loaded rule has no `webpieces.config.json` key. Decided in the bin, in JS. */\nexport const L0_FAULT_CONFIG_OUT_OF_SYNC = 'Y';\n\n/**\n * No fault AT THIS LAYER — the value every audit line carries when nothing fired.\n *\n * Never a claim that nothing was wrong: a `fault=-` line from the `sh` shim only says the sh half found\n * nothing, and the bin it then exec'd may still have blocked on S/C/Y and stamped its own line.\n */\nexport const L0_FAULT_NONE = '-';\n\n/**\n * The faults decided in POSIX `sh`, BEFORE the bin runs — a stale, missing or broken validator cannot\n * be trusted to validate itself. In first-match-wins order.\n */\nexport const L0_SH_FAULT_CODES = [\n L0_FAULT_DRIFT, L0_FAULT_BIN_MISSING, L0_FAULT_UNDECLARED, L0_FAULT_BIN_BROKEN,\n] as const;\n\n/**\n * The faults decided INSIDE the guard bin, in JS. These reached the audit trail with no fault label at\n * all until the JS emitters started stamping them: an `S` storm that blocked an agent for ~20 tool\n * calls left two lines in the `rejections/` stream, both attributed to a downstream rule, and nothing\n * anywhere identifying L0.\n */\nexport const L0_JS_FAULT_CODES = [\n L0_FAULT_SHIM_STALE, L0_FAULT_CONFIG_MISSING, L0_FAULT_CONFIG_OUT_OF_SYNC,\n] as const;\n\n// ---------------------------------------------------------------------------\n// THE JOIN KEYS — the three artifacts that describe one L0 event, and the coordinates that line them up.\n//\n// There are three, and until now they could not be grepped together:\n// 1. THE DENY the agent reads in the moment (shim-deny-reason.ts for S; DENY_REASON_SH for D/X/U/K)\n// 2. THE AUDIT LINE (`.webpieces/logs/**`), which carries `layer=` `row=` `fault=`\n// 3. THE MATRIX DOC (webpieces.guard-matrix.md, rendered from L0_FAULTS + L0_ALLOWLIST)\n//\n// The deny had NONE of them: no fault letter, no row, and — the highest-value omission — no guard NAME,\n// while every L1/L2 deny opens `[<rule-name>] (N violations)`. So a transcript could not be debugged\n// against the log after the fact, and a reader could not find the matrix row by eye.\n//\n// These constants are that vocabulary, declared HERE for the same reason the letters are: this module is\n// a LEAF with no imports, so `core/l0-matrix.ts` (the doc), `bin/shim*.ts` (the denies) and\n// `core/decision-log.ts` (the log) can all reach it without an import cycle. Retyping a name in any one\n// of them is the drift this file exists to make impossible.\n// ---------------------------------------------------------------------------\n\n/**\n * EVERY L0 fault code, as a type. The two arrays above are the halves; this is their union, and it is\n * what makes `L0_FAULT_NAMES` TOTAL — a `Record<string, …>` would have forced a `?? 'unknown'` fallback\n * at every read, which is shim shape #4 (a runtime default standing in for a type that could have\n * expressed the invariant). With the union, a new fault added without a name is a COMPILE error, and\n * neither reader needs a defensive branch.\n */\nexport type L0FaultCode = typeof L0_SH_FAULT_CODES[number] | typeof L0_JS_FAULT_CODES[number];\n\n/**\n * The stable, human-readable GUARD NAME per fault code — what goes in the deny's `[…]` header, in the\n * matrix doc's own `guard` column, and nowhere else in a second spelling.\n *\n * L1 prints `[stale-main-bash-guard] (1 violation)` and L0 printed nothing comparable; the names below\n * are deliberately in that same kebab shape so the two layers read as one system. They are IDENTITY, not\n * prose: renaming one silently breaks a grep that spans all three artifacts, exactly as renumbering an\n * L1 row would (see L1_ROWS' header).\n */\nexport const L0_FAULT_NAMES: Readonly<Record<L0FaultCode, string>> = {\n [L0_FAULT_DRIFT]: 'version-drift',\n [L0_FAULT_BIN_MISSING]: 'guard-bin-missing',\n [L0_FAULT_UNDECLARED]: 'guard-pkg-undeclared',\n [L0_FAULT_BIN_BROKEN]: 'guard-bin-crashed',\n [L0_FAULT_SHIM_STALE]: 'managed-hook-surface',\n [L0_FAULT_CONFIG_MISSING]: 'config-missing',\n [L0_FAULT_CONFIG_OUT_OF_SYNC]: 'config-out-of-sync',\n};\n\n/**\n * The LAYER token — the other half of every join key, spelled once. The deny header, the matrix\n * citation, the sh audit line and guards/L0-tooling.md all read it from here, so `grep layer=L0` cannot\n * miss an artifact that typed the token itself.\n */\nexport const L0_LAYER = 'L0';\n\n/**\n * The three rows of L0's decision matrix, by number — the numbers `renderGuardMatrixDoc` prints, the\n * numbers the audit line's `row=` carries, and the numbers a deny cites. L0's matrix has no genuine\n * second dimension: every branch reduces to `fault present?` x `on the allowlist?`.\n *\n * All three are named because all three are LOGGED: the sh half writes a line for the healthy hand-down\n * too (`PASS-BIN-ALLOW`), which is the line that tells \"the guard ran and found nothing\" apart from\n * \"the guard never ran\".\n */\nexport const L0_ROW_HANDED_DOWN = '1';\n\n/** Row 2: a fault is present but the call is on the L0 allowlist — a cure, a Read, or the config edit. */\nexport const L0_ROW_ALLOWLISTED = '2';\n\n/** Row 3: a fault is present and the call is NOT on the allowlist. The one row that ever BLOCKS. */\nexport const L0_ROW_BLOCKED = '3';\n\n/**\n * The `[guard-name] (layer=L0 fault=<code> row=<n>)` header every L0 deny opens with, after the ❌ line.\n *\n * ONE builder, called by the JS denies directly and interpolated into the POSIX-sh denies at render\n * time by renderShim() — so the sh half cannot spell the coordinates differently from the JS half even\n * though it cannot import anything at runtime. `detail` is the per-fault count that mirrors\n * formatReport's `(N violations)`.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over this leaf module's own constants; it must stay importable by the dependency-free shim renderer.\nexport function l0GuardHeader(fault: L0FaultCode, detail: string): string {\n return `[${L0_FAULT_NAMES[fault]}] (layer=${L0_LAYER} fault=${fault} row=${L0_ROW_BLOCKED}, ${detail})`;\n}\n\n/**\n * The one-line citation of WHICH matrix row was taken and on what dimension values — L1's pattern\n * (its deny cites \"`w` / `n` / `n` - row 8\"), in L0's own two columns.\n */\n// webpieces-disable no-function-outside-class -- sibling of l0GuardHeader in this leaf codebook module.\nexport function l0MatrixCitation(fault: L0FaultCode): string {\n return `matrix row ${L0_ROW_BLOCKED}: fault=${fault} present / on the allowlist? no -> BLOCK. Those are the same coordinates the audit line carries (layer=${L0_LAYER} row=${L0_ROW_BLOCKED} fault=${fault}) and the same row webpieces.guard-matrix.md prints.`;\n}\n"]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Opening marker of the generated block, as it appears in guards/L0-tooling.md. */
|
|
2
|
+
export declare const L0_DOC_BEGIN = "<!-- BEGIN GENERATED \u2014 L0ToolingDoc.render() in ai-hook-rules/src/core/l0-tooling-doc.ts; run `pnpm guards:generate` -->";
|
|
3
|
+
/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */
|
|
4
|
+
export declare const L0_DOC_END = "<!-- END GENERATED \u2014 hand-written prose resumes here -->";
|
|
5
|
+
/**
|
|
6
|
+
* The generated section of guards/L0-tooling.md, and the splice that puts it there.
|
|
7
|
+
*
|
|
8
|
+
* A class rather than a family of module functions (see l1-doc.ts, which predates the rule): the
|
|
9
|
+
* renderer, the extractor and the splicer are one unit, and the spec drives all three.
|
|
10
|
+
*/
|
|
11
|
+
export declare class L0ToolingDoc {
|
|
12
|
+
/** The whole generated block, WITHOUT the markers — those belong to the file, not to the renderer. */
|
|
13
|
+
render(): string;
|
|
14
|
+
/**
|
|
15
|
+
* The generated text of `doc`, exactly as committed. Throws when a marker is missing or doubled —
|
|
16
|
+
* a silently-unspliced doc is the drift this whole arrangement exists to end.
|
|
17
|
+
*/
|
|
18
|
+
extract(doc: string): string;
|
|
19
|
+
/** `doc` with the generated block replaced by today's render. Preserves every byte outside it. */
|
|
20
|
+
splice(doc: string): string;
|
|
21
|
+
/** A markdown cell: a literal `|` inside a value would end the column. */
|
|
22
|
+
private cell;
|
|
23
|
+
private preamble;
|
|
24
|
+
private faultTable;
|
|
25
|
+
/**
|
|
26
|
+
* The cures, one row per option. LITERAL commands only, rendered from `L0_FAULTS[].cures` — the same
|
|
27
|
+
* array `webpieces.guard-matrix.md` renders its Fix sections from, so the two can never prescribe
|
|
28
|
+
* different commands for one fault.
|
|
29
|
+
*/
|
|
30
|
+
private fixTable;
|
|
31
|
+
private matrixTable;
|
|
32
|
+
private allowlistTable;
|
|
33
|
+
/**
|
|
34
|
+
* Fault `S`'s subject: the THREE managed things, and the two registrations rendered from
|
|
35
|
+
* `shimCommand()` — which is what makes "they are ABSOLUTE" a fact this doc cannot get wrong.
|
|
36
|
+
*/
|
|
37
|
+
private managedSurface;
|
|
38
|
+
/**
|
|
39
|
+
* The audit line, rendered from `SHIM_LOG_FIELDS` + `SHIM_LOG_VERDICTS`. The optional field prints
|
|
40
|
+
* in brackets because that is exactly what it is: `bin=` appears only when it differs from `shim=`.
|
|
41
|
+
*/
|
|
42
|
+
private auditLine;
|
|
43
|
+
private logPaths;
|
|
44
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.L0ToolingDoc = exports.L0_DOC_END = exports.L0_DOC_BEGIN = void 0;
|
|
4
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
|
+
const shim_1 = require("../bin/shim");
|
|
6
|
+
const hook_registration_1 = require("../bin/hook-registration");
|
|
7
|
+
const l0_fault_codes_1 = require("./l0-fault-codes");
|
|
8
|
+
const log_streams_1 = require("./log-streams");
|
|
9
|
+
const l0_matrix_1 = require("./l0-matrix");
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// THE GENERATED HALF OF guards/L0-tooling.md.
|
|
12
|
+
//
|
|
13
|
+
// That file is the largest guard doc in the repo and, unlike its two siblings, it was hand-written from
|
|
14
|
+
// end to end — so it went stale twice in one session: it described FOUR managed surfaces after there
|
|
15
|
+
// were three, and a 7-field audit line after `shim=`/`bin=`/`layer=`/`row=` had joined it.
|
|
16
|
+
//
|
|
17
|
+
// The split, and WHY it is a split rather than a whole-file renderer:
|
|
18
|
+
//
|
|
19
|
+
// GENERATED (here) anything that is a COORDINATE of the code — the fault codes and their guard
|
|
20
|
+
// names, the cures, the three matrix rows, the allowlist, the managed surfaces,
|
|
21
|
+
// the audit-line fields and the verdict vocabulary. Every one of those is
|
|
22
|
+
// already an array somewhere; a doc that re-types them is a second spelling.
|
|
23
|
+
// HAND-WRITTEN (there) the incident histories, the config-validation invariant, the known gaps, the
|
|
24
|
+
// worked example. That is argument, not data, and a renderer would mangle it.
|
|
25
|
+
//
|
|
26
|
+
// The section is spliced BETWEEN two literal markers, the pattern renderL1Doc() already uses for its
|
|
27
|
+
// prose/table interleave, so the prose surrounds the tables instead of being swallowed by them.
|
|
28
|
+
// `pnpm guards:generate` rewrites the block and `l0-tooling-doc.spec.ts` locks it byte-for-byte.
|
|
29
|
+
//
|
|
30
|
+
// EVERY COMMAND PRINTED HERE COMES FROM A CONSTANT, never a retyped literal: the L0 allowlist matches
|
|
31
|
+
// WHOLE command strings, so a paraphrased cure in a doc is an unrunnable cure. The spec re-asserts that
|
|
32
|
+
// by running isAllowed() over every command this renderer prints.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Opening marker of the generated block, as it appears in guards/L0-tooling.md. */
|
|
35
|
+
exports.L0_DOC_BEGIN = '<!-- BEGIN GENERATED — L0ToolingDoc.render() in ai-hook-rules/src/core/l0-tooling-doc.ts; run `pnpm guards:generate` -->';
|
|
36
|
+
/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */
|
|
37
|
+
exports.L0_DOC_END = '<!-- END GENERATED — hand-written prose resumes here -->';
|
|
38
|
+
/**
|
|
39
|
+
* The generated section of guards/L0-tooling.md, and the splice that puts it there.
|
|
40
|
+
*
|
|
41
|
+
* A class rather than a family of module functions (see l1-doc.ts, which predates the rule): the
|
|
42
|
+
* renderer, the extractor and the splicer are one unit, and the spec drives all three.
|
|
43
|
+
*/
|
|
44
|
+
class L0ToolingDoc {
|
|
45
|
+
/** The whole generated block, WITHOUT the markers — those belong to the file, not to the renderer. */
|
|
46
|
+
render() {
|
|
47
|
+
return [
|
|
48
|
+
...this.preamble(),
|
|
49
|
+
...this.faultTable(),
|
|
50
|
+
...this.fixTable(),
|
|
51
|
+
...this.matrixTable(),
|
|
52
|
+
...this.allowlistTable(),
|
|
53
|
+
...this.managedSurface(),
|
|
54
|
+
...this.auditLine(),
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The generated text of `doc`, exactly as committed. Throws when a marker is missing or doubled —
|
|
59
|
+
* a silently-unspliced doc is the drift this whole arrangement exists to end.
|
|
60
|
+
*/
|
|
61
|
+
extract(doc) {
|
|
62
|
+
const opens = doc.split(exports.L0_DOC_BEGIN).length - 1;
|
|
63
|
+
const closes = doc.split(exports.L0_DOC_END).length - 1;
|
|
64
|
+
if (opens !== 1 || closes !== 1) {
|
|
65
|
+
throw new Error(`guards/L0-tooling.md must carry exactly one BEGIN/END marker pair, found ${String(opens)}/${String(closes)}`);
|
|
66
|
+
}
|
|
67
|
+
const afterBegin = doc.slice(doc.indexOf(exports.L0_DOC_BEGIN) + exports.L0_DOC_BEGIN.length);
|
|
68
|
+
return afterBegin.slice(0, afterBegin.indexOf(exports.L0_DOC_END)).replace(/^\n/, '').replace(/\n$/, '');
|
|
69
|
+
}
|
|
70
|
+
/** `doc` with the generated block replaced by today's render. Preserves every byte outside it. */
|
|
71
|
+
splice(doc) {
|
|
72
|
+
const head = doc.slice(0, doc.indexOf(exports.L0_DOC_BEGIN) + exports.L0_DOC_BEGIN.length);
|
|
73
|
+
const tail = doc.slice(doc.indexOf(exports.L0_DOC_END));
|
|
74
|
+
// extract() is called for its VALIDATION — one marker pair — before anything is rewritten.
|
|
75
|
+
this.extract(doc);
|
|
76
|
+
return `${head}\n${this.render()}\n${tail}`;
|
|
77
|
+
}
|
|
78
|
+
/** A markdown cell: a literal `|` inside a value would end the column. */
|
|
79
|
+
cell(value) {
|
|
80
|
+
return value.split('|').join('\\|');
|
|
81
|
+
}
|
|
82
|
+
preamble() {
|
|
83
|
+
return [
|
|
84
|
+
'> **GENERATED — do not hand-edit between the markers.** Rendered by `L0ToolingDoc.render()`',
|
|
85
|
+
'> (`ai-hook-rules/src/core/l0-tooling-doc.ts`) from `L0_FAULTS`, `L0_ALLOWLIST`, the managed-surface',
|
|
86
|
+
'> constants and `SHIM_LOG_FIELDS` — the same arrays the guard consults. `pnpm guards:generate`',
|
|
87
|
+
'> rewrites it; `l0-tooling-doc.spec.ts` locks it byte-for-byte. The prose outside the markers is',
|
|
88
|
+
'> hand-written and stays that way.',
|
|
89
|
+
'',
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
faultTable() {
|
|
93
|
+
return [
|
|
94
|
+
'### The faults',
|
|
95
|
+
'',
|
|
96
|
+
'| code | guard name | fault | detected by | enforced in |',
|
|
97
|
+
'|---|---|---|---|---|',
|
|
98
|
+
...l0_matrix_1.L0_FAULTS.map((f) => `| \`${f.code}\` | \`${l0_fault_codes_1.L0_FAULT_NAMES[f.code]}\` | ${this.cell(f.name)} | ${f.detectedBy} | ${f.enforcedIn} |`),
|
|
99
|
+
'',
|
|
100
|
+
`First match wins. \`${l0_fault_codes_1.L0_SH_FAULT_CODES.join('`/`')}\` are decided in POSIX \`sh\` inside the committed shim, BEFORE`,
|
|
101
|
+
`the guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.`,
|
|
102
|
+
`\`${l0_fault_codes_1.L0_JS_FAULT_CODES.join('`/`')}\` are decided inside the bin, in JS.`,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The cures, one row per option. LITERAL commands only, rendered from `L0_FAULTS[].cures` — the same
|
|
108
|
+
* array `webpieces.guard-matrix.md` renders its Fix sections from, so the two can never prescribe
|
|
109
|
+
* different commands for one fault.
|
|
110
|
+
*/
|
|
111
|
+
fixTable() {
|
|
112
|
+
const rows = [];
|
|
113
|
+
for (const fault of l0_matrix_1.L0_FAULTS) {
|
|
114
|
+
fault.cures.forEach((cure, i) => {
|
|
115
|
+
const literal = cure.isCommand() ? `\`${cure.call.command}\`` : `edit \`${cure.mention}\` yourself`;
|
|
116
|
+
const option = `${String(i + 1)}${cure.preferred ? ' (preferred)' : ''}`;
|
|
117
|
+
rows.push(`| \`${fault.code}\` | ${option} | ${this.cell(literal)} | ${this.cell(cure.discriminator)} |`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return [
|
|
121
|
+
'### The fix, per fault — type the option EXACTLY as written, and run nothing else on that line',
|
|
122
|
+
'',
|
|
123
|
+
'| fault | option | run EXACTLY | pick this when |',
|
|
124
|
+
'|---|---|---|---|',
|
|
125
|
+
...rows,
|
|
126
|
+
'',
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
matrixTable() {
|
|
130
|
+
return [
|
|
131
|
+
'### The matrix — three rows, and the fault only picks the MESSAGE',
|
|
132
|
+
'',
|
|
133
|
+
`| row | fault | on the allowlist? | outcome | logged as |`,
|
|
134
|
+
'|---|---|---|---|---|',
|
|
135
|
+
`| ${l0_fault_codes_1.L0_ROW_HANDED_DOWN} | none | — | hand down to the next guard layer | \`layer=${l0_fault_codes_1.L0_LAYER} row=${l0_fault_codes_1.L0_ROW_HANDED_DOWN}\` |`,
|
|
136
|
+
`| ${l0_fault_codes_1.L0_ROW_ALLOWLISTED} | any | yes | PASS or ALLOW (see the entry) | \`layer=${l0_fault_codes_1.L0_LAYER} row=${l0_fault_codes_1.L0_ROW_ALLOWLISTED}\` |`,
|
|
137
|
+
`| ${l0_fault_codes_1.L0_ROW_BLOCKED} | any | no | BLOCK — **only the message varies by fault** | \`layer=${l0_fault_codes_1.L0_LAYER} row=${l0_fault_codes_1.L0_ROW_BLOCKED}\` |`,
|
|
138
|
+
'',
|
|
139
|
+
'The tool is not a dimension either: "any Read" is an allowlist ENTRY, not a tool check. Those are',
|
|
140
|
+
'the same coordinates every L0 deny opens with, so a deny, a log line and this table join by eye.',
|
|
141
|
+
'',
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
allowlistTable() {
|
|
145
|
+
return [
|
|
146
|
+
'### The allowlist — ONE list, consulted identically by every fault',
|
|
147
|
+
'',
|
|
148
|
+
'| # | allowed | outcome | bypasses L1 on a HEALTHY tree? |',
|
|
149
|
+
'|---|---|---|---|',
|
|
150
|
+
...shim_1.L0_ALLOWLIST.map((e, i) => `| ${String(i + 1)} | ${this.cell(e.label)} | ${e.kind.toUpperCase()} | ${e.cure ? 'yes — it REPAIRS the tooling' : 'no — it repairs nothing, so L1 still judges it'} |`),
|
|
151
|
+
'',
|
|
152
|
+
'- **PASS** — L0 has no objection; the call falls THROUGH so downstream guards still judge it.',
|
|
153
|
+
'- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',
|
|
154
|
+
' downstream guard would block it.',
|
|
155
|
+
'',
|
|
156
|
+
'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1` and a',
|
|
157
|
+
'pipe into `tail`/`head` are tolerated; nothing else. Appending `&& git status` makes it a DIFFERENT',
|
|
158
|
+
'command and it is rejected again — that is not the guard refusing its own cure.',
|
|
159
|
+
'',
|
|
160
|
+
'`git merge` and a **bare** `git pull` are both deliberately absent — see "The git-sync split"',
|
|
161
|
+
'below for why the one safe pull spelling is on the list and the bare one is not. Main is merged',
|
|
162
|
+
'ONLY through the 3-point fork merge (`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a',
|
|
163
|
+
'PR is already open).',
|
|
164
|
+
'',
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Fault `S`'s subject: the THREE managed things, and the two registrations rendered from
|
|
169
|
+
* `shimCommand()` — which is what makes "they are ABSOLUTE" a fact this doc cannot get wrong.
|
|
170
|
+
*/
|
|
171
|
+
managedSurface() {
|
|
172
|
+
return [
|
|
173
|
+
'### The managed hook surface — what fault `S` compares (THREE things, one set)',
|
|
174
|
+
'',
|
|
175
|
+
'| # | surface |',
|
|
176
|
+
'|---|---|',
|
|
177
|
+
`| 1 | \`${hook_registration_1.SHIM_SURFACE}\` |`,
|
|
178
|
+
`| 2 | ${this.cell(hook_registration_1.REGISTRATION_SURFACE)} |`,
|
|
179
|
+
`| 3 | ${this.cell(hook_registration_1.ENV_SURFACE)} |`,
|
|
180
|
+
'',
|
|
181
|
+
'The registration is TWO PreToolUse entries, and both are ABSOLUTE — they resolve from any cwd:',
|
|
182
|
+
'',
|
|
183
|
+
'```',
|
|
184
|
+
(0, hook_registration_1.shimCommand)(hook_registration_1.GUARDS_BIN),
|
|
185
|
+
(0, hook_registration_1.shimCommand)(hook_registration_1.RULES_BIN),
|
|
186
|
+
'```',
|
|
187
|
+
'',
|
|
188
|
+
`\`${shim_1.UPGRADE_SHIM_CMD}\` repairs all three. \`${shim_1.RESTORE_SHIM_CMD}\``,
|
|
189
|
+
`repairs \`${hook_registration_1.SHIM_SURFACE}\` and nothing else, so it is the fallback for an installed release too old`,
|
|
190
|
+
'to carry the first.',
|
|
191
|
+
'',
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The audit line, rendered from `SHIM_LOG_FIELDS` + `SHIM_LOG_VERDICTS`. The optional field prints
|
|
196
|
+
* in brackets because that is exactly what it is: `bin=` appears only when it differs from `shim=`.
|
|
197
|
+
*/
|
|
198
|
+
auditLine() {
|
|
199
|
+
const shape = shim_1.SHIM_LOG_FIELDS.map((f) => (f.optional ? `[${f.label}]` : f.label)).join(' ');
|
|
200
|
+
return [
|
|
201
|
+
'### The L0 audit line — one tab-separated line per tool call',
|
|
202
|
+
'',
|
|
203
|
+
'```',
|
|
204
|
+
shape,
|
|
205
|
+
'```',
|
|
206
|
+
'',
|
|
207
|
+
'| # | field | means |',
|
|
208
|
+
'|---|---|---|',
|
|
209
|
+
...shim_1.SHIM_LOG_FIELDS.map((f, i) => `| ${String(i + 1)} | \`${this.cell(f.label)}\` | ${this.cell(f.means)} |`),
|
|
210
|
+
'',
|
|
211
|
+
'| verdict | means |',
|
|
212
|
+
'|---|---|',
|
|
213
|
+
...shim_1.SHIM_LOG_VERDICTS.map((v) => `| \`${v.label}\` | ${this.cell(v.means)} |`),
|
|
214
|
+
'',
|
|
215
|
+
...this.logPaths(),
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
logPaths() {
|
|
219
|
+
const stream = `${rules_config_1.LOGS_STATE_DIR}/${log_streams_1.L0_SHIM_STREAM}/<session>-<agent|coordinator>-<binName>.log`;
|
|
220
|
+
return [
|
|
221
|
+
'It lands in the log directory of the tree the CALL was made in, centralized under the primary clone',
|
|
222
|
+
'so that removing a worktree does not take its audit trail with it:',
|
|
223
|
+
'',
|
|
224
|
+
'```',
|
|
225
|
+
`<primary>/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.WORKTREE_STATE_DIR}/<tree>/${stream}`,
|
|
226
|
+
`<primary>/${rules_config_1.WEBPIECES_TMP_DIR}/${stream} # from the primary clone itself`,
|
|
227
|
+
'```',
|
|
228
|
+
'',
|
|
229
|
+
`The binary stamps the same \`layer=\`/\`row=\`/\`fault=\` fields onto its OWN streams —`,
|
|
230
|
+
`\`${log_streams_1.L1_LOCATION_STREAM}/\`, \`${log_streams_1.L2_DECISIONS_STREAM}/\`, \`${log_streams_1.CALLS_STREAM}/\` and \`${log_streams_1.REJECTIONS_STREAM}/\` under the same`,
|
|
231
|
+
`\`${rules_config_1.LOGS_STATE_DIR}/\` — so one grep spans the whole trail. A \`${rules_config_1.CONFIG_FILENAME}\` fault (\`C\`/\`Y\`) is`,
|
|
232
|
+
'therefore visible there and never on an `L0-shim` line, which only ever carries the `sh`-side codes.',
|
|
233
|
+
'',
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.L0ToolingDoc = L0ToolingDoc;
|
|
238
|
+
//# sourceMappingURL=l0-tooling-doc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l0-tooling-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l0-tooling-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { CONFIG_FILENAME, LOGS_STATE_DIR, WEBPIECES_TMP_DIR, WORKTREE_STATE_DIR } from '@webpieces/rules-config';\n\nimport {\n L0AllowEntry, L0_ALLOWLIST, RESTORE_SHIM_CMD, SHIM_LOG_FIELDS, SHIM_LOG_VERDICTS, ShimLogField,\n ShimLogVerdict, UPGRADE_SHIM_CMD,\n} from '../bin/shim';\nimport {\n ENV_SURFACE, GUARDS_BIN, REGISTRATION_SURFACE, RULES_BIN, SHIM_SURFACE, shimCommand,\n} from '../bin/hook-registration';\nimport {\n L0FaultCode, L0_FAULT_NAMES, L0_JS_FAULT_CODES, L0_LAYER, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED,\n L0_ROW_HANDED_DOWN, L0_SH_FAULT_CODES,\n} from './l0-fault-codes';\nimport {\n CALLS_STREAM, L0_SHIM_STREAM, L1_LOCATION_STREAM, L2_DECISIONS_STREAM, REJECTIONS_STREAM,\n} from './log-streams';\nimport { L0Cure, L0Fault, L0_FAULTS } from './l0-matrix';\n\n// ---------------------------------------------------------------------------\n// THE GENERATED HALF OF guards/L0-tooling.md.\n//\n// That file is the largest guard doc in the repo and, unlike its two siblings, it was hand-written from\n// end to end — so it went stale twice in one session: it described FOUR managed surfaces after there\n// were three, and a 7-field audit line after `shim=`/`bin=`/`layer=`/`row=` had joined it.\n//\n// The split, and WHY it is a split rather than a whole-file renderer:\n//\n// GENERATED (here) anything that is a COORDINATE of the code — the fault codes and their guard\n// names, the cures, the three matrix rows, the allowlist, the managed surfaces,\n// the audit-line fields and the verdict vocabulary. Every one of those is\n// already an array somewhere; a doc that re-types them is a second spelling.\n// HAND-WRITTEN (there) the incident histories, the config-validation invariant, the known gaps, the\n// worked example. That is argument, not data, and a renderer would mangle it.\n//\n// The section is spliced BETWEEN two literal markers, the pattern renderL1Doc() already uses for its\n// prose/table interleave, so the prose surrounds the tables instead of being swallowed by them.\n// `pnpm guards:generate` rewrites the block and `l0-tooling-doc.spec.ts` locks it byte-for-byte.\n//\n// EVERY COMMAND PRINTED HERE COMES FROM A CONSTANT, never a retyped literal: the L0 allowlist matches\n// WHOLE command strings, so a paraphrased cure in a doc is an unrunnable cure. The spec re-asserts that\n// by running isAllowed() over every command this renderer prints.\n// ---------------------------------------------------------------------------\n\n/** Opening marker of the generated block, as it appears in guards/L0-tooling.md. */\nexport const L0_DOC_BEGIN = '<!-- BEGIN GENERATED — L0ToolingDoc.render() in ai-hook-rules/src/core/l0-tooling-doc.ts; run `pnpm guards:generate` -->';\n\n/** Closing marker. Everything between the two is machine-owned; everything outside is prose. */\nexport const L0_DOC_END = '<!-- END GENERATED — hand-written prose resumes here -->';\n\n/**\n * The generated section of guards/L0-tooling.md, and the splice that puts it there.\n *\n * A class rather than a family of module functions (see l1-doc.ts, which predates the rule): the\n * renderer, the extractor and the splicer are one unit, and the spec drives all three.\n */\nexport class L0ToolingDoc {\n /** The whole generated block, WITHOUT the markers — those belong to the file, not to the renderer. */\n render(): string {\n return [\n ...this.preamble(),\n ...this.faultTable(),\n ...this.fixTable(),\n ...this.matrixTable(),\n ...this.allowlistTable(),\n ...this.managedSurface(),\n ...this.auditLine(),\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 whole arrangement exists to end.\n */\n extract(doc: string): string {\n const opens = doc.split(L0_DOC_BEGIN).length - 1;\n const closes = doc.split(L0_DOC_END).length - 1;\n if (opens !== 1 || closes !== 1) {\n throw new Error(`guards/L0-tooling.md must carry exactly one BEGIN/END marker pair, found ${String(opens)}/${String(closes)}`);\n }\n const afterBegin = doc.slice(doc.indexOf(L0_DOC_BEGIN) + L0_DOC_BEGIN.length);\n return afterBegin.slice(0, afterBegin.indexOf(L0_DOC_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(L0_DOC_BEGIN) + L0_DOC_BEGIN.length);\n const tail = doc.slice(doc.indexOf(L0_DOC_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 /** A markdown cell: a literal `|` inside a value would end the column. */\n private cell(value: string): string {\n return value.split('|').join('\\\\|');\n }\n\n private preamble(): string[] {\n return [\n '> **GENERATED — do not hand-edit between the markers.** Rendered by `L0ToolingDoc.render()`',\n '> (`ai-hook-rules/src/core/l0-tooling-doc.ts`) from `L0_FAULTS`, `L0_ALLOWLIST`, the managed-surface',\n '> constants and `SHIM_LOG_FIELDS` — the same arrays the guard consults. `pnpm guards:generate`',\n '> rewrites it; `l0-tooling-doc.spec.ts` locks it byte-for-byte. The prose outside the markers is',\n '> hand-written and stays that way.',\n '',\n ];\n }\n\n private faultTable(): string[] {\n return [\n '### The faults',\n '',\n '| code | guard name | fault | detected by | enforced in |',\n '|---|---|---|---|---|',\n ...L0_FAULTS.map((f: L0Fault): string =>\n `| \\`${f.code}\\` | \\`${L0_FAULT_NAMES[f.code as L0FaultCode]}\\` | ${this.cell(f.name)} | ${f.detectedBy} | ${f.enforcedIn} |`),\n '',\n `First match wins. \\`${L0_SH_FAULT_CODES.join('`/`')}\\` are decided in POSIX \\`sh\\` inside the committed shim, BEFORE`,\n `the guard bin runs — a stale, missing or broken validator cannot be trusted to validate itself.`,\n `\\`${L0_JS_FAULT_CODES.join('`/`')}\\` are decided inside the bin, in JS.`,\n '',\n ];\n }\n\n /**\n * The cures, one row per option. LITERAL commands only, rendered from `L0_FAULTS[].cures` — the same\n * array `webpieces.guard-matrix.md` renders its Fix sections from, so the two can never prescribe\n * different commands for one fault.\n */\n private fixTable(): string[] {\n const rows: string[] = [];\n for (const fault of L0_FAULTS) {\n fault.cures.forEach((cure: L0Cure, i: number): void => {\n const literal = cure.isCommand() ? `\\`${cure.call.command}\\`` : `edit \\`${cure.mention}\\` yourself`;\n const option = `${String(i + 1)}${cure.preferred ? ' (preferred)' : ''}`;\n rows.push(`| \\`${fault.code}\\` | ${option} | ${this.cell(literal)} | ${this.cell(cure.discriminator)} |`);\n });\n }\n return [\n '### The fix, per fault — type the option EXACTLY as written, and run nothing else on that line',\n '',\n '| fault | option | run EXACTLY | pick this when |',\n '|---|---|---|---|',\n ...rows,\n '',\n ];\n }\n\n private matrixTable(): string[] {\n return [\n '### The matrix — three rows, and the fault only picks the MESSAGE',\n '',\n `| row | fault | on the allowlist? | outcome | logged as |`,\n '|---|---|---|---|---|',\n `| ${L0_ROW_HANDED_DOWN} | none | — | hand down to the next guard layer | \\`layer=${L0_LAYER} row=${L0_ROW_HANDED_DOWN}\\` |`,\n `| ${L0_ROW_ALLOWLISTED} | any | yes | PASS or ALLOW (see the entry) | \\`layer=${L0_LAYER} row=${L0_ROW_ALLOWLISTED}\\` |`,\n `| ${L0_ROW_BLOCKED} | any | no | BLOCK — **only the message varies by fault** | \\`layer=${L0_LAYER} row=${L0_ROW_BLOCKED}\\` |`,\n '',\n 'The tool is not a dimension either: \"any Read\" is an allowlist ENTRY, not a tool check. Those are',\n 'the same coordinates every L0 deny opens with, so a deny, a log line and this table join by eye.',\n '',\n ];\n }\n\n private allowlistTable(): string[] {\n return [\n '### The allowlist — ONE list, consulted identically by every fault',\n '',\n '| # | allowed | outcome | bypasses L1 on a HEALTHY tree? |',\n '|---|---|---|---|',\n ...L0_ALLOWLIST.map((e: L0AllowEntry, i: number): string =>\n `| ${String(i + 1)} | ${this.cell(e.label)} | ${e.kind.toUpperCase()} | ${e.cure ? 'yes — it REPAIRS the tooling' : 'no — it repairs nothing, so L1 still judges it'} |`),\n '',\n '- **PASS** — L0 has no objection; the call falls THROUGH so downstream guards still judge it.',\n '- **ALLOW** — terminal; bypasses everything, because a cure must stay reachable even when a',\n ' downstream guard would block it.',\n '',\n 'Every Bash entry is anchored to the WHOLE command. A leading `cd <dir> &&`, a trailing `2>&1` and a',\n 'pipe into `tail`/`head` are tolerated; nothing else. Appending `&& git status` makes it a DIFFERENT',\n 'command and it is rejected again — that is not the guard refusing its own cure.',\n '',\n '`git merge` and a **bare** `git pull` are both deliberately absent — see \"The git-sync split\"',\n 'below for why the one safe pull spelling is on the list and the bare one is not. Main is merged',\n 'ONLY through the 3-point fork merge (`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a',\n 'PR is already open).',\n '',\n ];\n }\n\n /**\n * Fault `S`'s subject: the THREE managed things, and the two registrations rendered from\n * `shimCommand()` — which is what makes \"they are ABSOLUTE\" a fact this doc cannot get wrong.\n */\n private managedSurface(): string[] {\n return [\n '### The managed hook surface — what fault `S` compares (THREE things, one set)',\n '',\n '| # | surface |',\n '|---|---|',\n `| 1 | \\`${SHIM_SURFACE}\\` |`,\n `| 2 | ${this.cell(REGISTRATION_SURFACE)} |`,\n `| 3 | ${this.cell(ENV_SURFACE)} |`,\n '',\n 'The registration is TWO PreToolUse entries, and both are ABSOLUTE — they resolve from any cwd:',\n '',\n '```',\n shimCommand(GUARDS_BIN),\n shimCommand(RULES_BIN),\n '```',\n '',\n `\\`${UPGRADE_SHIM_CMD}\\` repairs all three. \\`${RESTORE_SHIM_CMD}\\``,\n `repairs \\`${SHIM_SURFACE}\\` and nothing else, so it is the fallback for an installed release too old`,\n 'to carry the first.',\n '',\n ];\n }\n\n /**\n * The audit line, rendered from `SHIM_LOG_FIELDS` + `SHIM_LOG_VERDICTS`. The optional field prints\n * in brackets because that is exactly what it is: `bin=` appears only when it differs from `shim=`.\n */\n private auditLine(): string[] {\n const shape = SHIM_LOG_FIELDS.map((f: ShimLogField): string => (f.optional ? `[${f.label}]` : f.label)).join(' ');\n return [\n '### The L0 audit line — one tab-separated line per tool call',\n '',\n '```',\n shape,\n '```',\n '',\n '| # | field | means |',\n '|---|---|---|',\n ...SHIM_LOG_FIELDS.map((f: ShimLogField, i: number): string =>\n `| ${String(i + 1)} | \\`${this.cell(f.label)}\\` | ${this.cell(f.means)} |`),\n '',\n '| verdict | means |',\n '|---|---|',\n ...SHIM_LOG_VERDICTS.map((v: ShimLogVerdict): string => `| \\`${v.label}\\` | ${this.cell(v.means)} |`),\n '',\n ...this.logPaths(),\n ];\n }\n\n private logPaths(): string[] {\n const stream = `${LOGS_STATE_DIR}/${L0_SHIM_STREAM}/<session>-<agent|coordinator>-<binName>.log`;\n return [\n 'It lands in the log directory of the tree the CALL was made in, centralized under the primary clone',\n 'so that removing a worktree does not take its audit trail with it:',\n '',\n '```',\n `<primary>/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/<tree>/${stream}`,\n `<primary>/${WEBPIECES_TMP_DIR}/${stream} # from the primary clone itself`,\n '```',\n '',\n `The binary stamps the same \\`layer=\\`/\\`row=\\`/\\`fault=\\` fields onto its OWN streams —`,\n `\\`${L1_LOCATION_STREAM}/\\`, \\`${L2_DECISIONS_STREAM}/\\`, \\`${CALLS_STREAM}/\\` and \\`${REJECTIONS_STREAM}/\\` under the same`,\n `\\`${LOGS_STATE_DIR}/\\` — so one grep spans the whole trail. A \\`${CONFIG_FILENAME}\\` fault (\\`C\\`/\\`Y\\`) is`,\n 'therefore visible there and never on an `L0-shim` line, which only ever carries the `sh`-side codes.',\n '',\n ];\n }\n}\n"]}
|
|
@@ -28,9 +28,14 @@ const FIX_HINT = new fix_hint_1.FixHint('`git merge` / `git rebase` are never ru
|
|
|
28
28
|
+ '\n'
|
|
29
29
|
// The "do not run it, ask the human, tell them to push back" instruction is NOT repeated here: the
|
|
30
30
|
// violation line the AI reads first already says it. This paragraph's job is only the pointer.
|
|
31
|
+
// The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would
|
|
32
|
+
// split the assertion's substring across two chunks. The retired wording ("a branch you do not own")
|
|
33
|
+
// was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how
|
|
34
|
+
// this hint kept teaching the removed rule after the doc it links had replaced it.
|
|
31
35
|
+ 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\n'
|
|
32
|
-
+ 'flow (incl. worktrees), the fork-point invariant, why you must
|
|
33
|
-
+ '
|
|
36
|
+
+ 'flow (incl. worktrees), the fork-point invariant, why you must\n'
|
|
37
|
+
+ 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\n'
|
|
38
|
+
+ 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\n'
|
|
34
39
|
// NOT "add that info to memory": "that info" was the whole git-workflow doc, which is REGENERATED
|
|
35
40
|
// per repo and per version — memorizing it guarantees a stale recall in the next session. Only the
|
|
36
41
|
// invariant is stable enough to persist; the doc must be re-read, never remembered.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder, SyncFlowGuidance, writeTemplate } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must never sync a branch another\\n'\n + 'session owns, and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main` — but ONLY in the primary\n // clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out at\n // <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n if (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder, SyncFlowGuidance, writeTemplate } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n // The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would\n // split the assertion's substring across two chunks. The retired wording (\"a branch you do not own\")\n // was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how\n // this hint kept teaching the removed rule after the doc it links had replaced it.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must\\n'\n + 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\\n'\n + 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main` — but ONLY in the primary\n // clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out at\n // <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n if (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\n );\n }\n}\n"]}
|