@webpieces/ai-hook-rules 0.4.645 → 0.4.646
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/core/decision-log.js +7 -0
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l2-doc.js +34 -1
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-matrix-doc.d.ts +23 -0
- package/src/core/l2-matrix-doc.js +72 -0
- package/src/core/l2-matrix-doc.js.map +1 -0
- package/src/core/l2-rows.d.ts +62 -1
- package/src/core/l2-rows.js +120 -16
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/matrix-cures.d.ts +12 -0
- package/src/core/matrix-cures.js +56 -0
- package/src/core/matrix-cures.js.map +1 -0
- package/src/core/rules/feature-branch-guard.js +4 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +1 -14
- package/src/core/rules/merged-branch-bash-guard.js +9 -89
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/read-stale-guard.js +4 -1
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/recovery-allowlist.d.ts +18 -0
- package/src/core/rules/recovery-allowlist.js +129 -0
- package/src/core/rules/recovery-allowlist.js.map +1 -0
- package/src/core/rules/stale-main-bash-guard.d.ts +41 -30
- package/src/core/rules/stale-main-bash-guard.js +90 -115
- package/src/core/rules/stale-main-bash-guard.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.646",
|
|
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.646"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
package/src/core/decision-log.js
CHANGED
|
@@ -15,6 +15,7 @@ const l0_fault_codes_1 = require("./l0-fault-codes");
|
|
|
15
15
|
const to_error_1 = require("./to-error");
|
|
16
16
|
const log_stream_1 = require("./log-stream");
|
|
17
17
|
const l2_rows_1 = require("./l2-rows");
|
|
18
|
+
const matrix_cures_1 = require("./matrix-cures");
|
|
18
19
|
// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is
|
|
19
20
|
// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This
|
|
20
21
|
// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES
|
|
@@ -202,6 +203,12 @@ function appendDecision(root, streamDir, decision) {
|
|
|
202
203
|
// APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault
|
|
203
204
|
// this was, or `-`.
|
|
204
205
|
`fault=${decision.fault}`,
|
|
206
|
+
// WHAT THE AGENT WAS TOLD TO DO — looked up from the row above, never passed in, so it is
|
|
207
|
+
// by construction the same literal the generated matrix prints for that row (see
|
|
208
|
+
// matrix-cures.ts). `row=` says which row judged the call; this says what that row
|
|
209
|
+
// prescribed, which is what makes the trail auditable against the doc without opening it.
|
|
210
|
+
// APPEND-ONLY, like every field before it.
|
|
211
|
+
`cure=${oneLine((0, matrix_cures_1.cureForMatrix)(decision.matrix.layer, decision.matrix.row))}`,
|
|
205
212
|
].join('\t') + '\n';
|
|
206
213
|
fs.appendFileSync(logPath, line);
|
|
207
214
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\nimport { l2RowForReason } from './l2-rows';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `L2_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation.\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. EVERY layer now cites a row: L0 through its two decision-matrix rows, L1 through\n * L1_ROWS, and L2 through L2_ROWS (see `matrixL2Row`). This used to say `'-'` was for \"a layer with no\n * row array YET (L2 is the un-converted one)\" — L2 is converted, and a comment describing a state the\n * code left behind is exactly the kind of doc a reader trusts and should not.\n *\n * `'-'` survives for ONE case, and it is a real one: an L2 reason that no row claims. See matrixL2Row.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n *\n * L0 NOW CITES ITS ROW TOO. It used to be `'-'` on the grounds that \"L0's faults are a table of letters\n * rather than numbered rows\" — but L0 has BOTH: the letter says WHICH fault, and the numbered decision\n * matrix (`renderGuardMatrixDoc`) says which of its three rows was taken. There is one ALLOW row and one\n * BLOCK row, so the pair `row=` + `fault=` pins the decision exactly, and the deny an agent reads now\n * carries the identical pair. That is the join: one grep spans the deny, the log line and the doc.\n * Two constants, not one `MATRIX_L0`, because the two call sites are a cure-bypass ALLOW and an L0\n * BLOCK — one token covering both is what made the row unciteable in the first place.\n */\nexport const MATRIX_L0_ALLOW = new MatrixRef('L0', L0_ROW_ALLOWLISTED);\nexport const MATRIX_L0_BLOCK = new MatrixRef('L0', L0_ROW_BLOCKED);\n\n/**\n * The L2 reference for one decision, with the row DERIVED FROM THE REASON.\n *\n * There is no `MATRIX_L2` constant any more, and its absence is the point: a single shared instance\n * meant every L2 line in the repo carried `row=-`, which reads as \"L2 has no rows\" rather than \"this\n * decision was not classified\". Deleting it makes every construction site name a reason, and the reason\n * is the only thing a call site has that identifies which row it is an instance of.\n *\n * `l2RowForReason` returns null for a reason no row claims, and that renders as `'-'` — visible in the\n * log, not silently absorbed into a default row. l2-matrix.spec.ts reads the four guard sources and\n * fails the build if any reason literal in them is unmapped, so `-` should never appear in practice;\n * when it does, it is a genuine hole in the table and the log says so.\n */\n// webpieces-disable no-function-outside-class -- a named constructor for MatrixRef beside it, in this module of module-scope writers\nexport function matrixL2Row(reason: string): MatrixRef {\n const row = l2RowForReason(reason);\n return new MatrixRef('L2', row === null ? '-' : String(row));\n}\n\n/**\n * The L2 stream, with NO row — for the two kinds of line that genuinely are not an instance of a row.\n *\n * 1. The runner's AGGREGATE bash lines (\"no bash-guard block\" / \"bash-guard block\"). They summarise\n * the whole guard set's answer for one command, not one row's verdict; the per-guard lines that\n * DO cite rows are written alongside them by the guards themselves.\n * 2. `whole-repo-build-guard`, which is not a branch-state policy at all — it is the experimental\n * home-config guard, has no webpieces.config.json entry, and shares this stream only because the\n * stream is \"bash decisions\", not \"L2 rows\".\n *\n * A NAMED constant rather than an inline `new MatrixRef('L2', '-')`, so `grep MATRIX_L2_UNROWED` lists\n * every uncited line and the list stays short and arguable. It is deliberately NOT called `MATRIX_L2`:\n * the old name was used by everything and made \"L2 has no rows\" indistinguishable from \"this line is\n * not a row\".\n */\nexport const MATRIX_L2_UNROWED = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one\n // had no objection to, and neither can see the other's answer. Measured under the\n // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded\n // here three times as `verdict=ALLOW`. The old field name promised an outcome it\n // structurally cannot know, so the name stayed even though L-1 is gone: the two\n // surviving hooks still run in parallel and still cannot see each other.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, readMainSyncStatus, MainSyncStatus, RepoRootFinder, claudeEnv } from '@webpieces/rules-config';\nimport { L1_LOCATION_STREAM, L2_DECISIONS_STREAM, CALLS_STREAM } from './log-streams';\n\nimport { L0_FAULT_NONE, L0_ROW_ALLOWLISTED, L0_ROW_BLOCKED } from './l0-fault-codes';\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\nimport { l2RowForReason } from './l2-rows';\nimport { cureForMatrix } from './matrix-cures';\n\n// The SYNC decision log — what the synchronous hook DID on each invocation and WHY. Its companion is\n// the ASYNC log (the `async-refresh/` stream, written by the detached refresher in main-sync-log.ts). This\n// one records EVERY guard decision — allow, block, config-bypass, and the fail-open cases — and CITES\n// the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is\n// traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —\n// the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\n/**\n * THE ACTION CODEBOOK, as a type. These are the five actions GUARD_MATRIX.md numbers 1-5, and they\n * are the vocabulary EVERY layer reports in — so one grep spans L-1, L0, L1 and L2.\n *\n * The three distinctions this exists to make, none of which `'ALLOW' | 'BLOCK'` could:\n *\n * ALLOW no objection — the call was HANDED DOWN to the next layer. A layer saying ALLOW\n * is NOT saying the call ran: the layer below it, or the OTHER parallel hook, may\n * still deny. This is L1's `ACT_DOWN`.\n * ALLOW_EXEMPT out of scope by construction — allowed, and evaluation STOPS here. L1's\n * `ACT_EXEMPT`.\n * ALLOW_FAIL_OPEN state could not be established, so nothing was judged. Keeping this distinct\n * from ALLOW is the entire point of the type: a fail-open allow and a real allow\n * that look identical make it impossible to tell whether the guards are protecting\n * anything or quietly abstaining. It used to be a `' (fail-open)'` SUBSTRING on the\n * reason field, which is exactly why the abstentions were never countable.\n * BLOCK_AI_CURE blocked, and the printed cure is a command the AI can run itself.\n * BLOCK_HUMAN blocked, and it needs a human decision — or a delegation (spawn a subagent) that\n * the blocked agent cannot perform for itself.\n *\n * Hard cut, per CLAUDE.md: `'BLOCK'` is GONE rather than aliased, so every construction site fails to\n * compile and has to say which kind of block it is. Before, that question had exactly one wrong\n * answer available — silence.\n */\nexport type Verdict = 'ALLOW' | 'ALLOW_EXEMPT' | 'ALLOW_FAIL_OPEN' | 'BLOCK_AI_CURE' | 'BLOCK_HUMAN';\n\n/**\n * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.\n *\n * `row` is the row NUMBER from the layer's row array (`L1_ROWS[i].num`, `L2_ROWS[i].num`) — the\n * same number the generated doc prints, because the doc is rendered from that same array. So a log\n * line joins to its matrix row BY NUMBER, and checking observed behaviour against the documented use\n * cases becomes a lookup rather than an investigation.\n */\nexport class MatrixRef {\n constructor(readonly layer: string, readonly row: string) {}\n}\n\n/**\n * The layer tokens. EVERY layer now cites a row: L0 through its two decision-matrix rows, L1 through\n * L1_ROWS, and L2 through L2_ROWS (see `matrixL2Row`). This used to say `'-'` was for \"a layer with no\n * row array YET (L2 is the un-converted one)\" — L2 is converted, and a comment describing a state the\n * code left behind is exactly the kind of doc a reader trusts and should not.\n *\n * `'-'` survives for ONE case, and it is a real one: an L2 reason that no row claims. See matrixL2Row.\n *\n * These are REQUIRED at the constructor, not defaulted: a defaulted `MatrixRef` would make the\n * uncited case reachable by doing nothing and impossible to grep — the same defect this file's own\n * docblock argues against for `'BLOCK'`, where silence was the one wrong answer available.\n *\n * L0 NOW CITES ITS ROW TOO. It used to be `'-'` on the grounds that \"L0's faults are a table of letters\n * rather than numbered rows\" — but L0 has BOTH: the letter says WHICH fault, and the numbered decision\n * matrix (`renderGuardMatrixDoc`) says which of its three rows was taken. There is one ALLOW row and one\n * BLOCK row, so the pair `row=` + `fault=` pins the decision exactly, and the deny an agent reads now\n * carries the identical pair. That is the join: one grep spans the deny, the log line and the doc.\n * Two constants, not one `MATRIX_L0`, because the two call sites are a cure-bypass ALLOW and an L0\n * BLOCK — one token covering both is what made the row unciteable in the first place.\n */\nexport const MATRIX_L0_ALLOW = new MatrixRef('L0', L0_ROW_ALLOWLISTED);\nexport const MATRIX_L0_BLOCK = new MatrixRef('L0', L0_ROW_BLOCKED);\n\n/**\n * The L2 reference for one decision, with the row DERIVED FROM THE REASON.\n *\n * There is no `MATRIX_L2` constant any more, and its absence is the point: a single shared instance\n * meant every L2 line in the repo carried `row=-`, which reads as \"L2 has no rows\" rather than \"this\n * decision was not classified\". Deleting it makes every construction site name a reason, and the reason\n * is the only thing a call site has that identifies which row it is an instance of.\n *\n * `l2RowForReason` returns null for a reason no row claims, and that renders as `'-'` — visible in the\n * log, not silently absorbed into a default row. l2-matrix.spec.ts reads the four guard sources and\n * fails the build if any reason literal in them is unmapped, so `-` should never appear in practice;\n * when it does, it is a genuine hole in the table and the log says so.\n */\n// webpieces-disable no-function-outside-class -- a named constructor for MatrixRef beside it, in this module of module-scope writers\nexport function matrixL2Row(reason: string): MatrixRef {\n const row = l2RowForReason(reason);\n return new MatrixRef('L2', row === null ? '-' : String(row));\n}\n\n/**\n * The L2 stream, with NO row — for the two kinds of line that genuinely are not an instance of a row.\n *\n * 1. The runner's AGGREGATE bash lines (\"no bash-guard block\" / \"bash-guard block\"). They summarise\n * the whole guard set's answer for one command, not one row's verdict; the per-guard lines that\n * DO cite rows are written alongside them by the guards themselves.\n * 2. `whole-repo-build-guard`, which is not a branch-state policy at all — it is the experimental\n * home-config guard, has no webpieces.config.json entry, and shares this stream only because the\n * stream is \"bash decisions\", not \"L2 rows\".\n *\n * A NAMED constant rather than an inline `new MatrixRef('L2', '-')`, so `grep MATRIX_L2_UNROWED` lists\n * every uncited line and the list stays short and arguable. It is deliberately NOT called `MATRIX_L2`:\n * the old name was used by everything and made \"L2 has no rows\" indistinguishable from \"this line is\n * not a row\".\n */\nexport const MATRIX_L2_UNROWED = new MatrixRef('L2', '-');\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\n// `cache` summarizes the async-written main-sync-status.json that drove a feature-branch-guard\n// decision (branch/merged/conflict/fork + the cache timestamp), or '-' when no cache was consulted\n// (bash guards, on-main, config-bypass).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n cache: string;\n /**\n * The L0 fault this decision IS, in the codebook's letter (core/l0-fault-codes.ts), or `-` for an\n * ordinary rule decision. The `sh` shim has always stamped `fault=` on its own stream; the three\n * JS-side faults (S/C/Y) reached this one with no label at all, so `grep fault=S` found nothing\n * even while an S storm was blocking every call.\n */\n fault: string;\n /** Which layer + row decided this. See MatrixRef — it is what joins a log line to the doc. */\n matrix: MatrixRef;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string, cache: string = '-', fault: string, matrix: MatrixRef) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n this.cache = cache;\n this.fault = fault;\n this.matrix = matrix;\n }\n}\n\n/**\n * Append one tab-separated line per L2 decision to `.webpieces/logs/L2-decisions/<writer>.log`, where\n * <writer> is LogStream's `<sessionId>-<agentId|coordinator>-<hook>` key (a caller that never\n * identified renders as `unknown-coordinator-hook` — there is no un-keyed name).\n * `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or a\n * RepoRootFinder-resolved root at the pre-load config-bypass site — never a raw cwd, so a bypass\n * logged from a subdir never scatters a stray `.webpieces`). Swallows all errors — logging must never\n * block or fail a hook.\n */\n// webpieces-disable no-function-outside-class -- the module-scope writer this log has always been, beside branchForLog/oneLine/rotateLogFile; it must stay callable from a tree too broken to build a DI container\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n appendDecision(root, L2_DECISIONS_STREAM, decision);\n}\n\n/**\n * The L1 stream — `.webpieces/logs/L1-location/<writer>.log`.\n *\n * L1 had NO stream. Its three blocking paths wrote into L2's file under an implementation name\n * (`force-to-root`, `trinary-version-skew`, `cd-must-be-first`), and its NON-blocking outcomes —\n * the exempt row and the three hand-down rows — wrote nothing at all. So \"L1 had no objection\" was\n * unobservable, and \"show me every L1 decision\" had no answer: L1 existed in the trail only as the\n * `root=` / `projectDir=` / `tree=` columns stapled onto somebody else's line.\n *\n * A SIBLING rather than a `base` parameter on logGuardDecision, deliberately: that signature is what\n * the process-wide `logStream` singleton exists to keep unchanged (see LogStream's docblock), and\n * `INVOCATION_LOG_FILE` already establishes the pattern of a second stream owning its own name in\n * this same module.\n */\n// webpieces-disable no-function-outside-class -- sibling of logGuardDecision, same module-scope writer shape and same reason\nexport function logL1Decision(root: string, decision: GuardDecision): void {\n appendDecision(root, L1_LOCATION_STREAM, decision);\n}\n\n// The one appender both streams share. `streamDir` is the LAYER; the writer key inside it is\n// logStream's session/agent/hook, which is what keeps one writer per file.\n// webpieces-disable no-function-outside-class -- the shared body of the two module-scope writers above\nfunction appendDecision(root: string, streamDir: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: a guard decision belongs to the tree it judged. WHO made the call is answered\n // by the filename, which logStream names with session/agent/hook.\n const logsDir = dotWebpieces.logsFile(root, streamDir);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n oneLine(decision.cache),\n // WHICH ROW of WHICH table decided this. The directory already carries the layer, but a\n // line quoted out of its file must still say what judged it — and `row=` is the join key\n // to the generated doc, which is the point of the whole exercise.\n `layer=${decision.matrix.layer}`,\n `row=${decision.matrix.row}`,\n // The tree this decision was actually made against, and what Claude Code told the hook the\n // project was. Appended (never reordered) for the same reason as on the invocation line —\n // see ClaudeEnv: when these two disagree, that disagreement is the bug.\n `root=${root}`,\n `projectDir=${claudeEnv.projectDirForLog()}`,\n // git's name for that tree — `primary`, else the worktree name. Same literal and same\n // derivation as the L0 shim log's `tree=` (shim-audit-log.ts), so one grep spans both\n // streams: L0 carries tree without projectDir, L1 now carries both.\n `tree=${dotWebpieces.worktreeName(root) || 'primary'}`,\n // APPEND-ONLY, same spelling as the invocation line and the L0 shim log: which L0 fault\n // this was, or `-`.\n `fault=${decision.fault}`,\n // WHAT THE AGENT WAS TOLD TO DO — looked up from the row above, never passed in, so it is\n // by construction the same literal the generated matrix prints for that row (see\n // matrix-cures.ts). `row=` says which row judged the call; this says what that row\n // prescribed, which is what makes the trail auditable against the doc without opening it.\n // APPEND-ONLY, like every field before it.\n `cure=${oneLine(cureForMatrix(decision.matrix.layer, decision.matrix.row))}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n/**\n * What the guard SAW on one invocation, captured up front and held until the outcome is known.\n * Data-only (per CLAUDE.md: classes for data, explicit construction).\n */\nexport class GuardInvocation {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n public readonly root: string,\n public readonly timestamp: string,\n public readonly tool: string,\n public readonly target: string,\n public readonly branch: string,\n public readonly sync: string,\n public readonly projectDir: string,\n ) {}\n}\n\n/**\n * The per-INVOCATION stream — `.webpieces/logs/calls/<writer>.log` (see LogStream for the writer\n * key), one line for EVERY guards-hook\n * call (allow or block, bash or file), unlike `L2-decisions/` which records only the calls a\n * rule actually judged. It captures the tool, the command/file, the live git branch, the async-written\n * main-sync-status.json snapshot (branch / merged / fork-point / conflict), and — since this class\n * replaced a bare log-and-forget function — HOW THE CALL ENDED.\n *\n * WHY IT IS TWO CALLS. The line used to be written the moment the hook started, so it could not carry\n * a verdict: the decision had not been made yet. Answering \"what happened to this call?\" therefore\n * meant joining this file against the L2 decision stream BY TIMESTAMP, which is exactly the kind of\n * reconstruction a log exists to make unnecessary. So {@link begin} now only CAPTURES (including the\n * git/cache reads, which must still happen while the hook is running), and {@link finish} — called\n * from the hook's single terminal boundary, emitAllow/emitDeny — writes the whole line once the\n * outcome is known. The two streams stay distinct in purpose: this one is \"every call and how it\n * ended\", the decision log remains \"every judgement and why\".\n *\n * Every error is swallowed: logging must never block or fail a hook.\n */\nexport class InvocationLog {\n private pending: GuardInvocation | null = null;\n\n /**\n * Capture the context of one invocation. `cwd` is the AI's working dir; the repo root that owns\n * `.webpieces` is resolved from it. Writes NOTHING — {@link finish} does that.\n */\n begin(cwd: string, tool: string, target: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const branch = branchForLog(root);\n // The cache is branch-keyed, so the entry to log is the one for the branch we are standing\n // on. 'unknown' (branchForLog's failure value) simply misses and logs 'sync=none'.\n const sync = summarizeSyncStatus(readMainSyncStatus(root, branch));\n this.pending = new GuardInvocation(root, new Date().toISOString(), tool, oneLine(target), branch, sync, claudeEnv.projectDirForLog());\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n /**\n * Write the captured line, now stamped with the outcome. A no-op when nothing was captured (the\n * 'rules' hook, or a terminal boundary reached before begin()), and it clears the pending entry so\n * a second emit cannot double-log.\n *\n * `rule` is the rule that blocked, or '-' when there is none; `fault` is the L0 fault code when this\n * call ended on one (S/C/Y — the JS-side faults), else '-'. FIELD ORDER IS APPEND-ONLY: the five\n * original fields keep their positions (cleanup automation mines this file), and `guards=` /\n * `rule=` / … / `fault=` are added at the end.\n */\n finish(verdict: Verdict, rule: string, fault: string = L0_FAULT_NONE): void {\n const invocation = this.pending;\n this.pending = null;\n if (invocation === null) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const logsDir = dotWebpieces.logsFile(invocation.root, CALLS_STREAM);\n fs.mkdirSync(logsDir, { recursive: true });\n const logPath = path.join(logsDir, logStream.writerFile('.log'));\n rotateLogFile(logPath, path.join(logsDir, logStream.writerFile('.1.log')));\n\n const line = [\n `[${invocation.timestamp}]`,\n invocation.tool,\n invocation.target,\n `branch=${invocation.branch}`,\n invocation.sync,\n // `guards=`, NOT `verdict=`. This hook can only report on ITSELF. Claude Code runs all\n // its PreToolUse hooks IN PARALLEL, so another hook process may deny a call this one\n // had no objection to, and neither can see the other's answer. Measured under the\n // RETIRED three-hook form: `cd <repo>/packages && ls` was DENIED by L-1 and recorded\n // here three times as `verdict=ALLOW`. The old field name promised an outcome it\n // structurally cannot know, so the name stayed even though L-1 is gone: the two\n // surviving hooks still run in parallel and still cannot see each other.\n `guards=${verdict}`,\n `rule=${oneLine(rule) || '-'}`,\n // The tree the guard ACTED in, next to what Claude Code said the project was. Both, on\n // every line, because the diagnostic value is entirely in comparing them — see\n // ClaudeEnv for the open question this field exists to settle empirically.\n `root=${invocation.root}`,\n `projectDir=${invocation.projectDir}`,\n // See logGuardDecision: the short tree label, so `tree=primary` with a matching\n // projectDir reads as healthy at a glance and `tree=<worktree>` beside a projectDir\n // pointing at the primary is the straddle, without diffing two absolute paths.\n `tree=${dotWebpieces.worktreeName(invocation.root) || 'primary'}`,\n // WHICH L0 fault ended this call, in the same letters and the same field name the L0 sh\n // shim uses (the `L0-shim/` stream) — so ONE grep spans the whole trail.\n `fault=${fault}`,\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n\n// Process-wide instance: one hook process handles exactly one tool call, so a single pending entry is\n// the whole state there is. Module-scope (rather than DI) because the terminal boundary that flushes\n// it — emitAllow/emitDeny — is itself module-scope protocol code with no container in reach.\nexport const invocationLog = new InvocationLog();\n\n// One-field summary of main-sync-status.json for the invocation log: the branch the cache is FOR,\n// whether it is already merged (and its PR), fork-point presence, and conflict state — the signals a\n// cleanup step keys off. 'sync=none' when the cache has not been written yet (first call of a session).\nfunction summarizeSyncStatus(status: MainSyncStatus | null): string {\n if (status === null) return 'sync=none';\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `sync=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
|
package/src/core/l2-doc.js
CHANGED
|
@@ -26,6 +26,10 @@ function tableRow(row) {
|
|
|
26
26
|
function notDoneRow(entry) {
|
|
27
27
|
return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;
|
|
28
28
|
}
|
|
29
|
+
// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module
|
|
30
|
+
function useCaseRow(useCase) {
|
|
31
|
+
return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;
|
|
32
|
+
}
|
|
29
33
|
/**
|
|
30
34
|
* Render guards/L2-branch-state.md.
|
|
31
35
|
*
|
|
@@ -37,6 +41,7 @@ function renderL2Doc() {
|
|
|
37
41
|
return [
|
|
38
42
|
...renderHead(),
|
|
39
43
|
...renderTable(),
|
|
44
|
+
...renderUseCases(),
|
|
40
45
|
...renderNotes(),
|
|
41
46
|
...renderTail(),
|
|
42
47
|
].join('\n');
|
|
@@ -161,8 +166,36 @@ function renderTable() {
|
|
|
161
166
|
'',
|
|
162
167
|
];
|
|
163
168
|
}
|
|
164
|
-
//
|
|
169
|
+
// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.
|
|
165
170
|
// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module
|
|
171
|
+
function renderUseCases() {
|
|
172
|
+
return [
|
|
173
|
+
'## L2 use cases',
|
|
174
|
+
'',
|
|
175
|
+
'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',
|
|
176
|
+
'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',
|
|
177
|
+
'longer exists and a row cannot quietly acquire behaviour nothing documents.',
|
|
178
|
+
'',
|
|
179
|
+
'**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',
|
|
180
|
+
'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',
|
|
181
|
+
'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',
|
|
182
|
+
'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',
|
|
183
|
+
'under. So a case whose row is wrong fails the build rather than misinforming a reader.',
|
|
184
|
+
'',
|
|
185
|
+
'| # | what you SEE (exact symptom) | state | verdict | Fix |',
|
|
186
|
+
'|---|---|---|---|---|',
|
|
187
|
+
...(0, l2_rows_1.allL2UseCases)().map(useCaseRow),
|
|
188
|
+
'',
|
|
189
|
+
'The write-on-main case under row 5 is the one to read beside "Not done": it is a real incident from',
|
|
190
|
+
'another repo on this toolchain, where `npx expo install` on `main` modified two tracked files and no',
|
|
191
|
+
'guard fired. It is filed under row 5 because row 5 is the row that SHOULD judge it — the table states',
|
|
192
|
+
'the policy, and "Not done" states how far the code has got. That is the arrangement that keeps a gap',
|
|
193
|
+
'visible instead of letting the doc quietly narrow itself to whatever the code happens to do.',
|
|
194
|
+
'',
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
// How the log joins to this table, and the skip list. Prose plus the reason→row contract.
|
|
198
|
+
// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module
|
|
166
199
|
function renderNotes() {
|
|
167
200
|
return [
|
|
168
201
|
'## How a log line joins to a row',
|
package/src/core/l2-doc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither. And the two Bash guards are not De Morgan duals — they differ in **polarity**',\n '(merged is default-DENY + allowlist, stale-main is default-ALLOW + blocklist), in **quantifier**',\n '(`every` segment must pass vs `some` segment triggers) and on the **empty command** (denies vs',\n 'allows). `pnpm build` is denied by one and allowed by the other. No single parameterised function',\n 'serves both, which is why the classes stay four while the switch became one.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere. `R` is judged separately in exactly one place — rows 6/7, on `main`.**',\n '',\n 'A Read names exactly one file, so the guard can evaluate it precisely. A Bash command is opaque, so',\n 'it gets the conservative answer. Reading a CURRENT `main` is fine; the problem is that `main` is',\n 'almost always behind.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and writes on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 is the only place the dirty argument ever had teeth, because there the cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. Even there, `git stash` → `git pull` →',\n '`git stash pop` works. **So there is no dirty row anywhere** — see \"Not done\" for where the code',\n 'still disagrees.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh pr view\\\\|list\\\\|status\\\\|checks` |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `staleContentRead`, `bareCheckoutOfMain` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2UseCase, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE, allL2UseCases } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction useCaseRow(useCase: L2UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither. And the two Bash guards are not De Morgan duals — they differ in **polarity**',\n '(merged is default-DENY + allowlist, stale-main is default-ALLOW + blocklist), in **quantifier**',\n '(`every` segment must pass vs `some` segment triggers) and on the **empty command** (denies vs',\n 'allows). `pnpm build` is denied by one and allowed by the other. No single parameterised function',\n 'serves both, which is why the classes stay four while the switch became one.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere. `R` is judged separately in exactly one place — rows 6/7, on `main`.**',\n '',\n 'A Read names exactly one file, so the guard can evaluate it precisely. A Bash command is opaque, so',\n 'it gets the conservative answer. Reading a CURRENT `main` is fine; the problem is that `main` is',\n 'almost always behind.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and writes on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 is the only place the dirty argument ever had teeth, because there the cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. Even there, `git stash` → `git pull` →',\n '`git stash pop` works. **So there is no dirty row anywhere** — see \"Not done\" for where the code',\n 'still disagrees.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L2 use cases',\n '',\n 'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',\n 'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',\n 'longer exists and a row cannot quietly acquire behaviour nothing documents.',\n '',\n '**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',\n 'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',\n 'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',\n 'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',\n 'under. So a case whose row is wrong fails the build rather than misinforming a reader.',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL2UseCases().map(useCaseRow),\n '',\n 'The write-on-main case under row 5 is the one to read beside \"Not done\": it is a real incident from',\n 'another repo on this toolchain, where `npx expo install` on `main` modified two tracked files and no',\n 'guard fired. It is filed under row 5 because row 5 is the row that SHOULD judge it — the table states',\n 'the policy, and \"Not done\" states how far the code has got. That is the arrangement that keeps a gap',\n 'visible instead of letting the doc quietly narrow itself to whatever the code happens to do.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh pr view\\\\|list\\\\|status\\\\|checks` |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `staleContentRead`, `bareCheckoutOfMain` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The delivered copy of guards/L2-branch-state.md, as a rules-config template name. */
|
|
2
|
+
export declare const BRANCH_STATE_MATRIX_DOC = "webpieces.branch-state-matrix.md";
|
|
3
|
+
/**
|
|
4
|
+
* Drop the L2 matrix where the AI can read it, and return its absolute path ('' if it could not be
|
|
5
|
+
* written).
|
|
6
|
+
*
|
|
7
|
+
* Best-effort by design, exactly as `writeGuardMatrixDoc` is: this runs on a path that is already
|
|
8
|
+
* denying the call, and a missing template (a `@webpieces/rules-config` older than this package) must
|
|
9
|
+
* cost the reader a pointer, never turn their deny into a stack trace.
|
|
10
|
+
*/
|
|
11
|
+
export declare function writeBranchStateMatrixDoc(workspaceRoot: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The `READ <path>` pointer appended to an L2 deny, or '' when the doc could not be written.
|
|
14
|
+
*
|
|
15
|
+
* Opens with a NEWLINE and carries no quotes or backslashes, for the same two reasons L0's does: the
|
|
16
|
+
* deny renders in the house format (a header, a `[guard-name]` block, `Fix Option N:` lines) so a
|
|
17
|
+
* pointer glued onto the last line is the one place that shape breaks; and the text is interpolated
|
|
18
|
+
* into a JSON payload, where a quote would corrupt the decision rather than merely the prose.
|
|
19
|
+
*
|
|
20
|
+
* It names the ROW as well as the path. A bare "read this doc" is a page; a row number is the two
|
|
21
|
+
* lines that explain this exact verdict, and the log line for this call carries the same number.
|
|
22
|
+
*/
|
|
23
|
+
export declare function branchStateMatrixPointer(docPath: string, row: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BRANCH_STATE_MATRIX_DOC = void 0;
|
|
4
|
+
exports.writeBranchStateMatrixDoc = writeBranchStateMatrixDoc;
|
|
5
|
+
exports.branchStateMatrixPointer = branchStateMatrixPointer;
|
|
6
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
7
|
+
const to_error_1 = require("./to-error");
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The L2 matrix, DELIVERED — dropped where a blocked agent can read it, and named in the deny.
|
|
10
|
+
//
|
|
11
|
+
// L0 has done this since the config-missing fault: `writeGuardMatrixDoc` drops
|
|
12
|
+
// webpieces.guard-matrix.md into `.webpieces/instruct-ai/` on a BLOCK, and `guardMatrixPointer` puts
|
|
13
|
+
// its ABSOLUTE path in the deny text. L2 had the better table and none of the delivery: its rows,
|
|
14
|
+
// their cures and their use cases lived in `guards/L2-branch-state.md`, a path an agent has no reason
|
|
15
|
+
// to know and every reason not to find — while the deny it was actually reading named no doc at all.
|
|
16
|
+
//
|
|
17
|
+
// So this is L0's mechanism, applied to L2. Same three pieces, same reasons:
|
|
18
|
+
//
|
|
19
|
+
// the DOC a rules-config TEMPLATE, byte-locked to renderL2Doc() by l2-matrix.spec.ts, so the
|
|
20
|
+
// delivered copy cannot describe a table the guards no longer have.
|
|
21
|
+
// the WRITE lazy and best-effort, ONLY on a block. An agent that is not blocked does not need it,
|
|
22
|
+
// and a failed write must degrade the deny, never replace it with a crash.
|
|
23
|
+
// the POINTER an ABSOLUTE path. A relative one is unusable: the shell's cwd is not the governed
|
|
24
|
+
// root and cannot be assumed (see EffectiveTree), which is the same reason L1's
|
|
25
|
+
// messages name `<root>` explicitly rather than telling the agent to `cd` first.
|
|
26
|
+
//
|
|
27
|
+
// WHY THE POINTER AND THE `cure=` LOG FIELD SHIP TOGETHER (matrix-cures.ts is the other half): the
|
|
28
|
+
// deny hands the agent the row's cure and the path to the table that cure came from; the log records
|
|
29
|
+
// the row AND that same cure literal. So "what did the guard tell it to do" is answerable from the
|
|
30
|
+
// trail alone, and comparing the two is how you find a row whose use cases need updating — which is
|
|
31
|
+
// the whole point of keeping use cases as row data.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/** The delivered copy of guards/L2-branch-state.md, as a rules-config template name. */
|
|
34
|
+
exports.BRANCH_STATE_MATRIX_DOC = 'webpieces.branch-state-matrix.md';
|
|
35
|
+
/**
|
|
36
|
+
* Drop the L2 matrix where the AI can read it, and return its absolute path ('' if it could not be
|
|
37
|
+
* written).
|
|
38
|
+
*
|
|
39
|
+
* Best-effort by design, exactly as `writeGuardMatrixDoc` is: this runs on a path that is already
|
|
40
|
+
* denying the call, and a missing template (a `@webpieces/rules-config` older than this package) must
|
|
41
|
+
* cost the reader a pointer, never turn their deny into a stack trace.
|
|
42
|
+
*/
|
|
43
|
+
// webpieces-disable no-function-outside-class -- sibling of branchStateMatrixPointer, the shape L0's writeGuardMatrixDoc already established
|
|
44
|
+
function writeBranchStateMatrixDoc(workspaceRoot) {
|
|
45
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
46
|
+
try {
|
|
47
|
+
return (0, rules_config_1.writeTemplate)(workspaceRoot, exports.BRANCH_STATE_MATRIX_DOC);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const error = (0, to_error_1.toError)(err);
|
|
51
|
+
void error; // best-effort: no doc → the deny simply omits the pointer
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The `READ <path>` pointer appended to an L2 deny, or '' when the doc could not be written.
|
|
57
|
+
*
|
|
58
|
+
* Opens with a NEWLINE and carries no quotes or backslashes, for the same two reasons L0's does: the
|
|
59
|
+
* deny renders in the house format (a header, a `[guard-name]` block, `Fix Option N:` lines) so a
|
|
60
|
+
* pointer glued onto the last line is the one place that shape breaks; and the text is interpolated
|
|
61
|
+
* into a JSON payload, where a quote would corrupt the decision rather than merely the prose.
|
|
62
|
+
*
|
|
63
|
+
* It names the ROW as well as the path. A bare "read this doc" is a page; a row number is the two
|
|
64
|
+
* lines that explain this exact verdict, and the log line for this call carries the same number.
|
|
65
|
+
*/
|
|
66
|
+
// webpieces-disable no-function-outside-class -- sibling of writeBranchStateMatrixDoc, mirroring l0-matrix.ts
|
|
67
|
+
function branchStateMatrixPointer(docPath, row) {
|
|
68
|
+
if (docPath === '')
|
|
69
|
+
return '';
|
|
70
|
+
return `\nThe full L2 branch-state matrix - every row, its cure, and the observed use cases it covers - is at ${docPath}; this call was judged by ROW ${row}, and the L2-decisions log line for it carries the same row= and cure=. READ that row if you are unsure why this call was blocked.`;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=l2-matrix-doc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l2-matrix-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-matrix-doc.ts"],"names":[],"mappings":";;;AA0CA,8DASC;AAcD,4DAGC;AApED,0DAAwD;AAExD,yCAAqC;AAErC,8EAA8E;AAC9E,+FAA+F;AAC/F,EAAE;AACF,+EAA+E;AAC/E,qGAAqG;AACrG,kGAAkG;AAClG,sGAAsG;AACtG,qGAAqG;AACrG,EAAE;AACF,6EAA6E;AAC7E,EAAE;AACF,oGAAoG;AACpG,mFAAmF;AACnF,uGAAuG;AACvG,0FAA0F;AAC1F,mGAAmG;AACnG,+FAA+F;AAC/F,gGAAgG;AAChG,EAAE;AACF,mGAAmG;AACnG,qGAAqG;AACrG,mGAAmG;AACnG,oGAAoG;AACpG,oDAAoD;AACpD,8EAA8E;AAE9E,wFAAwF;AAC3E,QAAA,uBAAuB,GAAG,kCAAkC,CAAC;AAE1E;;;;;;;GAOG;AACH,6IAA6I;AAC7I,SAAgB,yBAAyB,CAAC,aAAqB;IAC3D,8DAA8D;IAC9D,IAAI,CAAC;QACD,OAAO,IAAA,4BAAa,EAAC,aAAa,EAAE,+BAAuB,CAAC,CAAC;IACjE,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,0DAA0D;QACtE,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,8GAA8G;AAC9G,SAAgB,wBAAwB,CAAC,OAAe,EAAE,GAAW;IACjE,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAC9B,OAAO,yGAAyG,OAAO,iCAAiC,GAAG,oIAAoI,CAAC;AACpS,CAAC","sourcesContent":["import { writeTemplate } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\n\n// ---------------------------------------------------------------------------\n// The L2 matrix, DELIVERED — dropped where a blocked agent can read it, and named in the deny.\n//\n// L0 has done this since the config-missing fault: `writeGuardMatrixDoc` drops\n// webpieces.guard-matrix.md into `.webpieces/instruct-ai/` on a BLOCK, and `guardMatrixPointer` puts\n// its ABSOLUTE path in the deny text. L2 had the better table and none of the delivery: its rows,\n// their cures and their use cases lived in `guards/L2-branch-state.md`, a path an agent has no reason\n// to know and every reason not to find — while the deny it was actually reading named no doc at all.\n//\n// So this is L0's mechanism, applied to L2. Same three pieces, same reasons:\n//\n// the DOC a rules-config TEMPLATE, byte-locked to renderL2Doc() by l2-matrix.spec.ts, so the\n// delivered copy cannot describe a table the guards no longer have.\n// the WRITE lazy and best-effort, ONLY on a block. An agent that is not blocked does not need it,\n// and a failed write must degrade the deny, never replace it with a crash.\n// the POINTER an ABSOLUTE path. A relative one is unusable: the shell's cwd is not the governed\n// root and cannot be assumed (see EffectiveTree), which is the same reason L1's\n// messages name `<root>` explicitly rather than telling the agent to `cd` first.\n//\n// WHY THE POINTER AND THE `cure=` LOG FIELD SHIP TOGETHER (matrix-cures.ts is the other half): the\n// deny hands the agent the row's cure and the path to the table that cure came from; the log records\n// the row AND that same cure literal. So \"what did the guard tell it to do\" is answerable from the\n// trail alone, and comparing the two is how you find a row whose use cases need updating — which is\n// the whole point of keeping use cases as row data.\n// ---------------------------------------------------------------------------\n\n/** The delivered copy of guards/L2-branch-state.md, as a rules-config template name. */\nexport const BRANCH_STATE_MATRIX_DOC = 'webpieces.branch-state-matrix.md';\n\n/**\n * Drop the L2 matrix where the AI can read it, and return its absolute path ('' if it could not be\n * written).\n *\n * Best-effort by design, exactly as `writeGuardMatrixDoc` is: this runs on a path that is already\n * denying the call, and a missing template (a `@webpieces/rules-config` older than this package) must\n * cost the reader a pointer, never turn their deny into a stack trace.\n */\n// webpieces-disable no-function-outside-class -- sibling of branchStateMatrixPointer, the shape L0's writeGuardMatrixDoc already established\nexport function writeBranchStateMatrixDoc(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return writeTemplate(workspaceRoot, BRANCH_STATE_MATRIX_DOC);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: no doc → the deny simply omits the pointer\n return '';\n }\n}\n\n/**\n * The `READ <path>` pointer appended to an L2 deny, or '' when the doc could not be written.\n *\n * Opens with a NEWLINE and carries no quotes or backslashes, for the same two reasons L0's does: the\n * deny renders in the house format (a header, a `[guard-name]` block, `Fix Option N:` lines) so a\n * pointer glued onto the last line is the one place that shape breaks; and the text is interpolated\n * into a JSON payload, where a quote would corrupt the decision rather than merely the prose.\n *\n * It names the ROW as well as the path. A bare \"read this doc\" is a page; a row number is the two\n * lines that explain this exact verdict, and the log line for this call carries the same number.\n */\n// webpieces-disable no-function-outside-class -- sibling of writeBranchStateMatrixDoc, mirroring l0-matrix.ts\nexport function branchStateMatrixPointer(docPath: string, row: string): string {\n if (docPath === '') return '';\n return `\\nThe full L2 branch-state matrix - every row, its cure, and the observed use cases it covers - is at ${docPath}; this call was judged by ROW ${row}, and the L2-decisions log line for it carries the same row= and cure=. READ that row if you are unsure why this call was blocked.`;\n}\n"]}
|
package/src/core/l2-rows.d.ts
CHANGED
|
@@ -28,6 +28,41 @@ export declare const L2_ALLOW: L2Action;
|
|
|
28
28
|
export declare const L2_EXEMPT: L2Action;
|
|
29
29
|
export declare const L2_BLOCK: L2Action;
|
|
30
30
|
export declare const L2_FAIL_OPEN: L2Action;
|
|
31
|
+
/**
|
|
32
|
+
* One row of the "L2 use cases" table: what you SEE, the state it puts you in, the verdict, the fix.
|
|
33
|
+
*
|
|
34
|
+
* THE POINT OF THIS CLASS is that a use case is added HERE, in code, beside the row it exercises — not
|
|
35
|
+
* into a hand-written doc section that drifts. When a new situation comes up in a session, it becomes
|
|
36
|
+
* one more `new L2UseCase(...)` on the row that judged it, `pnpm guards:generate` re-renders the doc,
|
|
37
|
+
* and the byte-lock spec fails if anyone edits the rendered table instead.
|
|
38
|
+
*
|
|
39
|
+
* The four text fields are rendered VERBATIM. `reason` is the ENFORCEMENT half and is never rendered:
|
|
40
|
+
* it is the exact `reason` string the guard logs for this case, so a spec can push it back through
|
|
41
|
+
* `l2RowForReason` and assert it lands on the row this use case is filed under. That closes the loop
|
|
42
|
+
* the L2 decision log opens — `row=` in the trail, this table on the page, one join between them.
|
|
43
|
+
*
|
|
44
|
+
* `reason` is REQUIRED, and a case that exercises something which is not an L2 row exit says so with
|
|
45
|
+
* `NO_ROW_EXIT` rather than by omitting the argument. An optional field would make opting OUT of the
|
|
46
|
+
* only real enforcement here the shortest thing to type and impossible to grep — the widening-by-absence
|
|
47
|
+
* shape CLAUDE.md rejects. `grep NO_ROW_EXIT` now lists every unenforced case.
|
|
48
|
+
*/
|
|
49
|
+
export declare class L2UseCase {
|
|
50
|
+
readonly num: number;
|
|
51
|
+
readonly symptom: string;
|
|
52
|
+
readonly state: string;
|
|
53
|
+
readonly verdict: string;
|
|
54
|
+
readonly fix: string;
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
constructor(num: number, symptom: string, state: string, verdict: string, fix: string, reason: string);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The `reason` for a use case that is NOT an L2 row exit, and so has nothing to join back to.
|
|
60
|
+
*
|
|
61
|
+
* The only legitimate case today is row 3: merge-in-progress is L4's state, and L2 exempts it without
|
|
62
|
+
* logging a reason of its own. Named rather than absent, so "this case is not enforced" is a value in
|
|
63
|
+
* the table you can grep for instead of a missing argument nobody notices.
|
|
64
|
+
*/
|
|
65
|
+
export declare const NO_ROW_EXIT = "NO_ROW_EXIT (not an L2 row exit \u2014 another layer owns this state)";
|
|
31
66
|
/**
|
|
32
67
|
* One row of L2's decision table.
|
|
33
68
|
*
|
|
@@ -44,11 +79,29 @@ export declare class L2Row {
|
|
|
44
79
|
readonly action: L2Action;
|
|
45
80
|
/** The `cure` cell, verbatim. `—` when the row allows. */
|
|
46
81
|
readonly cure: string;
|
|
82
|
+
/**
|
|
83
|
+
* The observed situations this row judges. Rendered as the "L2 use cases" table.
|
|
84
|
+
*
|
|
85
|
+
* A NON-EMPTY tuple, and required: a row nobody has ever seen fire is either dead or
|
|
86
|
+
* undocumented, and both are worth knowing. Expressing that in the TYPE rather than as a
|
|
87
|
+
* runtime assertion is the JwtRoles pattern — the invariant is enforced at the moment the row
|
|
88
|
+
* is written, which is the only moment that changes what somebody types.
|
|
89
|
+
*/
|
|
90
|
+
readonly useCases: readonly [L2UseCase, ...L2UseCase[]];
|
|
47
91
|
constructor(num: number, tools: readonly L2Tool[],
|
|
48
92
|
/** The `state` cell, verbatim. */
|
|
49
93
|
state: string, action: L2Action,
|
|
50
94
|
/** The `cure` cell, verbatim. `—` when the row allows. */
|
|
51
|
-
cure: string
|
|
95
|
+
cure: string,
|
|
96
|
+
/**
|
|
97
|
+
* The observed situations this row judges. Rendered as the "L2 use cases" table.
|
|
98
|
+
*
|
|
99
|
+
* A NON-EMPTY tuple, and required: a row nobody has ever seen fire is either dead or
|
|
100
|
+
* undocumented, and both are worth knowing. Expressing that in the TYPE rather than as a
|
|
101
|
+
* runtime assertion is the JwtRoles pattern — the invariant is enforced at the moment the row
|
|
102
|
+
* is written, which is the only moment that changes what somebody types.
|
|
103
|
+
*/
|
|
104
|
+
useCases: readonly [L2UseCase, ...L2UseCase[]]);
|
|
52
105
|
/** `B R E`, the doc's own spelling of the tool cell. */
|
|
53
106
|
toolCell(): string;
|
|
54
107
|
}
|
|
@@ -68,6 +121,14 @@ export declare class L2Row {
|
|
|
68
121
|
* behind.
|
|
69
122
|
*/
|
|
70
123
|
export declare const L2_ROWS: readonly L2Row[];
|
|
124
|
+
/**
|
|
125
|
+
* Every use case on every row, in row order, for the doc and for the exhaustiveness specs.
|
|
126
|
+
*
|
|
127
|
+
* Numbering is GLOBAL and is identity, exactly as the row numbers are: a use case is cited by number in
|
|
128
|
+
* review and in the doc, so add new ones at the END of the highest number rather than renumbering to
|
|
129
|
+
* keep a row's block contiguous.
|
|
130
|
+
*/
|
|
131
|
+
export declare function allL2UseCases(): readonly L2UseCase[];
|
|
71
132
|
/**
|
|
72
133
|
* The row a logged reason belongs to, or null when nothing claims it.
|
|
73
134
|
*
|