@webpieces/ai-hook-rules 0.4.745 → 0.4.746

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.745",
3
+ "version": "0.4.746",
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.745",
28
+ "@webpieces/rules-config": "0.4.746",
29
29
  "inversify": "7.10.4",
30
30
  "reflect-metadata": "0.2.2"
31
31
  },
@@ -14,6 +14,7 @@ const log_streams_1 = require("./log-streams");
14
14
  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
+ const log_target_1 = require("./log-target");
17
18
  const ai_type_context_1 = require("./ai-type-context");
18
19
  const l2_rows_1 = require("./l2-rows");
19
20
  const matrix_cures_1 = require("./matrix-cures");
@@ -23,8 +24,7 @@ const matrix_cures_1 = require("./matrix-cures");
23
24
  // the async-written cache snapshot (`cache` field) that drove the decision, so a wrong allow/block is
24
25
  // traceable to a stale or missing async write. Writes to `.webpieces/logs/L2-decisions/<writer>.log` —
25
26
  // the LAYER is the directory, the WRITER is the file (see log-streams.ts and LogStream).
26
- const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)
27
- const MAX_TARGET_LEN = 160;
27
+ const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rotation in rejection-log)
28
28
  /**
29
29
  * WHICH ROW of WHICH layer's decision table produced this line. Data-only → a class, per CLAUDE.md.
30
30
  *
@@ -378,10 +378,13 @@ function branchForLog(root) {
378
378
  return 'unknown';
379
379
  }
380
380
  }
381
- // Collapse newlines/tabs and cap length so one decision is always one log line.
381
+ // Collapse newlines/tabs and cap length so one decision is always one log line. The collapse itself
382
+ // lives in LogTarget, because SessionCallHistory READS this format back and the two must agree byte
383
+ // for byte — a second private copy of the cap is a silent way for a long command to stop matching
384
+ // itself.
385
+ // webpieces-disable no-function-outside-class -- the module's own leaf helper, beside branchForLog/rotateLogFile
382
386
  function oneLine(value) {
383
- const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
384
- return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';
387
+ return log_target_1.logTarget.oneLine(value);
385
388
  }
386
389
  function rotateLogFile(logPath, prevPath) {
387
390
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
@@ -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 { aiTypeContext } from './ai-type-context';\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/rules/no-backwards-compat.md`: `'BLOCK'` is GONE rather than aliased, so every\n * 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 // WHICH HARNESS made the call, spelled exactly as the L0 sh shim spells it on its own\n // stream — so ONE grep (`ai=codex`) spans all five streams and \"is Codex actually being\n // guarded?\" is a question the trail can answer. APPEND-ONLY, like every field before it; a\n // row from a release that predates this carries no `ai=` and reads as `unknown`.\n `ai=${aiTypeContext.forLog()}`,\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 // WHICH HARNESS made the call — same field name and same vocabulary as the L0 sh shim's\n // `ai=` and the decision stream's, so one grep spans the whole trail. APPEND-ONLY.\n `ai=${aiTypeContext.forLog()}`,\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 { logTarget } from './log-target';\nimport { aiTypeContext } from './ai-type-context';\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 rotation in rejection-log)\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/rules/no-backwards-compat.md`: `'BLOCK'` is GONE rather than aliased, so every\n * 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 // WHICH HARNESS made the call, spelled exactly as the L0 sh shim spells it on its own\n // stream — so ONE grep (`ai=codex`) spans all five streams and \"is Codex actually being\n // guarded?\" is a question the trail can answer. APPEND-ONLY, like every field before it; a\n // row from a release that predates this carries no `ai=` and reads as `unknown`.\n `ai=${aiTypeContext.forLog()}`,\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 // WHICH HARNESS made the call — same field name and same vocabulary as the L0 sh shim's\n // `ai=` and the decision stream's, so one grep spans the whole trail. APPEND-ONLY.\n `ai=${aiTypeContext.forLog()}`,\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. The collapse itself\n// lives in LogTarget, because SessionCallHistory READS this format back and the two must agree byte\n// for byte — a second private copy of the cap is a silent way for a long command to stop matching\n// itself.\n// webpieces-disable no-function-outside-class -- the module's own leaf helper, beside branchForLog/rotateLogFile\nfunction oneLine(value: string): string {\n return logTarget.oneLine(value);\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"]}
@@ -28,6 +28,10 @@ export declare function loadRules(config: WebpiecesRulesConfig, workspaceRoot: s
28
28
  * `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets
29
29
  * the build killed by the 600s watchdog; the cure is the SAME command with less typing, available
30
30
  * for every input, and it cannot itself match the guard.
31
+ * - `wait-spin-guard` acts unconditionally, on the same test again. An `echo .` keep-alive spends a
32
+ * whole turn (~557k tokens) to do nothing, and its cure — `pnpm wp-await-reviews` /
33
+ * `pnpm wp-await-checks` for a worktree subagent, a Monitor plus ending the turn for a main agent —
34
+ * is available for every input and can never itself match the guard.
31
35
  *
32
36
  * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS
33
37
  * repo's gate actually runs.
@@ -29,6 +29,7 @@ const branch_creation_guard_1 = require("./rules/branch-creation-guard");
29
29
  const pr_creation_or_push_guard_1 = require("./rules/pr-creation-or-push-guard");
30
30
  const merge_in_progress_guard_1 = require("./rules/merge-in-progress-guard");
31
31
  const build_output_pipe_guard_1 = require("./rules/build-output-pipe-guard");
32
+ const wait_spin_guard_1 = require("./rules/wait-spin-guard");
32
33
  const pr_merge_guard_1 = require("./rules/pr-merge-guard");
33
34
  const redirect_how_to_merge_main_1 = require("./rules/redirect-how-to-merge-main");
34
35
  const no_js_files_1 = require("./rules/no-js-files");
@@ -114,6 +115,10 @@ function loadRules(config, workspaceRoot, guardHints) {
114
115
  * `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets
115
116
  * the build killed by the 600s watchdog; the cure is the SAME command with less typing, available
116
117
  * for every input, and it cannot itself match the guard.
118
+ * - `wait-spin-guard` acts unconditionally, on the same test again. An `echo .` keep-alive spends a
119
+ * whole turn (~557k tokens) to do nothing, and its cure — `pnpm wp-await-reviews` /
120
+ * `pnpm wp-await-checks` for a worktree subagent, a Monitor plus ending the turn for a main agent —
121
+ * is available for every input and can never itself match the guard.
117
122
  *
118
123
  * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS
119
124
  * repo's gate actually runs.
@@ -124,6 +129,7 @@ function loadKeylessBashRules(affectedBuildCommand) {
124
129
  new whole_repo_build_guard_1.WholeRepoBuildGuardRule(affectedBuildCommand),
125
130
  new commit_message_substitution_guard_1.CommitMessageSubstitutionGuardRule(),
126
131
  new build_output_pipe_guard_1.BuildOutputPipeGuardRule(),
132
+ new wait_spin_guard_1.WaitSpinGuardRule(),
127
133
  ];
128
134
  }
129
135
  // One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)
@@ -1 +1 @@
1
- {"version":3,"file":"load-rules.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/load-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n BaseRuleConfig, RuleOptions, WebpiecesRulesConfig,\n NoAnyUnknownConfig, NoImplicitAnyConfig, MaxFileLinesConfig, ValidateTsInSrcConfig,\n NoDestructureConfig, RequireReturnTypeConfig, NoUnmanagedExceptionsConfig,\n CatchErrorPatternConfig, ThrowCauseRequiredConfig,\n NoSymbolDiTokensConfig, NoCustomCssConfig, NoProcessExitOutsideMainConfig, BranchCreationGuardConfig,\n PrLifecycleGuardConfig, BranchStateGuardConfig,\n NoJsFilesConfig, MatchRuleConfig,\n} from '@webpieces/rules-config';\n\nimport type { Rule, PlainRule } from './types';\nimport { InformAiError } from './types';\nimport { toError } from './to-error';\nimport { EmptyRuleConfig } from './rule-base';\nimport { CustomRuleAdapter } from './custom-rule-adapter';\nimport { builtInConfigKeys } from './rules/index';\nimport { NoAnyUnknownRule } from './rules/no-any-unknown';\nimport { NoImplicitAnyRule } from './rules/no-implicit-any';\nimport { MaxFileLinesRule } from './rules/max-file-lines';\nimport { ValidateTsInSrcRule } from './rules/validate-ts-in-src';\nimport { NoDestructureRule } from './rules/no-destructure';\nimport { RequireReturnTypeRule } from './rules/require-return-type';\nimport { NoUnmanagedExceptionsRule } from './rules/no-unmanaged-exceptions';\nimport { CatchErrorPatternRule } from './rules/catch-error-pattern';\nimport { ThrowCauseRequiredRule } from './rules/throw-cause-required';\nimport { NoSymbolDiTokensRule } from './rules/no-symbol-di-tokens';\nimport { NoCustomCssRule } from './rules/no-custom-css';\nimport { NoProcessExitOutsideMainRule } from './rules/no-process-exit-outside-main';\nimport { BranchCreationGuardRule } from './rules/branch-creation-guard';\nimport { PrCreationOrPushGuardRule } from './rules/pr-creation-or-push-guard';\nimport { MergeInProgressGuardRule } from './rules/merge-in-progress-guard';\nimport { BuildOutputPipeGuardRule } from './rules/build-output-pipe-guard';\nimport { PrMergeGuardRule } from './rules/pr-merge-guard';\nimport { RedirectHowToMergeMainRule } from './rules/redirect-how-to-merge-main';\nimport { NoJsFilesRule } from './rules/no-js-files';\nimport { FeatureBranchGuardRule } from './rules/feature-branch-guard';\nimport { ReadStaleGuardRule } from './rules/read-stale-guard';\nimport { MergedBranchBashGuardRule } from './rules/merged-branch-bash-guard';\nimport { StaleMainBashGuardRule } from './rules/stale-main-bash-guard';\nimport { WholeRepoBuildGuardRule } from './rules/whole-repo-build-guard';\nimport { CommitMessageSubstitutionGuardRule } from './rules/commit-message-substitution-guard';\nimport { MatchRule } from './rules/match-rule';\n\nconst REQUIRED_FIELDS: readonly string[] = ['name', 'description', 'scope', 'files', 'check'];\nconst VALID_SCOPES = new Set(['edit', 'file', 'bash']);\n\n/**\n * ONE CONFIG KEY → N RULES.\n *\n * Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json). The\n * config arrives as a plain object structurally typed as the *Config class, so the `as` narrows the\n * shared BaseRuleConfig param back to the concrete config the rule consumes.\n *\n * The map is keyed by CONFIG KEY and each factory returns an ARRAY, because a hookGuards key names a\n * POLICY and a policy may be implemented by several classes: `branch-state-guard` builds all four\n * branch-state guards from one entry, `pr-lifecycle-guard` all four PR-lifecycle guards. It used to be\n * `Record<string, (c) => Rule>` — one factory per key — which is precisely why four classes could not\n * share a key and why the config had to carry nine switches for three decisions.\n *\n * `guardHints` are the resolved `commands.guardHints` strings, handed to the two rules that print a\n * gated command. They arrive as a constructor argument rather than a config field, so there is exactly\n * one spelling of each command in the config (see PrLifecycleGuardConfig).\n */\ntype RuleFactory = (config: BaseRuleConfig, guardHints: GuardHintCommands) => readonly Rule[];\n\n/** The two gated-command strings guards print, resolved from `commands.guardHints`. Data-only. */\nexport class GuardHintCommands {\n constructor(readonly upsertPr: string, readonly mergeComplete: string) {}\n}\n\nconst BUILT_IN_RULE_MAP: Record<string, RuleFactory> = {\n 'no-any-unknown': (c: BaseRuleConfig) => [new NoAnyUnknownRule(c as NoAnyUnknownConfig)],\n 'no-implicit-any': (c: BaseRuleConfig) => [new NoImplicitAnyRule(c as NoImplicitAnyConfig)],\n 'max-file-lines': (c: BaseRuleConfig) => [new MaxFileLinesRule(c as MaxFileLinesConfig)],\n 'validate-ts-in-src': (c: BaseRuleConfig) => [new ValidateTsInSrcRule(c as ValidateTsInSrcConfig)],\n 'no-destructure': (c: BaseRuleConfig) => [new NoDestructureRule(c as NoDestructureConfig)],\n 'require-return-type': (c: BaseRuleConfig) => [new RequireReturnTypeRule(c as RequireReturnTypeConfig)],\n 'no-unmanaged-exceptions': (c: BaseRuleConfig) => [new NoUnmanagedExceptionsRule(c as NoUnmanagedExceptionsConfig)],\n 'catch-error-pattern': (c: BaseRuleConfig) => [new CatchErrorPatternRule(c as CatchErrorPatternConfig)],\n 'throw-cause-required': (c: BaseRuleConfig) => [new ThrowCauseRequiredRule(c as ThrowCauseRequiredConfig)],\n 'no-symbol-di-tokens': (c: BaseRuleConfig) => [new NoSymbolDiTokensRule(c as NoSymbolDiTokensConfig)],\n 'no-custom-css': (c: BaseRuleConfig) => [new NoCustomCssRule(c as NoCustomCssConfig)],\n 'no-process-exit-outside-main': (c: BaseRuleConfig) => [new NoProcessExitOutsideMainRule(c as NoProcessExitOutsideMainConfig)],\n 'no-js-files': (c: BaseRuleConfig) => [new NoJsFilesRule(c as NoJsFilesConfig)],\n 'branch-creation-guard': (c: BaseRuleConfig) => [new BranchCreationGuardRule(c as BranchCreationGuardConfig)],\n // THE TWO COLLAPSED POLICIES. Order inside each array is the order the rules run in, and it is the\n // same order the previous per-key registry produced.\n 'pr-lifecycle-guard': (c: BaseRuleConfig, hints: GuardHintCommands) => [\n new PrCreationOrPushGuardRule(c as PrLifecycleGuardConfig, hints.upsertPr),\n new MergeInProgressGuardRule(c as PrLifecycleGuardConfig, hints.mergeComplete),\n new PrMergeGuardRule(c as PrLifecycleGuardConfig),\n new RedirectHowToMergeMainRule(c as PrLifecycleGuardConfig),\n ],\n 'branch-state-guard': (c: BaseRuleConfig) => [\n new FeatureBranchGuardRule(c as BranchStateGuardConfig),\n new ReadStaleGuardRule(c as BranchStateGuardConfig),\n new MergedBranchBashGuardRule(c as BranchStateGuardConfig),\n new StaleMainBashGuardRule(c as BranchStateGuardConfig),\n ],\n};\n\n// Index the typed config by rule name. Each value is the rule's *Config (a plain object from\n// JSON), or undefined when the rule has no entry yet (the sync check reports those).\nfunction asConfigMap(config: WebpiecesRulesConfig): Record<string, BaseRuleConfig | undefined> {\n // webpieces-disable no-any-unknown -- index the typed config by dynamic rule name\n return config as unknown as Record<string, BaseRuleConfig | undefined>;\n}\n\n// webpieces-disable no-function-outside-class -- the module's entry point, beside loadMatchRules/loadKeylessBashRules; this whole loader is module-scope functions and a lone class for one of them would break the file's shape\nexport function loadRules(\n config: WebpiecesRulesConfig,\n workspaceRoot: string,\n guardHints: GuardHintCommands,\n): readonly Rule[] {\n const builtIns = loadBuiltInRules(config, guardHints);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n/**\n * The KEYLESS bash guards: rules that have NO webpieces.config.json entry, and are therefore\n * deliberately kept out of `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y,\n * \"every built-in rule needs an entry, or every Bash call is blocked\") can never see them. That\n * containment is the whole point: whole-repo-build-guard shipped inside the config-driven set once and\n * took every upgrading consumer's shell down with it.\n *\n * Each rule here decides for ITSELF whether it acts, and the two do it differently on purpose:\n *\n * - `whole-repo-build-guard` is EXPERIMENTAL and inert unless the optional machine-local\n * `~/.webpieces/config.json` opts IN with `experimental.whole-repo-build-guard: true`. Every\n * experimental flag defaults OFF, and it takes no file and no key to be in that default state —\n * which is the difference between this and the required-key release that blocked every upgrading\n * consumer's shell.\n * - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick\n * expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every\n * input and can never itself match the guard — so there is nothing for a switch to rescue.\n * - `build-output-pipe-guard` acts unconditionally, on the same test. Piping `wp-build` /\n * `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets\n * the build killed by the 600s watchdog; the cure is the SAME command with less typing, available\n * for every input, and it cannot itself match the guard.\n *\n * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS\n * repo's gate actually runs.\n */\n// webpieces-disable no-function-outside-class -- sibling of loadRules/loadMatchRules in this module; the whole loader is module-scope functions and a lone class for this one would break the file's shape\nexport function loadKeylessBashRules(affectedBuildCommand: string): Rule[] {\n return [\n new WholeRepoBuildGuardRule(affectedBuildCommand),\n new CommitMessageSubstitutionGuardRule(),\n new BuildOutputPipeGuardRule(),\n ];\n}\n\n// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)\n// because match-rules live in their own validated section — they must NOT flow through the\n// config-sync check, which compares rule names against the `rules`/`hookGuards` map.\nexport function loadMatchRules(matchRules: readonly MatchRuleConfig[]): Rule[] {\n return matchRules.map((c: MatchRuleConfig) => new MatchRule(c));\n}\n\n// Iterates CONFIG KEYS, not rule names — one entry can yield several rules (see BUILT_IN_RULE_MAP).\n// webpieces-disable no-function-outside-class -- the body of loadRules above, in the same module of loader functions\nfunction loadBuiltInRules(config: WebpiecesRulesConfig, guardHints: GuardHintCommands): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const configKey of builtInConfigKeys) {\n const factory = BUILT_IN_RULE_MAP[configKey];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in config key: ${configKey}\\n`);\n continue;\n }\n const ruleConfig = map[configKey] ?? new EmptyRuleConfig();\n rules.push(...factory(ruleConfig, guardHints));\n }\n return rules;\n}\n\nfunction loadCustomRules(config: WebpiecesRulesConfig, workspaceRoot: string): Rule[] {\n const dirs = config.rulesDir ?? [];\n // webpieces-disable no-any-unknown -- index the typed config by dynamic custom-rule name\n const map = config as unknown as Record<string, RuleOptions | undefined>;\n const rules: Rule[] = [];\n for (const plain of loadCustomPlainRules(dirs, workspaceRoot)) {\n const rawConfig = map[plain.name] ?? {};\n rules.push(new CustomRuleAdapter(plain, rawConfig));\n }\n return rules;\n}\n\nfunction loadCustomPlainRules(rulesDirs: readonly string[], workspaceRoot: string): PlainRule[] {\n const modules: PlainRule[] = [];\n for (const dir of rulesDirs) {\n const absDir = path.isAbsolute(dir) ? dir : path.join(workspaceRoot, dir);\n if (!fs.existsSync(absDir)) {\n process.stderr.write(`[ai-hooks] rulesDir not found: ${absDir}\\n`);\n continue;\n }\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(absDir).filter((e: string) => e.endsWith('.js'));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot read custom rules directory '${absDir}'`, { cause: error });\n }\n for (const entry of entries) {\n const full = path.join(absDir, entry);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const mod = require(full);\n const candidate = mod.default || mod;\n if (validateRule(candidate)) modules.push(candidate);\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot load custom rule '${full}'`, { cause: error });\n }\n }\n }\n return modules;\n}\n\n// webpieces-disable no-any-unknown -- validates untrusted require() output at system boundary\nfunction validateRule(rule: unknown): rule is PlainRule {\n if (!rule || typeof rule !== 'object') {\n process.stderr.write('[ai-hooks] rule is not an object, skipping\\n');\n return false;\n }\n // webpieces-disable no-any-unknown -- narrowing from unknown at system boundary\n const obj = rule as Record<string, unknown>;\n for (const field of REQUIRED_FIELDS) {\n if (obj[field] === undefined) {\n const name = typeof obj['name'] === 'string' ? obj['name'] : '<unnamed>';\n process.stderr.write(`[ai-hooks] rule \"${name}\" missing required field: ${field}\\n`);\n return false;\n }\n }\n if (!VALID_SCOPES.has(obj['scope'] as string)) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" has invalid scope: ${String(obj['scope'])}\\n`);\n return false;\n }\n if (!Array.isArray(obj['files'])) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" files must be an array\\n`);\n return false;\n }\n if (typeof obj['check'] !== 'function') {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" check must be a function\\n`);\n return false;\n }\n return true;\n}\n\nexport function globMatches(pattern: string, filePath: string): boolean {\n const regex = globToRegex(pattern);\n return regex.test(filePath);\n}\n\nfunction globToRegex(pattern: string): RegExp {\n let re = '';\n let i = 0;\n while (i < pattern.length) {\n const ch = pattern[i];\n if (ch === '*') {\n if (pattern[i + 1] === '*') {\n re += '.*';\n i += 2;\n if (pattern[i] === '/') i += 1;\n continue;\n }\n re += '[^/]*';\n i += 1;\n continue;\n }\n if (ch === '?') {\n re += '[^/]';\n i += 1;\n continue;\n }\n if ('.+^$(){}|[]\\\\'.includes(ch)) {\n re += '\\\\' + ch;\n i += 1;\n continue;\n }\n re += ch;\n i += 1;\n }\n return new RegExp('^' + re + '$');\n}\n"]}
1
+ {"version":3,"file":"load-rules.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/load-rules.ts"],"names":[],"mappings":";;;AAiHA,8BAQC;AAgCD,oDAOC;AAKD,wCAEC;AA6FD,kCAGC;;AAvQD,+CAAyB;AACzB,mDAA6B;AAa7B,mCAAwC;AACxC,yCAAqC;AACrC,2CAA8C;AAC9C,+DAA0D;AAC1D,yCAAkD;AAClD,2DAA0D;AAC1D,6DAA4D;AAC5D,2DAA0D;AAC1D,mEAAiE;AACjE,2DAA2D;AAC3D,qEAAoE;AACpE,6EAA4E;AAC5E,qEAAoE;AACpE,uEAAsE;AACtE,qEAAmE;AACnE,yDAAwD;AACxD,uFAAoF;AACpF,yEAAwE;AACxE,iFAA8E;AAC9E,6EAA2E;AAC3E,6EAA2E;AAC3E,6DAA4D;AAC5D,2DAA0D;AAC1D,mFAAgF;AAChF,qDAAoD;AACpD,uEAAsE;AACtE,+DAA8D;AAC9D,+EAA6E;AAC7E,yEAAuE;AACvE,2EAAyE;AACzE,iGAA+F;AAC/F,mDAA+C;AAE/C,MAAM,eAAe,GAAsB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAC9F,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAqBvD,kGAAkG;AAClG,MAAa,iBAAiB;IACL;IAA2B;IAAhD,YAAqB,QAAgB,EAAW,aAAqB;QAAhD,aAAQ,GAAR,QAAQ,CAAQ;QAAW,kBAAa,GAAb,aAAa,CAAQ;IAAG,CAAC;CAC5E;AAFD,8CAEC;AAED,MAAM,iBAAiB,GAAgC;IACnD,gBAAgB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,iCAAgB,CAAC,CAAuB,CAAC,CAAC;IACxF,iBAAiB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,mCAAiB,CAAC,CAAwB,CAAC,CAAC;IAC3F,gBAAgB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,iCAAgB,CAAC,CAAuB,CAAC,CAAC;IACxF,oBAAoB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,wCAAmB,CAAC,CAA0B,CAAC,CAAC;IAClG,gBAAgB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,kCAAiB,CAAC,CAAwB,CAAC,CAAC;IAC1F,qBAAqB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,2CAAqB,CAAC,CAA4B,CAAC,CAAC;IACvG,yBAAyB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,mDAAyB,CAAC,CAAgC,CAAC,CAAC;IACnH,qBAAqB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,2CAAqB,CAAC,CAA4B,CAAC,CAAC;IACvG,sBAAsB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,6CAAsB,CAAC,CAA6B,CAAC,CAAC;IAC1G,qBAAqB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,0CAAoB,CAAC,CAA2B,CAAC,CAAC;IACrG,eAAe,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,+BAAe,CAAC,CAAsB,CAAC,CAAC;IACrF,8BAA8B,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,2DAA4B,CAAC,CAAmC,CAAC,CAAC;IAC9H,aAAa,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,2BAAa,CAAC,CAAoB,CAAC,CAAC;IAC/E,uBAAuB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,CAAC,IAAI,+CAAuB,CAAC,CAA8B,CAAC,CAAC;IAC7G,mGAAmG;IACnG,qDAAqD;IACrD,oBAAoB,EAAE,CAAC,CAAiB,EAAE,KAAwB,EAAE,EAAE,CAAC;QACnE,IAAI,qDAAyB,CAAC,CAA2B,EAAE,KAAK,CAAC,QAAQ,CAAC;QAC1E,IAAI,kDAAwB,CAAC,CAA2B,EAAE,KAAK,CAAC,aAAa,CAAC;QAC9E,IAAI,iCAAgB,CAAC,CAA2B,CAAC;QACjD,IAAI,uDAA0B,CAAC,CAA2B,CAAC;KAC9D;IACD,oBAAoB,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC;QACzC,IAAI,6CAAsB,CAAC,CAA2B,CAAC;QACvD,IAAI,qCAAkB,CAAC,CAA2B,CAAC;QACnD,IAAI,oDAAyB,CAAC,CAA2B,CAAC;QAC1D,IAAI,8CAAsB,CAAC,CAA2B,CAAC;KAC1D;CACJ,CAAC;AAEF,6FAA6F;AAC7F,qFAAqF;AACrF,SAAS,WAAW,CAAC,MAA4B;IAC7C,kFAAkF;IAClF,OAAO,MAA+D,CAAC;AAC3E,CAAC;AAED,iOAAiO;AACjO,SAAgB,SAAS,CACrB,MAA4B,EAC5B,aAAqB,EACrB,UAA6B;IAE7B,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACtD,OAAO,CAAC,GAAG,QAAQ,EAAE,GAAG,MAAM,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,2MAA2M;AAC3M,SAAgB,oBAAoB,CAAC,oBAA4B;IAC7D,OAAO;QACH,IAAI,gDAAuB,CAAC,oBAAoB,CAAC;QACjD,IAAI,sEAAkC,EAAE;QACxC,IAAI,kDAAwB,EAAE;QAC9B,IAAI,mCAAiB,EAAE;KAC1B,CAAC;AACN,CAAC;AAED,sGAAsG;AACtG,2FAA2F;AAC3F,qFAAqF;AACrF,SAAgB,cAAc,CAAC,UAAsC;IACjE,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC,CAAkB,EAAE,EAAE,CAAC,IAAI,sBAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACpE,CAAC;AAED,oGAAoG;AACpG,qHAAqH;AACrH,SAAS,gBAAgB,CAAC,MAA4B,EAAE,UAA6B;IACjF,MAAM,GAAG,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IAChC,MAAM,KAAK,GAAW,EAAE,CAAC;IACzB,KAAK,MAAM,SAAS,IAAI,yBAAiB,EAAE,CAAC;QACxC,MAAM,OAAO,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,EAAE,CAAC;YACX,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,2CAA2C,SAAS,IAAI,CAAC,CAAC;YAC/E,SAAS;QACb,CAAC;QACD,MAAM,UAAU,GAAG,GAAG,CAAC,SAAS,CAAC,IAAI,IAAI,2BAAe,EAAE,CAAC;QAC3D,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,SAAS,eAAe,CAAC,MAA4B,EAAE,aAAqB;IACxE,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;IACnC,yFAAyF;IACzF,MAAM,GAAG,GAAG,MAA4D,CAAC;IACzE,MAAM,KAAK,GAAW,EAAE,CAAC;IACzB,KAAK,MAAM,KAAK,IAAI,oBAAoB,CAAC,IAAI,EAAE,aAAa,CAAC,EAAE,CAAC;QAC5D,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACxC,KAAK,CAAC,IAAI,CAAC,IAAI,uCAAiB,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC;IACxD,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,SAAS,oBAAoB,CAAC,SAA4B,EAAE,aAAqB;IAC7E,MAAM,OAAO,GAAgB,EAAE,CAAC;IAChC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;QAC1E,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACzB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,kCAAkC,MAAM,IAAI,CAAC,CAAC;YACnE,SAAS;QACb,CAAC;QACD,IAAI,OAAiB,CAAC;QACtB,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;QAC9E,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,qBAAa,CAAC,uCAAuC,MAAM,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QAChG,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;YACtC,8DAA8D;YAC9D,IAAI,CAAC;gBACD,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;gBAC1B,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC;gBACrC,IAAI,YAAY,CAAC,SAAS,CAAC;oBAAE,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACzD,CAAC;YAAC,OAAO,GAAY,EAAE,CAAC;gBACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;gBAC3B,MAAM,IAAI,qBAAa,CAAC,4BAA4B,IAAI,GAAG,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACnF,CAAC;QACL,CAAC;IACL,CAAC;IACD,OAAO,OAAO,CAAC;AACnB,CAAC;AAED,8FAA8F;AAC9F,SAAS,YAAY,CAAC,IAAa;IAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;QACrE,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,gFAAgF;IAChF,MAAM,GAAG,GAAG,IAA+B,CAAC;IAC5C,KAAK,MAAM,KAAK,IAAI,eAAe,EAAE,CAAC;QAClC,IAAI,GAAG,CAAC,KAAK,CAAC,KAAK,SAAS,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,MAAM,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;YACzE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,IAAI,6BAA6B,KAAK,IAAI,CAAC,CAAC;YACrF,OAAO,KAAK,CAAC;QACjB,CAAC;IACL,CAAC;IACD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAW,CAAC,EAAE,CAAC;QAC5C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,MAAM,CAAC,wBAAwB,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC;QACtG,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;QAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,MAAM,CAAC,4BAA4B,CAAC,CAAC;QAClF,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,IAAI,OAAO,GAAG,CAAC,OAAO,CAAC,KAAK,UAAU,EAAE,CAAC;QACrC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,MAAM,CAAC,8BAA8B,CAAC,CAAC;QACpF,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,SAAgB,WAAW,CAAC,OAAe,EAAE,QAAgB;IACzD,MAAM,KAAK,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;IACnC,OAAO,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,WAAW,CAAC,OAAe;IAChC,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;QACxB,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QACtB,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACb,IAAI,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;gBACzB,EAAE,IAAI,IAAI,CAAC;gBACX,CAAC,IAAI,CAAC,CAAC;gBACP,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG;oBAAE,CAAC,IAAI,CAAC,CAAC;gBAC/B,SAAS;YACb,CAAC;YACD,EAAE,IAAI,OAAO,CAAC;YACd,CAAC,IAAI,CAAC,CAAC;YACP,SAAS;QACb,CAAC;QACD,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACb,EAAE,IAAI,MAAM,CAAC;YACb,CAAC,IAAI,CAAC,CAAC;YACP,SAAS;QACb,CAAC;QACD,IAAI,eAAe,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;YAC/B,EAAE,IAAI,IAAI,GAAG,EAAE,CAAC;YAChB,CAAC,IAAI,CAAC,CAAC;YACP,SAAS;QACb,CAAC;QACD,EAAE,IAAI,EAAE,CAAC;QACT,CAAC,IAAI,CAAC,CAAC;IACX,CAAC;IACD,OAAO,IAAI,MAAM,CAAC,GAAG,GAAG,EAAE,GAAG,GAAG,CAAC,CAAC;AACtC,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n BaseRuleConfig, RuleOptions, WebpiecesRulesConfig,\n NoAnyUnknownConfig, NoImplicitAnyConfig, MaxFileLinesConfig, ValidateTsInSrcConfig,\n NoDestructureConfig, RequireReturnTypeConfig, NoUnmanagedExceptionsConfig,\n CatchErrorPatternConfig, ThrowCauseRequiredConfig,\n NoSymbolDiTokensConfig, NoCustomCssConfig, NoProcessExitOutsideMainConfig, BranchCreationGuardConfig,\n PrLifecycleGuardConfig, BranchStateGuardConfig,\n NoJsFilesConfig, MatchRuleConfig,\n} from '@webpieces/rules-config';\n\nimport type { Rule, PlainRule } from './types';\nimport { InformAiError } from './types';\nimport { toError } from './to-error';\nimport { EmptyRuleConfig } from './rule-base';\nimport { CustomRuleAdapter } from './custom-rule-adapter';\nimport { builtInConfigKeys } from './rules/index';\nimport { NoAnyUnknownRule } from './rules/no-any-unknown';\nimport { NoImplicitAnyRule } from './rules/no-implicit-any';\nimport { MaxFileLinesRule } from './rules/max-file-lines';\nimport { ValidateTsInSrcRule } from './rules/validate-ts-in-src';\nimport { NoDestructureRule } from './rules/no-destructure';\nimport { RequireReturnTypeRule } from './rules/require-return-type';\nimport { NoUnmanagedExceptionsRule } from './rules/no-unmanaged-exceptions';\nimport { CatchErrorPatternRule } from './rules/catch-error-pattern';\nimport { ThrowCauseRequiredRule } from './rules/throw-cause-required';\nimport { NoSymbolDiTokensRule } from './rules/no-symbol-di-tokens';\nimport { NoCustomCssRule } from './rules/no-custom-css';\nimport { NoProcessExitOutsideMainRule } from './rules/no-process-exit-outside-main';\nimport { BranchCreationGuardRule } from './rules/branch-creation-guard';\nimport { PrCreationOrPushGuardRule } from './rules/pr-creation-or-push-guard';\nimport { MergeInProgressGuardRule } from './rules/merge-in-progress-guard';\nimport { BuildOutputPipeGuardRule } from './rules/build-output-pipe-guard';\nimport { WaitSpinGuardRule } from './rules/wait-spin-guard';\nimport { PrMergeGuardRule } from './rules/pr-merge-guard';\nimport { RedirectHowToMergeMainRule } from './rules/redirect-how-to-merge-main';\nimport { NoJsFilesRule } from './rules/no-js-files';\nimport { FeatureBranchGuardRule } from './rules/feature-branch-guard';\nimport { ReadStaleGuardRule } from './rules/read-stale-guard';\nimport { MergedBranchBashGuardRule } from './rules/merged-branch-bash-guard';\nimport { StaleMainBashGuardRule } from './rules/stale-main-bash-guard';\nimport { WholeRepoBuildGuardRule } from './rules/whole-repo-build-guard';\nimport { CommitMessageSubstitutionGuardRule } from './rules/commit-message-substitution-guard';\nimport { MatchRule } from './rules/match-rule';\n\nconst REQUIRED_FIELDS: readonly string[] = ['name', 'description', 'scope', 'files', 'check'];\nconst VALID_SCOPES = new Set(['edit', 'file', 'bash']);\n\n/**\n * ONE CONFIG KEY → N RULES.\n *\n * Each built-in rule is constructed from its typed *Config (the entry in webpieces.config.json). The\n * config arrives as a plain object structurally typed as the *Config class, so the `as` narrows the\n * shared BaseRuleConfig param back to the concrete config the rule consumes.\n *\n * The map is keyed by CONFIG KEY and each factory returns an ARRAY, because a hookGuards key names a\n * POLICY and a policy may be implemented by several classes: `branch-state-guard` builds all four\n * branch-state guards from one entry, `pr-lifecycle-guard` all four PR-lifecycle guards. It used to be\n * `Record<string, (c) => Rule>` — one factory per key — which is precisely why four classes could not\n * share a key and why the config had to carry nine switches for three decisions.\n *\n * `guardHints` are the resolved `commands.guardHints` strings, handed to the two rules that print a\n * gated command. They arrive as a constructor argument rather than a config field, so there is exactly\n * one spelling of each command in the config (see PrLifecycleGuardConfig).\n */\ntype RuleFactory = (config: BaseRuleConfig, guardHints: GuardHintCommands) => readonly Rule[];\n\n/** The two gated-command strings guards print, resolved from `commands.guardHints`. Data-only. */\nexport class GuardHintCommands {\n constructor(readonly upsertPr: string, readonly mergeComplete: string) {}\n}\n\nconst BUILT_IN_RULE_MAP: Record<string, RuleFactory> = {\n 'no-any-unknown': (c: BaseRuleConfig) => [new NoAnyUnknownRule(c as NoAnyUnknownConfig)],\n 'no-implicit-any': (c: BaseRuleConfig) => [new NoImplicitAnyRule(c as NoImplicitAnyConfig)],\n 'max-file-lines': (c: BaseRuleConfig) => [new MaxFileLinesRule(c as MaxFileLinesConfig)],\n 'validate-ts-in-src': (c: BaseRuleConfig) => [new ValidateTsInSrcRule(c as ValidateTsInSrcConfig)],\n 'no-destructure': (c: BaseRuleConfig) => [new NoDestructureRule(c as NoDestructureConfig)],\n 'require-return-type': (c: BaseRuleConfig) => [new RequireReturnTypeRule(c as RequireReturnTypeConfig)],\n 'no-unmanaged-exceptions': (c: BaseRuleConfig) => [new NoUnmanagedExceptionsRule(c as NoUnmanagedExceptionsConfig)],\n 'catch-error-pattern': (c: BaseRuleConfig) => [new CatchErrorPatternRule(c as CatchErrorPatternConfig)],\n 'throw-cause-required': (c: BaseRuleConfig) => [new ThrowCauseRequiredRule(c as ThrowCauseRequiredConfig)],\n 'no-symbol-di-tokens': (c: BaseRuleConfig) => [new NoSymbolDiTokensRule(c as NoSymbolDiTokensConfig)],\n 'no-custom-css': (c: BaseRuleConfig) => [new NoCustomCssRule(c as NoCustomCssConfig)],\n 'no-process-exit-outside-main': (c: BaseRuleConfig) => [new NoProcessExitOutsideMainRule(c as NoProcessExitOutsideMainConfig)],\n 'no-js-files': (c: BaseRuleConfig) => [new NoJsFilesRule(c as NoJsFilesConfig)],\n 'branch-creation-guard': (c: BaseRuleConfig) => [new BranchCreationGuardRule(c as BranchCreationGuardConfig)],\n // THE TWO COLLAPSED POLICIES. Order inside each array is the order the rules run in, and it is the\n // same order the previous per-key registry produced.\n 'pr-lifecycle-guard': (c: BaseRuleConfig, hints: GuardHintCommands) => [\n new PrCreationOrPushGuardRule(c as PrLifecycleGuardConfig, hints.upsertPr),\n new MergeInProgressGuardRule(c as PrLifecycleGuardConfig, hints.mergeComplete),\n new PrMergeGuardRule(c as PrLifecycleGuardConfig),\n new RedirectHowToMergeMainRule(c as PrLifecycleGuardConfig),\n ],\n 'branch-state-guard': (c: BaseRuleConfig) => [\n new FeatureBranchGuardRule(c as BranchStateGuardConfig),\n new ReadStaleGuardRule(c as BranchStateGuardConfig),\n new MergedBranchBashGuardRule(c as BranchStateGuardConfig),\n new StaleMainBashGuardRule(c as BranchStateGuardConfig),\n ],\n};\n\n// Index the typed config by rule name. Each value is the rule's *Config (a plain object from\n// JSON), or undefined when the rule has no entry yet (the sync check reports those).\nfunction asConfigMap(config: WebpiecesRulesConfig): Record<string, BaseRuleConfig | undefined> {\n // webpieces-disable no-any-unknown -- index the typed config by dynamic rule name\n return config as unknown as Record<string, BaseRuleConfig | undefined>;\n}\n\n// webpieces-disable no-function-outside-class -- the module's entry point, beside loadMatchRules/loadKeylessBashRules; this whole loader is module-scope functions and a lone class for one of them would break the file's shape\nexport function loadRules(\n config: WebpiecesRulesConfig,\n workspaceRoot: string,\n guardHints: GuardHintCommands,\n): readonly Rule[] {\n const builtIns = loadBuiltInRules(config, guardHints);\n const custom = loadCustomRules(config, workspaceRoot);\n return [...builtIns, ...custom];\n}\n\n/**\n * The KEYLESS bash guards: rules that have NO webpieces.config.json entry, and are therefore\n * deliberately kept out of `builtInConfigKeys`/`BUILT_IN_RULE_MAP` — so the config-sync check (fault Y,\n * \"every built-in rule needs an entry, or every Bash call is blocked\") can never see them. That\n * containment is the whole point: whole-repo-build-guard shipped inside the config-driven set once and\n * took every upgrading consumer's shell down with it.\n *\n * Each rule here decides for ITSELF whether it acts, and the two do it differently on purpose:\n *\n * - `whole-repo-build-guard` is EXPERIMENTAL and inert unless the optional machine-local\n * `~/.webpieces/config.json` opts IN with `experimental.whole-repo-build-guard: true`. Every\n * experimental flag defaults OFF, and it takes no file and no key to be in that default state —\n * which is the difference between this and the required-key release that blocked every upgrading\n * consumer's shell.\n * - `commit-message-substitution-guard` acts unconditionally. Nobody legitimately wants a backtick\n * expanded inside a commit message, and its cure (`git commit -F <file>`) is available for every\n * input and can never itself match the guard — so there is nothing for a switch to rescue.\n * - `build-output-pipe-guard` acts unconditionally, on the same test. Piping `wp-build` /\n * `wp-review-upsert-pr` / `wp-finish-upsert-pr` withholds their heartbeat until they exit and gets\n * the build killed by the 600s watchdog; the cure is the SAME command with less typing, available\n * for every input, and it cannot itself match the guard.\n * - `wait-spin-guard` acts unconditionally, on the same test again. An `echo .` keep-alive spends a\n * whole turn (~557k tokens) to do nothing, and its cure — `pnpm wp-await-reviews` /\n * `pnpm wp-await-checks` for a worktree subagent, a Monitor plus ending the turn for a main agent —\n * is available for every input and can never itself match the guard.\n *\n * `affectedBuildCommand` is the project's gate command, passed through so a refusal quotes what THIS\n * repo's gate actually runs.\n */\n// webpieces-disable no-function-outside-class -- sibling of loadRules/loadMatchRules in this module; the whole loader is module-scope functions and a lone class for this one would break the file's shape\nexport function loadKeylessBashRules(affectedBuildCommand: string): Rule[] {\n return [\n new WholeRepoBuildGuardRule(affectedBuildCommand),\n new CommitMessageSubstitutionGuardRule(),\n new BuildOutputPipeGuardRule(),\n new WaitSpinGuardRule(),\n ];\n}\n\n// One MatchRule per entry of the `match-rules` array. Kept separate from loadRules (built-ins/custom)\n// because match-rules live in their own validated section — they must NOT flow through the\n// config-sync check, which compares rule names against the `rules`/`hookGuards` map.\nexport function loadMatchRules(matchRules: readonly MatchRuleConfig[]): Rule[] {\n return matchRules.map((c: MatchRuleConfig) => new MatchRule(c));\n}\n\n// Iterates CONFIG KEYS, not rule names — one entry can yield several rules (see BUILT_IN_RULE_MAP).\n// webpieces-disable no-function-outside-class -- the body of loadRules above, in the same module of loader functions\nfunction loadBuiltInRules(config: WebpiecesRulesConfig, guardHints: GuardHintCommands): Rule[] {\n const map = asConfigMap(config);\n const rules: Rule[] = [];\n for (const configKey of builtInConfigKeys) {\n const factory = BUILT_IN_RULE_MAP[configKey];\n if (!factory) {\n process.stderr.write(`[ai-hooks] unknown built-in config key: ${configKey}\\n`);\n continue;\n }\n const ruleConfig = map[configKey] ?? new EmptyRuleConfig();\n rules.push(...factory(ruleConfig, guardHints));\n }\n return rules;\n}\n\nfunction loadCustomRules(config: WebpiecesRulesConfig, workspaceRoot: string): Rule[] {\n const dirs = config.rulesDir ?? [];\n // webpieces-disable no-any-unknown -- index the typed config by dynamic custom-rule name\n const map = config as unknown as Record<string, RuleOptions | undefined>;\n const rules: Rule[] = [];\n for (const plain of loadCustomPlainRules(dirs, workspaceRoot)) {\n const rawConfig = map[plain.name] ?? {};\n rules.push(new CustomRuleAdapter(plain, rawConfig));\n }\n return rules;\n}\n\nfunction loadCustomPlainRules(rulesDirs: readonly string[], workspaceRoot: string): PlainRule[] {\n const modules: PlainRule[] = [];\n for (const dir of rulesDirs) {\n const absDir = path.isAbsolute(dir) ? dir : path.join(workspaceRoot, dir);\n if (!fs.existsSync(absDir)) {\n process.stderr.write(`[ai-hooks] rulesDir not found: ${absDir}\\n`);\n continue;\n }\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(absDir).filter((e: string) => e.endsWith('.js'));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot read custom rules directory '${absDir}'`, { cause: error });\n }\n for (const entry of entries) {\n const full = path.join(absDir, entry);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const mod = require(full);\n const candidate = mod.default || mod;\n if (validateRule(candidate)) modules.push(candidate);\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Cannot load custom rule '${full}'`, { cause: error });\n }\n }\n }\n return modules;\n}\n\n// webpieces-disable no-any-unknown -- validates untrusted require() output at system boundary\nfunction validateRule(rule: unknown): rule is PlainRule {\n if (!rule || typeof rule !== 'object') {\n process.stderr.write('[ai-hooks] rule is not an object, skipping\\n');\n return false;\n }\n // webpieces-disable no-any-unknown -- narrowing from unknown at system boundary\n const obj = rule as Record<string, unknown>;\n for (const field of REQUIRED_FIELDS) {\n if (obj[field] === undefined) {\n const name = typeof obj['name'] === 'string' ? obj['name'] : '<unnamed>';\n process.stderr.write(`[ai-hooks] rule \"${name}\" missing required field: ${field}\\n`);\n return false;\n }\n }\n if (!VALID_SCOPES.has(obj['scope'] as string)) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" has invalid scope: ${String(obj['scope'])}\\n`);\n return false;\n }\n if (!Array.isArray(obj['files'])) {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" files must be an array\\n`);\n return false;\n }\n if (typeof obj['check'] !== 'function') {\n process.stderr.write(`[ai-hooks] rule \"${obj['name']}\" check must be a function\\n`);\n return false;\n }\n return true;\n}\n\nexport function globMatches(pattern: string, filePath: string): boolean {\n const regex = globToRegex(pattern);\n return regex.test(filePath);\n}\n\nfunction globToRegex(pattern: string): RegExp {\n let re = '';\n let i = 0;\n while (i < pattern.length) {\n const ch = pattern[i];\n if (ch === '*') {\n if (pattern[i + 1] === '*') {\n re += '.*';\n i += 2;\n if (pattern[i] === '/') i += 1;\n continue;\n }\n re += '[^/]*';\n i += 1;\n continue;\n }\n if (ch === '?') {\n re += '[^/]';\n i += 1;\n continue;\n }\n if ('.+^$(){}|[]\\\\'.includes(ch)) {\n re += '\\\\' + ch;\n i += 1;\n continue;\n }\n re += ch;\n i += 1;\n }\n return new RegExp('^' + re + '$');\n}\n"]}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The ONE spelling of "how a command or file path is written into a log line's target field".
3
+ *
4
+ * It exists because the format now has a READER as well as a writer. `InvocationLog` and
5
+ * `logGuardDecision` collapse a command to one capped line before appending it; `SessionCallHistory`
6
+ * has to reproduce that byte for byte to recognise the same command coming back. Two private copies of
7
+ * a collapse-and-cap is exactly the second spelling `.claude/rules/no-backwards-compat.md` rejects —
8
+ * and the failure it produces is silent, since a mismatched cap simply means a long command never
9
+ * matches itself and the guard that depends on it quietly never fires.
10
+ */
11
+ export declare class LogTarget {
12
+ /**
13
+ * Collapse newlines and tabs so one decision is always one log line, and cap the length so a
14
+ * pasted heredoc cannot make a log line unreadable. The ellipsis is part of the stored value.
15
+ */
16
+ oneLine(value: string): string;
17
+ }
18
+ /** The cap, exported so a spec can assert against it rather than re-typing the number. */
19
+ export declare const MAX_TARGET_LEN = 160;
20
+ /** Process-wide instance: it is a pure function of its input, so there is nothing to construct twice. */
21
+ export declare const logTarget: LogTarget;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logTarget = exports.MAX_TARGET_LEN = exports.LogTarget = void 0;
4
+ /**
5
+ * The ONE spelling of "how a command or file path is written into a log line's target field".
6
+ *
7
+ * It exists because the format now has a READER as well as a writer. `InvocationLog` and
8
+ * `logGuardDecision` collapse a command to one capped line before appending it; `SessionCallHistory`
9
+ * has to reproduce that byte for byte to recognise the same command coming back. Two private copies of
10
+ * a collapse-and-cap is exactly the second spelling `.claude/rules/no-backwards-compat.md` rejects —
11
+ * and the failure it produces is silent, since a mismatched cap simply means a long command never
12
+ * matches itself and the guard that depends on it quietly never fires.
13
+ */
14
+ class LogTarget {
15
+ /**
16
+ * Collapse newlines and tabs so one decision is always one log line, and cap the length so a
17
+ * pasted heredoc cannot make a log line unreadable. The ellipsis is part of the stored value.
18
+ */
19
+ oneLine(value) {
20
+ const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
21
+ return flat.length <= exports.MAX_TARGET_LEN ? flat : flat.slice(0, exports.MAX_TARGET_LEN) + '…';
22
+ }
23
+ }
24
+ exports.LogTarget = LogTarget;
25
+ /** The cap, exported so a spec can assert against it rather than re-typing the number. */
26
+ exports.MAX_TARGET_LEN = 160;
27
+ /** Process-wide instance: it is a pure function of its input, so there is nothing to construct twice. */
28
+ exports.logTarget = new LogTarget();
29
+ //# sourceMappingURL=log-target.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-target.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/log-target.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;GASG;AACH,MAAa,SAAS;IAClB;;;OAGG;IACH,OAAO,CAAC,KAAa;QACjB,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACrD,OAAO,IAAI,CAAC,MAAM,IAAI,sBAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,sBAAc,CAAC,GAAG,GAAG,CAAC;IACtF,CAAC;CACJ;AATD,8BASC;AAED,0FAA0F;AAC7E,QAAA,cAAc,GAAG,GAAG,CAAC;AAElC,yGAAyG;AAC5F,QAAA,SAAS,GAAG,IAAI,SAAS,EAAE,CAAC","sourcesContent":["/**\n * The ONE spelling of \"how a command or file path is written into a log line's target field\".\n *\n * It exists because the format now has a READER as well as a writer. `InvocationLog` and\n * `logGuardDecision` collapse a command to one capped line before appending it; `SessionCallHistory`\n * has to reproduce that byte for byte to recognise the same command coming back. Two private copies of\n * a collapse-and-cap is exactly the second spelling `.claude/rules/no-backwards-compat.md` rejects —\n * and the failure it produces is silent, since a mismatched cap simply means a long command never\n * matches itself and the guard that depends on it quietly never fires.\n */\nexport class LogTarget {\n /**\n * Collapse newlines and tabs so one decision is always one log line, and cap the length so a\n * pasted heredoc cannot make a log line unreadable. The ellipsis is part of the stored value.\n */\n 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}\n\n/** The cap, exported so a spec can assert against it rather than re-typing the number. */\nexport const MAX_TARGET_LEN = 160;\n\n/** Process-wide instance: it is a pure function of its input, so there is nothing to construct twice. */\nexport const logTarget = new LogTarget();\n"]}
@@ -0,0 +1,93 @@
1
+ import type { BashContext, Violation } from '../types';
2
+ import { BashRuleBase, EmptyRuleConfig } from '../rule-base';
3
+ import { FixHint } from '../fix-hint';
4
+ /**
5
+ * Blocks the two shapes an agent uses to WAIT when it has nothing left to do — a bare `echo .` /
6
+ * `true` / `date` keep-alive, and the same `gh pr checks <n>` asked for the third time — and hands back
7
+ * the ONE blocking command that its kind of agent can actually use instead.
8
+ *
9
+ * ─── The incident, measured (issue #874) ───────────────────────────────────────────────────────────
10
+ * In the 24 hours to 2026-09-07, ten agent runs across four repos spun this way: 920M tokens, 18.3% of
11
+ * every token the fleet spent, ~$1,389. Excluding the single worst run entirely it is still 9.0%. The
12
+ * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per
13
+ * turn, so a three-second `echo .` costs more than most of the work around it.
14
+ *
15
+ * ─── WHY agents do it, which is the part that decides the cure ─────────────────────────────────────
16
+ * Not laziness, and not a missing instruction. A causal chain with no slack in it:
17
+ *
18
+ * 1. A subagent that stops making tool calls is FINISHED — its run returns to its parent. It cannot
19
+ * end its turn and be woken up later; ending the turn IS the end of the agent.
20
+ * 2. `Monitor` does not block. Its own result text says "Keep working — do not poll or sleep", which
21
+ * is precisely what an agent with nothing left to do cannot act on.
22
+ * 3. A `Monitor` with a real polling loop is refused by the HARNESS — 178 of 553 subagent Monitor
23
+ * calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the
24
+ * worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard
25
+ * does not try.
26
+ * 4. So `echo .` every three seconds is the only remaining way to stay alive.
27
+ *
28
+ * A BLOCKING BASH COMMAND is therefore the only wait primitive a worktree-isolated subagent has, which
29
+ * is why `pnpm wp-await-reviews` and `pnpm wp-await-checks` exist and why this guard can afford to
30
+ * refuse: for the first time there is something to refuse INTO.
31
+ *
32
+ * ─── THE CURE DIFFERS BY AGENT KIND, and prescribing the wrong one DESTROYS WORK ───────────────────
33
+ * A main agent can end its turn and be re-invoked by a backgrounded command; that is the cheapest wait
34
+ * there is, and telling it to block a Bash call for ten minutes would be worse advice. A
35
+ * worktree-isolated subagent cannot: "end your turn" kills it mid-wait and loses everything it has
36
+ * done. One cure per kind, and the kind is read the same way the decision log stamps it —
37
+ * `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the primary clone. Same call,
38
+ * so a `tree=` column and this verdict cannot disagree.
39
+ *
40
+ * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
41
+ * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
42
+ * NEW key under `hookGuards` is a key every consumer must ADD or have every Bash call blocked on
43
+ * upgrade (fault Y) — that shipped once, with `whole-repo-build-guard`. And there is nothing for a
44
+ * switch to rescue: the cure is available for every input, is strictly better than what was blocked,
45
+ * and can never itself match this guard.
46
+ */
47
+ export declare class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {
48
+ constructor();
49
+ private readonly scan;
50
+ private readonly history;
51
+ readonly description: string;
52
+ get fixHint(): FixHint;
53
+ check(ctx: BashContext): readonly Violation[];
54
+ /**
55
+ * ONE `gh pr checks 874` is a snapshot somebody acts on and must stay allowed — the difference
56
+ * between asking a question and refusing to stop asking it is the number of times, and nothing else.
57
+ * So the third identical call is the one refused, and the count comes from this session's own call
58
+ * log (which holds calls 1..N-1 by the time this runs) and fails OPEN when it cannot be read.
59
+ */
60
+ private judgePoll;
61
+ private allow;
62
+ private block;
63
+ private message;
64
+ private what;
65
+ /**
66
+ * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a
67
+ * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.
68
+ */
69
+ private cure;
70
+ /**
71
+ * A linked worktree means a worktree-isolated subagent — git's own answer, via the same call the
72
+ * decision log's `tree=` column makes. Fails to `false` (the main-agent cure) only when git says
73
+ * this is the primary clone, which is what the primary clone is.
74
+ */
75
+ private isWorktreeIsolated;
76
+ private truncate;
77
+ private logDecision;
78
+ }
79
+ /**
80
+ * How many identical prior calls make the next one a spin. TWO, so the THIRD is refused: one call is a
81
+ * snapshot, two is a re-check after doing something, three in a row with nothing in between is a loop.
82
+ */
83
+ export declare const REPEATS_BEFORE_REFUSAL = 2;
84
+ /**
85
+ * The subagent cure. It opens by saying what the agent must NOT do, because "end your turn" is the
86
+ * advice a subagent is most likely to reach for and the one that destroys it.
87
+ */
88
+ export declare const SUBAGENT_CURE: string;
89
+ /**
90
+ * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling
91
+ * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.
92
+ */
93
+ export declare const MAIN_AGENT_CURE: string;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAIN_AGENT_CURE = exports.SUBAGENT_CURE = exports.REPEATS_BEFORE_REFUSAL = exports.WaitSpinGuardRule = void 0;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ const types_1 = require("../types");
6
+ const rule_base_1 = require("../rule-base");
7
+ const fix_hint_1 = require("../fix-hint");
8
+ const l0_fault_codes_1 = require("../l0-fault-codes");
9
+ const decision_log_1 = require("../decision-log");
10
+ const command_scan_1 = require("../command-scan");
11
+ const session_call_history_1 = require("../session-call-history");
12
+ const shell_segment_scan_1 = require("./shell-segment-scan");
13
+ const wait_spin_scan_1 = require("./wait-spin-scan");
14
+ /**
15
+ * Blocks the two shapes an agent uses to WAIT when it has nothing left to do — a bare `echo .` /
16
+ * `true` / `date` keep-alive, and the same `gh pr checks <n>` asked for the third time — and hands back
17
+ * the ONE blocking command that its kind of agent can actually use instead.
18
+ *
19
+ * ─── The incident, measured (issue #874) ───────────────────────────────────────────────────────────
20
+ * In the 24 hours to 2026-09-07, ten agent runs across four repos spun this way: 920M tokens, 18.3% of
21
+ * every token the fleet spent, ~$1,389. Excluding the single worst run entirely it is still 9.0%. The
22
+ * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per
23
+ * turn, so a three-second `echo .` costs more than most of the work around it.
24
+ *
25
+ * ─── WHY agents do it, which is the part that decides the cure ─────────────────────────────────────
26
+ * Not laziness, and not a missing instruction. A causal chain with no slack in it:
27
+ *
28
+ * 1. A subagent that stops making tool calls is FINISHED — its run returns to its parent. It cannot
29
+ * end its turn and be woken up later; ending the turn IS the end of the agent.
30
+ * 2. `Monitor` does not block. Its own result text says "Keep working — do not poll or sleep", which
31
+ * is precisely what an agent with nothing left to do cannot act on.
32
+ * 3. A `Monitor` with a real polling loop is refused by the HARNESS — 178 of 553 subagent Monitor
33
+ * calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the
34
+ * worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard
35
+ * does not try.
36
+ * 4. So `echo .` every three seconds is the only remaining way to stay alive.
37
+ *
38
+ * A BLOCKING BASH COMMAND is therefore the only wait primitive a worktree-isolated subagent has, which
39
+ * is why `pnpm wp-await-reviews` and `pnpm wp-await-checks` exist and why this guard can afford to
40
+ * refuse: for the first time there is something to refuse INTO.
41
+ *
42
+ * ─── THE CURE DIFFERS BY AGENT KIND, and prescribing the wrong one DESTROYS WORK ───────────────────
43
+ * A main agent can end its turn and be re-invoked by a backgrounded command; that is the cheapest wait
44
+ * there is, and telling it to block a Bash call for ten minutes would be worse advice. A
45
+ * worktree-isolated subagent cannot: "end your turn" kills it mid-wait and loses everything it has
46
+ * done. One cure per kind, and the kind is read the same way the decision log stamps it —
47
+ * `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the primary clone. Same call,
48
+ * so a `tree=` column and this verdict cannot disagree.
49
+ *
50
+ * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
51
+ * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
52
+ * NEW key under `hookGuards` is a key every consumer must ADD or have every Bash call blocked on
53
+ * upgrade (fault Y) — that shipped once, with `whole-repo-build-guard`. And there is nothing for a
54
+ * switch to rescue: the cure is available for every input, is strictly better than what was blocked,
55
+ * and can never itself match this guard.
56
+ */
57
+ class WaitSpinGuardRule extends rule_base_1.BashRuleBase {
58
+ constructor() {
59
+ // configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class
60
+ // docstring on fault Y.
61
+ super(new rule_base_1.EmptyRuleConfig(), 'wait-spin-guard', 'wait-spin-guard');
62
+ }
63
+ scan = new wait_spin_scan_1.WaitSpinScan(new command_scan_1.CommandScanner(), new shell_segment_scan_1.ShellSegmentScan());
64
+ history = new session_call_history_1.SessionCallHistory();
65
+ description = 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +
66
+ 'the same gh pr checks/view asked a third time — and name the ONE blocking wait command the ' +
67
+ 'calling agent kind can use: pnpm wp-await-reviews / pnpm wp-await-checks for a worktree ' +
68
+ 'subagent, a Monitor plus ending the turn for a main agent.';
69
+ get fixHint() {
70
+ return new fix_hint_1.FixHint('This command does nothing except keep your turn alive, and a turn costs your whole context — '
71
+ + '~557,000 tokens measured, whatever the command was.', 'Wait by BLOCKING on one command instead of by taking turns. The line above names the one '
72
+ + 'that fits the agent you are; it is the only one to run.', []);
73
+ }
74
+ check(ctx) {
75
+ // Blocklist-shaped, so match on commandCode — stripping heredocs and quoted prose can only ever
76
+ // block LESS, and this repo's docs and commit messages are full of these command names.
77
+ const hit = this.scan.classify(ctx.commandCode);
78
+ if (hit === null)
79
+ return this.allow(ctx, 'not-a-wait-spin');
80
+ if (hit.shape === wait_spin_scan_1.SPIN_POLL)
81
+ return this.judgePoll(ctx, hit);
82
+ return this.block(ctx, hit);
83
+ }
84
+ /**
85
+ * ONE `gh pr checks 874` is a snapshot somebody acts on and must stay allowed — the difference
86
+ * between asking a question and refusing to stop asking it is the number of times, and nothing else.
87
+ * So the third identical call is the one refused, and the count comes from this session's own call
88
+ * log (which holds calls 1..N-1 by the time this runs) and fails OPEN when it cannot be read.
89
+ */
90
+ judgePoll(ctx, hit) {
91
+ const prior = this.history.priorBashCalls(ctx.workspaceRoot, ctx.command);
92
+ if (prior < exports.REPEATS_BEFORE_REFUSAL)
93
+ return this.allow(ctx, `poll-${String(prior)}-prior`);
94
+ return this.block(ctx, hit);
95
+ }
96
+ allow(ctx, reason) {
97
+ this.logDecision(ctx, 'ALLOW', reason);
98
+ return [];
99
+ }
100
+ block(ctx, hit) {
101
+ // BLOCK_AI_CURE: the cure is a command the agent runs itself, right now, in place of this one.
102
+ this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.shape}-${hit.program}`);
103
+ return [new types_1.Violation(1, this.truncate(ctx.command), this.message(ctx, hit))];
104
+ }
105
+ // Short on purpose: it is read mid-wait by an agent that needs the ONE next move.
106
+ message(ctx, hit) {
107
+ return `Blocked: \`${hit.program}\` ${this.what(hit)}. `
108
+ + 'Every turn resends your whole conversation — ~557,000 tokens measured — so waiting by '
109
+ + 'taking turns is the most expensive thing you can do, and it was 18.3% of all fleet '
110
+ + 'tokens in one measured day.\n\n'
111
+ + this.cure(ctx);
112
+ }
113
+ what(hit) {
114
+ return hit.shape === wait_spin_scan_1.SPIN_POLL
115
+ ? 'has already been run twice in this session with the identical arguments, and the answer '
116
+ + 'has not changed because you have not done anything between the calls'
117
+ : 'does nothing at all — it exists only to keep your turn alive';
118
+ }
119
+ /**
120
+ * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a
121
+ * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.
122
+ */
123
+ cure(ctx) {
124
+ return this.isWorktreeIsolated(ctx) ? exports.SUBAGENT_CURE : exports.MAIN_AGENT_CURE;
125
+ }
126
+ /**
127
+ * A linked worktree means a worktree-isolated subagent — git's own answer, via the same call the
128
+ * decision log's `tree=` column makes. Fails to `false` (the main-agent cure) only when git says
129
+ * this is the primary clone, which is what the primary clone is.
130
+ */
131
+ isWorktreeIsolated(ctx) {
132
+ return rules_config_1.dotWebpieces.worktreeName(ctx.workspaceRoot) !== '';
133
+ }
134
+ truncate(s) {
135
+ const MAX = 120;
136
+ return s.length <= MAX ? s : s.slice(0, MAX) + '…';
137
+ }
138
+ logDecision(ctx, verdict, reason) {
139
+ (0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('wait-spin-guard', 'Bash', ctx.command, '-', verdict, reason, '-', l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.MATRIX_L2_UNROWED));
140
+ }
141
+ }
142
+ exports.WaitSpinGuardRule = WaitSpinGuardRule;
143
+ /**
144
+ * How many identical prior calls make the next one a spin. TWO, so the THIRD is refused: one call is a
145
+ * snapshot, two is a re-check after doing something, three in a row with nothing in between is a loop.
146
+ */
147
+ exports.REPEATS_BEFORE_REFUSAL = 2;
148
+ /**
149
+ * The subagent cure. It opens by saying what the agent must NOT do, because "end your turn" is the
150
+ * advice a subagent is most likely to reach for and the one that destroys it.
151
+ */
152
+ exports.SUBAGENT_CURE = 'You are a worktree-isolated subagent, so you CANNOT end your turn and be woken up — ending it ends\n'
153
+ + 'your run and loses this work. Block on one command instead. It heartbeats while it waits and\n'
154
+ + 'returns as soon as there is something to do:\n\n'
155
+ + ' pnpm wp-await-reviews # waiting on reviewer subagents you spawned\n'
156
+ + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\n\n'
157
+ + 'Either one exits before the harness ceiling and tells you to run it again if the wait is longer,\n'
158
+ + 'so a long wait costs about ten calls rather than several hundred.';
159
+ /**
160
+ * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling
161
+ * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.
162
+ */
163
+ exports.MAIN_AGENT_CURE = 'You are the main agent, so you have the cheapest wait there is: start a `Monitor` (or run the\n'
164
+ + 'command you are waiting on with run_in_background) and then END YOUR TURN. A backgrounded command\n'
165
+ + 're-invokes you when it exits, and you burn nothing at all in the meantime.';
166
+ //# sourceMappingURL=wait-spin-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wait-spin-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-guard.ts"],"names":[],"mappings":";;;AAAA,0DAAuD;AAGvD,oCAA0C;AAC1C,4CAA6D;AAC7D,0CAAsC;AACtC,sDAAkD;AAClD,kDAA8F;AAC9F,kDAAiD;AACjD,kEAA6D;AAC7D,6DAAwD;AACxD,qDAAwE;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAa,iBAAkB,SAAQ,wBAA6B;IAChE;QACI,8FAA8F;QAC9F,wBAAwB;QACxB,KAAK,CAAC,IAAI,2BAAe,EAAE,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,CAAC;IACvE,CAAC;IAEgB,IAAI,GAAG,IAAI,6BAAY,CAAC,IAAI,6BAAc,EAAE,EAAE,IAAI,qCAAgB,EAAE,CAAC,CAAC;IACtE,OAAO,GAAG,IAAI,yCAAkB,EAAE,CAAC;IAE3C,WAAW,GAChB,gGAAgG;QAChG,6FAA6F;QAC7F,0FAA0F;QAC1F,4DAA4D,CAAC;IAEjE,IAAI,OAAO;QACP,OAAO,IAAI,kBAAO,CACd,+FAA+F;cAC7F,qDAAqD,EACvD,2FAA2F;cACzF,yDAAyD,EAC3D,EAAE,CACL,CAAC;IACN,CAAC;IAED,KAAK,CAAC,GAAgB;QAClB,gGAAgG;QAChG,wFAAwF;QACxF,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAChD,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;QAC5D,IAAI,GAAG,CAAC,KAAK,KAAK,0BAAS;YAAE,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC7D,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAChC,CAAC;IAED;;;;;OAKG;IACK,SAAS,CAAC,GAAgB,EAAE,GAAgB;QAChD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,GAAG,CAAC,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;QAC1E,IAAI,KAAK,GAAG,8BAAsB;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,QAAQ,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC1F,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAChC,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAc;QAC1C,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;QACvC,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,GAAgB;QAC5C,+FAA+F;QAC/F,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;QACtE,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC;IAC1E,CAAC;IAED,kFAAkF;IAC1E,OAAO,CAAC,GAAgB,EAAE,GAAgB;QAC9C,OAAO,cAAc,GAAG,CAAC,OAAO,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI;cAClD,wFAAwF;cACxF,qFAAqF;cACrF,iCAAiC;cACjC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAEO,IAAI,CAAC,GAAgB;QACzB,OAAO,GAAG,CAAC,KAAK,KAAK,0BAAS;YAC1B,CAAC,CAAC,0FAA0F;kBACtF,sEAAsE;YAC5E,CAAC,CAAC,8DAA8D,CAAC;IACzE,CAAC;IAED;;;OAGG;IACK,IAAI,CAAC,GAAgB;QACzB,OAAO,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,qBAAa,CAAC,CAAC,CAAC,uBAAe,CAAC;IAC1E,CAAC;IAED;;;;OAIG;IACK,kBAAkB,CAAC,GAAgB;QACvC,OAAO,2BAAY,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC;IAC/D,CAAC;IAEO,QAAQ,CAAC,CAAS;QACtB,MAAM,GAAG,GAAG,GAAG,CAAC;QAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;IACvD,CAAC;IAEO,WAAW,CAAC,GAAgB,EAAE,OAAgB,EAAE,MAAc;QAClE,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CACb,iBAAiB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAC5D,GAAG,EAAE,8BAAa,EAAE,gCAAiB,CACxC,CACJ,CAAC;IACN,CAAC;CACJ;AAzGD,8CAyGC;AAED;;;GAGG;AACU,QAAA,sBAAsB,GAAG,CAAC,CAAC;AAExC;;;GAGG;AACU,QAAA,aAAa,GACtB,sGAAsG;MACpG,gGAAgG;MAChG,kDAAkD;MAClD,kFAAkF;MAClF,iEAAiE;MACjE,oGAAoG;MACpG,mEAAmE,CAAC;AAE1E;;;GAGG;AACU,QAAA,eAAe,GACxB,iGAAiG;MAC/F,qGAAqG;MACrG,4EAA4E,CAAC","sourcesContent":["import { dotWebpieces } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { SessionCallHistory } from '../session-call-history';\nimport { ShellSegmentScan } from './shell-segment-scan';\nimport { WaitSpinScan, WaitSpinHit, SPIN_POLL } from './wait-spin-scan';\n\n/**\n * Blocks the two shapes an agent uses to WAIT when it has nothing left to do — a bare `echo .` /\n * `true` / `date` keep-alive, and the same `gh pr checks <n>` asked for the third time — and hands back\n * the ONE blocking command that its kind of agent can actually use instead.\n *\n * ─── The incident, measured (issue #874) ───────────────────────────────────────────────────────────\n * In the 24 hours to 2026-09-07, ten agent runs across four repos spun this way: 920M tokens, 18.3% of\n * every token the fleet spent, ~$1,389. Excluding the single worst run entirely it is still 9.0%. The\n * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per\n * turn, so a three-second `echo .` costs more than most of the work around it.\n *\n * ─── WHY agents do it, which is the part that decides the cure ─────────────────────────────────────\n * Not laziness, and not a missing instruction. A causal chain with no slack in it:\n *\n * 1. A subagent that stops making tool calls is FINISHED — its run returns to its parent. It cannot\n * end its turn and be woken up later; ending the turn IS the end of the agent.\n * 2. `Monitor` does not block. Its own result text says \"Keep working — do not poll or sleep\", which\n * is precisely what an agent with nothing left to do cannot act on.\n * 3. A `Monitor` with a real polling loop is refused by the HARNESS — 178 of 553 subagent Monitor\n * calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the\n * worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard\n * does not try.\n * 4. So `echo .` every three seconds is the only remaining way to stay alive.\n *\n * A BLOCKING BASH COMMAND is therefore the only wait primitive a worktree-isolated subagent has, which\n * is why `pnpm wp-await-reviews` and `pnpm wp-await-checks` exist and why this guard can afford to\n * refuse: for the first time there is something to refuse INTO.\n *\n * ─── THE CURE DIFFERS BY AGENT KIND, and prescribing the wrong one DESTROYS WORK ───────────────────\n * A main agent can end its turn and be re-invoked by a backgrounded command; that is the cheapest wait\n * there is, and telling it to block a Bash call for ten minutes would be worse advice. A\n * worktree-isolated subagent cannot: \"end your turn\" kills it mid-wait and loses everything it has\n * done. One cure per kind, and the kind is read the same way the decision log stamps it —\n * `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the primary clone. Same call,\n * so a `tree=` column and this verdict cannot disagree.\n *\n * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────\n * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A\n * NEW key under `hookGuards` is a key every consumer must ADD or have every Bash call blocked on\n * upgrade (fault Y) — that shipped once, with `whole-repo-build-guard`. And there is nothing for a\n * switch to rescue: the cure is available for every input, is strictly better than what was blocked,\n * and can never itself match this guard.\n */\nexport class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {\n constructor() {\n // configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class\n // docstring on fault Y.\n super(new EmptyRuleConfig(), 'wait-spin-guard', 'wait-spin-guard');\n }\n\n private readonly scan = new WaitSpinScan(new CommandScanner(), new ShellSegmentScan());\n private readonly history = new SessionCallHistory();\n\n readonly description =\n 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +\n 'the same gh pr checks/view asked a third time — and name the ONE blocking wait command the ' +\n 'calling agent kind can use: pnpm wp-await-reviews / pnpm wp-await-checks for a worktree ' +\n 'subagent, a Monitor plus ending the turn for a main agent.';\n\n get fixHint(): FixHint {\n return new FixHint(\n 'This command does nothing except keep your turn alive, and a turn costs your whole context — '\n + '~557,000 tokens measured, whatever the command was.',\n 'Wait by BLOCKING on one command instead of by taking turns. The line above names the one '\n + 'that fits the agent you are; it is the only one to run.',\n [],\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // Blocklist-shaped, so match on commandCode — stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's docs and commit messages are full of these command names.\n const hit = this.scan.classify(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-wait-spin');\n if (hit.shape === SPIN_POLL) return this.judgePoll(ctx, hit);\n return this.block(ctx, hit);\n }\n\n /**\n * ONE `gh pr checks 874` is a snapshot somebody acts on and must stay allowed — the difference\n * between asking a question and refusing to stop asking it is the number of times, and nothing else.\n * So the third identical call is the one refused, and the count comes from this session's own call\n * log (which holds calls 1..N-1 by the time this runs) and fails OPEN when it cannot be read.\n */\n private judgePoll(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n const prior = this.history.priorBashCalls(ctx.workspaceRoot, ctx.command);\n if (prior < REPEATS_BEFORE_REFUSAL) return this.allow(ctx, `poll-${String(prior)}-prior`);\n return this.block(ctx, hit);\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n // BLOCK_AI_CURE: the cure is a command the agent runs itself, right now, in place of this one.\n this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.shape}-${hit.program}`);\n return [new V(1, this.truncate(ctx.command), this.message(ctx, hit))];\n }\n\n // Short on purpose: it is read mid-wait by an agent that needs the ONE next move.\n private message(ctx: BashContext, hit: WaitSpinHit): string {\n return `Blocked: \\`${hit.program}\\` ${this.what(hit)}. `\n + 'Every turn resends your whole conversation — ~557,000 tokens measured — so waiting by '\n + 'taking turns is the most expensive thing you can do, and it was 18.3% of all fleet '\n + 'tokens in one measured day.\\n\\n'\n + this.cure(ctx);\n }\n\n private what(hit: WaitSpinHit): string {\n return hit.shape === SPIN_POLL\n ? 'has already been run twice in this session with the identical arguments, and the answer '\n + 'has not changed because you have not done anything between the calls'\n : 'does nothing at all — it exists only to keep your turn alive';\n }\n\n /**\n * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a\n * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.\n */\n private cure(ctx: BashContext): string {\n return this.isWorktreeIsolated(ctx) ? SUBAGENT_CURE : MAIN_AGENT_CURE;\n }\n\n /**\n * A linked worktree means a worktree-isolated subagent — git's own answer, via the same call the\n * decision log's `tree=` column makes. Fails to `false` (the main-agent cure) only when git says\n * this is the primary clone, which is what the primary clone is.\n */\n private isWorktreeIsolated(ctx: BashContext): boolean {\n return dotWebpieces.worktreeName(ctx.workspaceRoot) !== '';\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision(\n 'wait-spin-guard', 'Bash', ctx.command, '-', verdict, reason,\n '-', L0_FAULT_NONE, MATRIX_L2_UNROWED,\n ),\n );\n }\n}\n\n/**\n * How many identical prior calls make the next one a spin. TWO, so the THIRD is refused: one call is a\n * snapshot, two is a re-check after doing something, three in a row with nothing in between is a loop.\n */\nexport const REPEATS_BEFORE_REFUSAL = 2;\n\n/**\n * The subagent cure. It opens by saying what the agent must NOT do, because \"end your turn\" is the\n * advice a subagent is most likely to reach for and the one that destroys it.\n */\nexport const SUBAGENT_CURE =\n 'You are a worktree-isolated subagent, so you CANNOT end your turn and be woken up — ending it ends\\n'\n + 'your run and loses this work. Block on one command instead. It heartbeats while it waits and\\n'\n + 'returns as soon as there is something to do:\\n\\n'\n + ' pnpm wp-await-reviews # waiting on reviewer subagents you spawned\\n'\n + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\\n\\n'\n + 'Either one exits before the harness ceiling and tells you to run it again if the wait is longer,\\n'\n + 'so a long wait costs about ten calls rather than several hundred.';\n\n/**\n * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling\n * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.\n */\nexport const MAIN_AGENT_CURE =\n 'You are the main agent, so you have the cheapest wait there is: start a `Monitor` (or run the\\n'\n + 'command you are waiting on with run_in_background) and then END YOUR TURN. A backgrounded command\\n'\n + 're-invokes you when it exits, and you burn nothing at all in the meantime.';\n"]}
@@ -0,0 +1,69 @@
1
+ import { CommandScanner } from '../command-scan';
2
+ import { ShellSegmentScan } from './shell-segment-scan';
3
+ /**
4
+ * Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,
5
+ * a way of staying alive rather than a way of doing something?
6
+ *
7
+ * ─── The two shapes, and why only these ────────────────────────────────────────────────────────────
8
+ *
9
+ * NO-OP the whole command produces no effect an agent could act on — `echo .`, `echo idle3`,
10
+ * `true`, `:`, `date -u +%H:%M`. Measured across the fleet in the 24h to 2026-09-07: ten
11
+ * agent runs spun this way, 920M tokens (18.3% of fleet tokens) burned, because every turn
12
+ * resends the whole conversation at ~557k tokens. Nobody types these to learn anything.
13
+ *
14
+ * POLL an identical `gh pr checks <n>` / `gh pr view <n>`, run over and over. ONE of these is a
15
+ * legitimate snapshot and must stay allowed — it is the difference between asking a
16
+ * question and refusing to stop asking it — so this scan only CLASSIFIES the shape, and
17
+ * the guard decides on the session's own call history whether it has been asked before.
18
+ *
19
+ * ─── What is NOT a hit, and why the carve-outs are the whole risk ──────────────────────────────────
20
+ * `echo` is everywhere in legitimate work, and a false positive here breaks ordinary commands rather
21
+ * than merely annoying somebody. Three rules keep it honest, and every one of them is drawn from real
22
+ * lines in this repo's own guard logs:
23
+ *
24
+ * ONE SEGMENT ONLY `echo "=== IN-SCOPE DIFF ===" && git diff …` and
25
+ * `sed -n 1,50p f.ts; echo ---; sed -n 60,90p f.ts` are compound commands that DO
26
+ * something. A command with a second segment is never a hit, whatever the first
27
+ * segment is.
28
+ * NO REDIRECT `echo "$body" > /tmp/pr-body.md` writes a file. `ShellSegmentScan.redirectsToFile`
29
+ * owns that test (and its `2>&1` carve-out), shared rather than re-spelled here.
30
+ * NO PIPE a pipe is a second segment, so this falls out of the segment rule; `date | xargs …`
31
+ * is a real command and is allowed by construction.
32
+ *
33
+ * The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare
34
+ * token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a
35
+ * spin costs one turn, and refusing a real command costs the task.
36
+ */
37
+ /** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */
38
+ export declare const SPIN_NOOP = "noop";
39
+ /** The whole command is a PR/CI status read. Only a REPEAT of one is a spin; the guard decides that. */
40
+ export declare const SPIN_POLL = "poll";
41
+ /** Programs whose bare invocation is a keep-alive and nothing else. */
42
+ export declare const NOOP_PROGRAMS: readonly string[];
43
+ /** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */
44
+ export declare const POLLED_GH_SUBCOMMANDS: readonly string[];
45
+ /** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */
46
+ export declare class WaitSpinHit {
47
+ shape: string;
48
+ program: string;
49
+ constructor(shape: string, program: string);
50
+ }
51
+ export declare class WaitSpinScan {
52
+ private readonly scanner;
53
+ private readonly segments;
54
+ constructor(scanner: CommandScanner, segments: ShellSegmentScan);
55
+ /** The spin shape this whole command is, or null when it is a real command. */
56
+ classify(command: string): WaitSpinHit | null;
57
+ private classifyWords;
58
+ /**
59
+ * `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted
60
+ * prose is stripped out of `commandCode`, so `echo "<any sentence>"` arrives here as a bare `echo`,
61
+ * and blocking that shape refuses a command whose content nobody ever looked at.
62
+ *
63
+ * `true`, `:` and `date` are judged whole: `date -u +%H:%M` prints the clock and nothing else,
64
+ * whatever its format string.
65
+ */
66
+ private isNoop;
67
+ private isPoll;
68
+ private pollLabel;
69
+ }
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WaitSpinScan = exports.WaitSpinHit = exports.POLLED_GH_SUBCOMMANDS = exports.NOOP_PROGRAMS = exports.SPIN_POLL = exports.SPIN_NOOP = void 0;
4
+ /**
5
+ * Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,
6
+ * a way of staying alive rather than a way of doing something?
7
+ *
8
+ * ─── The two shapes, and why only these ────────────────────────────────────────────────────────────
9
+ *
10
+ * NO-OP the whole command produces no effect an agent could act on — `echo .`, `echo idle3`,
11
+ * `true`, `:`, `date -u +%H:%M`. Measured across the fleet in the 24h to 2026-09-07: ten
12
+ * agent runs spun this way, 920M tokens (18.3% of fleet tokens) burned, because every turn
13
+ * resends the whole conversation at ~557k tokens. Nobody types these to learn anything.
14
+ *
15
+ * POLL an identical `gh pr checks <n>` / `gh pr view <n>`, run over and over. ONE of these is a
16
+ * legitimate snapshot and must stay allowed — it is the difference between asking a
17
+ * question and refusing to stop asking it — so this scan only CLASSIFIES the shape, and
18
+ * the guard decides on the session's own call history whether it has been asked before.
19
+ *
20
+ * ─── What is NOT a hit, and why the carve-outs are the whole risk ──────────────────────────────────
21
+ * `echo` is everywhere in legitimate work, and a false positive here breaks ordinary commands rather
22
+ * than merely annoying somebody. Three rules keep it honest, and every one of them is drawn from real
23
+ * lines in this repo's own guard logs:
24
+ *
25
+ * ONE SEGMENT ONLY `echo "=== IN-SCOPE DIFF ===" && git diff …` and
26
+ * `sed -n 1,50p f.ts; echo ---; sed -n 60,90p f.ts` are compound commands that DO
27
+ * something. A command with a second segment is never a hit, whatever the first
28
+ * segment is.
29
+ * NO REDIRECT `echo "$body" > /tmp/pr-body.md` writes a file. `ShellSegmentScan.redirectsToFile`
30
+ * owns that test (and its `2>&1` carve-out), shared rather than re-spelled here.
31
+ * NO PIPE a pipe is a second segment, so this falls out of the segment rule; `date | xargs …`
32
+ * is a real command and is allowed by construction.
33
+ *
34
+ * The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare
35
+ * token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a
36
+ * spin costs one turn, and refusing a real command costs the task.
37
+ */
38
+ /** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */
39
+ exports.SPIN_NOOP = 'noop';
40
+ /** The whole command is a PR/CI status read. Only a REPEAT of one is a spin; the guard decides that. */
41
+ exports.SPIN_POLL = 'poll';
42
+ /** Programs whose bare invocation is a keep-alive and nothing else. */
43
+ exports.NOOP_PROGRAMS = ['echo', 'true', ':', 'date'];
44
+ /** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */
45
+ exports.POLLED_GH_SUBCOMMANDS = ['checks', 'view'];
46
+ const NOOP_SET = new Set(exports.NOOP_PROGRAMS);
47
+ const POLLED_SET = new Set(exports.POLLED_GH_SUBCOMMANDS);
48
+ /**
49
+ * The KEEP-ALIVE TOKENS, and nothing wider.
50
+ *
51
+ * This started as "any short bare token" and that was measurably too wide. Two real commands matched:
52
+ * `echo hi`, which is a benign line in this package's own golden fixtures, and the prose-stripped
53
+ * remains of an `echo "<a sentence>"`, which `runner.spec.ts` asserts is NOT blocked. Both are commands
54
+ * somebody meant, and a guard that refuses those is a guard someone turns off.
55
+ *
56
+ * So the family is NAMED rather than inferred: the dot runs, plus the words the measured spins actually
57
+ * used, each free to carry a numeric or hyphenated tail (`idle3`, `waiting-for-reviewers`). Anything
58
+ * else `echo` prints is content, and content is allowed.
59
+ */
60
+ const KEEP_ALIVE_TOKEN = /^(?:\.{1,3}|(?:idle|ok|okay|waiting|wait|standby|still|alive|ping|pong|tick|noop|nop|heartbeat|zzz)[a-z0-9_-]*)$/i;
61
+ /** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */
62
+ class WaitSpinHit {
63
+ shape;
64
+ program;
65
+ constructor(shape, program) {
66
+ this.shape = shape;
67
+ this.program = program;
68
+ }
69
+ }
70
+ exports.WaitSpinHit = WaitSpinHit;
71
+ class WaitSpinScan {
72
+ scanner;
73
+ segments;
74
+ constructor(scanner, segments) {
75
+ this.scanner = scanner;
76
+ this.segments = segments;
77
+ }
78
+ /** The spin shape this whole command is, or null when it is a real command. */
79
+ classify(command) {
80
+ const parts = this.scanner.segmentsWithJoins(command);
81
+ if (parts.length !== 1)
82
+ return null;
83
+ const words = this.scanner.runnerStrippedWords(parts[0].text);
84
+ if (words.length === 0)
85
+ return null;
86
+ if (this.segments.redirectsToFile(words))
87
+ return null;
88
+ return this.classifyWords(words);
89
+ }
90
+ classifyWords(words) {
91
+ const program = this.scanner.programName(words[0]);
92
+ if (this.isPoll(program, words))
93
+ return new WaitSpinHit(exports.SPIN_POLL, this.pollLabel(words));
94
+ if (!NOOP_SET.has(program))
95
+ return null;
96
+ return this.isNoop(program, words) ? new WaitSpinHit(exports.SPIN_NOOP, program) : null;
97
+ }
98
+ /**
99
+ * `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted
100
+ * prose is stripped out of `commandCode`, so `echo "<any sentence>"` arrives here as a bare `echo`,
101
+ * and blocking that shape refuses a command whose content nobody ever looked at.
102
+ *
103
+ * `true`, `:` and `date` are judged whole: `date -u +%H:%M` prints the clock and nothing else,
104
+ * whatever its format string.
105
+ */
106
+ isNoop(program, words) {
107
+ if (program === 'true' || program === ':')
108
+ return words.length === 1;
109
+ if (program === 'date')
110
+ return true;
111
+ return words.length === 2 && KEEP_ALIVE_TOKEN.test(words[1]);
112
+ }
113
+ isPoll(program, words) {
114
+ return program === 'gh' && words.length >= 3 && words[1] === 'pr' && POLLED_SET.has(words[2]);
115
+ }
116
+ // `gh pr checks` / `gh pr view` — the label the refusal prints, without the PR number or the flags,
117
+ // which the guard already has in the raw command.
118
+ pollLabel(words) {
119
+ return `gh pr ${words[2]}`;
120
+ }
121
+ }
122
+ exports.WaitSpinScan = WaitSpinScan;
123
+ //# sourceMappingURL=wait-spin-scan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wait-spin-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,\n * a way of staying alive rather than a way of doing something?\n *\n * ─── The two shapes, and why only these ────────────────────────────────────────────────────────────\n *\n * NO-OP the whole command produces no effect an agent could act on — `echo .`, `echo idle3`,\n * `true`, `:`, `date -u +%H:%M`. Measured across the fleet in the 24h to 2026-09-07: ten\n * agent runs spun this way, 920M tokens (18.3% of fleet tokens) burned, because every turn\n * resends the whole conversation at ~557k tokens. Nobody types these to learn anything.\n *\n * POLL an identical `gh pr checks <n>` / `gh pr view <n>`, run over and over. ONE of these is a\n * legitimate snapshot and must stay allowed — it is the difference between asking a\n * question and refusing to stop asking it — so this scan only CLASSIFIES the shape, and\n * the guard decides on the session's own call history whether it has been asked before.\n *\n * ─── What is NOT a hit, and why the carve-outs are the whole risk ──────────────────────────────────\n * `echo` is everywhere in legitimate work, and a false positive here breaks ordinary commands rather\n * than merely annoying somebody. Three rules keep it honest, and every one of them is drawn from real\n * lines in this repo's own guard logs:\n *\n * ONE SEGMENT ONLY `echo \"=== IN-SCOPE DIFF ===\" && git diff …` and\n * `sed -n 1,50p f.ts; echo ---; sed -n 60,90p f.ts` are compound commands that DO\n * something. A command with a second segment is never a hit, whatever the first\n * segment is.\n * NO REDIRECT `echo \"$body\" > /tmp/pr-body.md` writes a file. `ShellSegmentScan.redirectsToFile`\n * owns that test (and its `2>&1` carve-out), shared rather than re-spelled here.\n * NO PIPE a pipe is a second segment, so this falls out of the segment rule; `date | xargs …`\n * is a real command and is allowed by construction.\n *\n * The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare\n * token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a\n * spin costs one turn, and refusing a real command costs the task.\n */\n\n/** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */\nexport const SPIN_NOOP = 'noop';\n/** The whole command is a PR/CI status read. Only a REPEAT of one is a spin; the guard decides that. */\nexport const SPIN_POLL = 'poll';\n\n/** Programs whose bare invocation is a keep-alive and nothing else. */\nexport const NOOP_PROGRAMS: readonly string[] = ['echo', 'true', ':', 'date'];\n\n/** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */\nexport const POLLED_GH_SUBCOMMANDS: readonly string[] = ['checks', 'view'];\n\nconst NOOP_SET: ReadonlySet<string> = new Set(NOOP_PROGRAMS);\nconst POLLED_SET: ReadonlySet<string> = new Set(POLLED_GH_SUBCOMMANDS);\n\n/**\n * The KEEP-ALIVE TOKENS, and nothing wider.\n *\n * This started as \"any short bare token\" and that was measurably too wide. Two real commands matched:\n * `echo hi`, which is a benign line in this package's own golden fixtures, and the prose-stripped\n * remains of an `echo \"<a sentence>\"`, which `runner.spec.ts` asserts is NOT blocked. Both are commands\n * somebody meant, and a guard that refuses those is a guard someone turns off.\n *\n * So the family is NAMED rather than inferred: the dot runs, plus the words the measured spins actually\n * used, each free to carry a numeric or hyphenated tail (`idle3`, `waiting-for-reviewers`). Anything\n * else `echo` prints is content, and content is allowed.\n */\nconst KEEP_ALIVE_TOKEN =\n /^(?:\\.{1,3}|(?:idle|ok|okay|waiting|wait|standby|still|alive|ping|pong|tick|noop|nop|heartbeat|zzz)[a-z0-9_-]*)$/i;\n\n/** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */\nexport class WaitSpinHit {\n shape: string;\n program: string;\n\n constructor(shape: string, program: string) {\n this.shape = shape;\n this.program = program;\n }\n}\n\nexport class WaitSpinScan {\n constructor(\n private readonly scanner: CommandScanner,\n private readonly segments: ShellSegmentScan,\n ) {}\n\n /** The spin shape this whole command is, or null when it is a real command. */\n classify(command: string): WaitSpinHit | null {\n const parts = this.scanner.segmentsWithJoins(command);\n if (parts.length !== 1) return null;\n const words = this.scanner.runnerStrippedWords(parts[0].text);\n if (words.length === 0) return null;\n if (this.segments.redirectsToFile(words)) return null;\n return this.classifyWords(words);\n }\n\n private classifyWords(words: readonly string[]): WaitSpinHit | null {\n const program = this.scanner.programName(words[0]);\n if (this.isPoll(program, words)) return new WaitSpinHit(SPIN_POLL, this.pollLabel(words));\n if (!NOOP_SET.has(program)) return null;\n return this.isNoop(program, words) ? new WaitSpinHit(SPIN_NOOP, program) : null;\n }\n\n /**\n * `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted\n * prose is stripped out of `commandCode`, so `echo \"<any sentence>\"` arrives here as a bare `echo`,\n * and blocking that shape refuses a command whose content nobody ever looked at.\n *\n * `true`, `:` and `date` are judged whole: `date -u +%H:%M` prints the clock and nothing else,\n * whatever its format string.\n */\n private isNoop(program: string, words: readonly string[]): boolean {\n if (program === 'true' || program === ':') return words.length === 1;\n if (program === 'date') return true;\n return words.length === 2 && KEEP_ALIVE_TOKEN.test(words[1]);\n }\n\n private isPoll(program: string, words: readonly string[]): boolean {\n return program === 'gh' && words.length >= 3 && words[1] === 'pr' && POLLED_SET.has(words[2]);\n }\n\n // `gh pr checks` / `gh pr view` — the label the refusal prints, without the PR number or the flags,\n // which the guard already has in the raw command.\n private pollLabel(words: readonly string[]): string {\n return `gh pr ${words[2]}`;\n }\n}\n"]}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * HOW MANY TIMES has this session already run this exact Bash command?
3
+ *
4
+ * ─── Why a guard is allowed to ask a question about HISTORY at all ─────────────────────────────────
5
+ * Every other guard here answers from the command TEXT plus the tree's git state, and `managed-env.ts`
6
+ * states that preference out loud: same command, same verdict, every time. That rule exists so a
7
+ * verdict cannot depend on where an unrelated earlier command left the shell — an accident.
8
+ *
9
+ * A SPIN is not an accident, and it is not visible in one command. `gh pr checks 874` asked once is a
10
+ * snapshot somebody acts on; asked for the ninetieth time in four minutes it is the wait loop this
11
+ * whole feature exists to delete, and the two are byte-identical. Repetition IS the defect, so
12
+ * repetition is the only thing that can be measured. Nothing else about the call differs.
13
+ *
14
+ * ─── Where the count comes from ────────────────────────────────────────────────────────────────────
15
+ * `InvocationLog` already writes ONE tab-separated line per guards-hook call to
16
+ * `<state>/logs/calls/<sessionId>-<agentId|coordinator>-<hook>.log`, and `logStream` has already been
17
+ * identified with this session and agent by the time a rule's `check()` runs. So the file IS the
18
+ * session, for free — no new state, no new writer, and a worktree-isolated subagent gets its own
19
+ * because the path is resolved from the tree it acts on.
20
+ *
21
+ * Each tool call is its own process and flushes that line at its terminal boundary, so on call N the
22
+ * file holds calls 1..N-1. The count this returns is therefore PRIOR calls, never including the one
23
+ * being judged.
24
+ *
25
+ * ─── It fails OPEN, always ─────────────────────────────────────────────────────────────────────────
26
+ * A missing file (the first call of a session), a rotated one, an unreadable one — all of them mean
27
+ * "no evidence", which is 0, which blocks nothing. A logging failure may never become a refusal.
28
+ */
29
+ export declare class SessionCallHistory {
30
+ /**
31
+ * How many times `command` already appears as a Bash target in THIS session's call log.
32
+ *
33
+ * The comparison is against the same one-line normalization `InvocationLog` writes, so a command
34
+ * spanning two lines in the payload and the same command on one line count as the same command.
35
+ */
36
+ priorBashCalls(root: string, command: string): number;
37
+ /**
38
+ * One logged call is `[<iso>]\tBash\t<target>\tbranch=…` — so field 1 is the tool and field 2 the
39
+ * command. Split rather than `includes`, because a command that MENTIONS another command in its
40
+ * text would otherwise count as that command.
41
+ */
42
+ private isBashCallOf;
43
+ private lines;
44
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionCallHistory = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const rules_config_1 = require("@webpieces/rules-config");
7
+ const log_streams_1 = require("./log-streams");
8
+ const log_stream_1 = require("./log-stream");
9
+ const log_target_1 = require("./log-target");
10
+ const to_error_1 = require("./to-error");
11
+ /**
12
+ * HOW MANY TIMES has this session already run this exact Bash command?
13
+ *
14
+ * ─── Why a guard is allowed to ask a question about HISTORY at all ─────────────────────────────────
15
+ * Every other guard here answers from the command TEXT plus the tree's git state, and `managed-env.ts`
16
+ * states that preference out loud: same command, same verdict, every time. That rule exists so a
17
+ * verdict cannot depend on where an unrelated earlier command left the shell — an accident.
18
+ *
19
+ * A SPIN is not an accident, and it is not visible in one command. `gh pr checks 874` asked once is a
20
+ * snapshot somebody acts on; asked for the ninetieth time in four minutes it is the wait loop this
21
+ * whole feature exists to delete, and the two are byte-identical. Repetition IS the defect, so
22
+ * repetition is the only thing that can be measured. Nothing else about the call differs.
23
+ *
24
+ * ─── Where the count comes from ────────────────────────────────────────────────────────────────────
25
+ * `InvocationLog` already writes ONE tab-separated line per guards-hook call to
26
+ * `<state>/logs/calls/<sessionId>-<agentId|coordinator>-<hook>.log`, and `logStream` has already been
27
+ * identified with this session and agent by the time a rule's `check()` runs. So the file IS the
28
+ * session, for free — no new state, no new writer, and a worktree-isolated subagent gets its own
29
+ * because the path is resolved from the tree it acts on.
30
+ *
31
+ * Each tool call is its own process and flushes that line at its terminal boundary, so on call N the
32
+ * file holds calls 1..N-1. The count this returns is therefore PRIOR calls, never including the one
33
+ * being judged.
34
+ *
35
+ * ─── It fails OPEN, always ─────────────────────────────────────────────────────────────────────────
36
+ * A missing file (the first call of a session), a rotated one, an unreadable one — all of them mean
37
+ * "no evidence", which is 0, which blocks nothing. A logging failure may never become a refusal.
38
+ */
39
+ class SessionCallHistory {
40
+ /**
41
+ * How many times `command` already appears as a Bash target in THIS session's call log.
42
+ *
43
+ * The comparison is against the same one-line normalization `InvocationLog` writes, so a command
44
+ * spanning two lines in the payload and the same command on one line count as the same command.
45
+ */
46
+ priorBashCalls(root, command) {
47
+ const wanted = log_target_1.logTarget.oneLine(command);
48
+ if (wanted === '')
49
+ return 0;
50
+ return this.lines(root).filter((line) => this.isBashCallOf(line, wanted)).length;
51
+ }
52
+ /**
53
+ * One logged call is `[<iso>]\tBash\t<target>\tbranch=…` — so field 1 is the tool and field 2 the
54
+ * command. Split rather than `includes`, because a command that MENTIONS another command in its
55
+ * text would otherwise count as that command.
56
+ */
57
+ isBashCallOf(line, wanted) {
58
+ const fields = line.split('\t');
59
+ return fields.length > 2 && fields[1] === 'Bash' && fields[2] === wanted;
60
+ }
61
+ lines(root) {
62
+ // webpieces-disable no-unmanaged-exceptions -- a log that cannot be read is NO EVIDENCE, which
63
+ // is the fail-open answer; a logging failure must never become a refusal
64
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
65
+ try {
66
+ const file = rules_config_1.dotWebpieces.logsFile(root, log_streams_1.CALLS_STREAM, log_stream_1.logStream.writerFile('.log'));
67
+ if (!fs.existsSync(file))
68
+ return [];
69
+ return fs.readFileSync(file, 'utf8').split('\n');
70
+ }
71
+ catch (err) {
72
+ const error = (0, to_error_1.toError)(err);
73
+ void error;
74
+ return [];
75
+ }
76
+ }
77
+ }
78
+ exports.SessionCallHistory = SessionCallHistory;
79
+ //# sourceMappingURL=session-call-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-call-history.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/session-call-history.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AAEzB,0DAAuD;AAEvD,+CAA6C;AAC7C,6CAAyC;AACzC,6CAAyC;AACzC,yCAAqC;AAErC;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAa,kBAAkB;IAC3B;;;;;OAKG;IACH,cAAc,CAAC,IAAY,EAAE,OAAe;QACxC,MAAM,MAAM,GAAG,sBAAS,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC1C,IAAI,MAAM,KAAK,EAAE;YAAE,OAAO,CAAC,CAAC;QAC5B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAY,EAAW,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IACtG,CAAC;IAED;;;;OAIG;IACK,YAAY,CAAC,IAAY,EAAE,MAAc;QAC7C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAChC,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,IAAI,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IAC7E,CAAC;IAEO,KAAK,CAAC,IAAY;QACtB,+FAA+F;QAC/F,yEAAyE;QACzE,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,IAAI,GAAG,2BAAY,CAAC,QAAQ,CAAC,IAAI,EAAE,0BAAY,EAAE,sBAAS,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;YACrF,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,OAAO,EAAE,CAAC;YACpC,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,EAAE,CAAC;QACd,CAAC;IACL,CAAC;CACJ;AArCD,gDAqCC","sourcesContent":["import * as fs from 'fs';\n\nimport { dotWebpieces } from '@webpieces/rules-config';\n\nimport { CALLS_STREAM } from './log-streams';\nimport { logStream } from './log-stream';\nimport { logTarget } from './log-target';\nimport { toError } from './to-error';\n\n/**\n * HOW MANY TIMES has this session already run this exact Bash command?\n *\n * ─── Why a guard is allowed to ask a question about HISTORY at all ─────────────────────────────────\n * Every other guard here answers from the command TEXT plus the tree's git state, and `managed-env.ts`\n * states that preference out loud: same command, same verdict, every time. That rule exists so a\n * verdict cannot depend on where an unrelated earlier command left the shell — an accident.\n *\n * A SPIN is not an accident, and it is not visible in one command. `gh pr checks 874` asked once is a\n * snapshot somebody acts on; asked for the ninetieth time in four minutes it is the wait loop this\n * whole feature exists to delete, and the two are byte-identical. Repetition IS the defect, so\n * repetition is the only thing that can be measured. Nothing else about the call differs.\n *\n * ─── Where the count comes from ────────────────────────────────────────────────────────────────────\n * `InvocationLog` already writes ONE tab-separated line per guards-hook call to\n * `<state>/logs/calls/<sessionId>-<agentId|coordinator>-<hook>.log`, and `logStream` has already been\n * identified with this session and agent by the time a rule's `check()` runs. So the file IS the\n * session, for free — no new state, no new writer, and a worktree-isolated subagent gets its own\n * because the path is resolved from the tree it acts on.\n *\n * Each tool call is its own process and flushes that line at its terminal boundary, so on call N the\n * file holds calls 1..N-1. The count this returns is therefore PRIOR calls, never including the one\n * being judged.\n *\n * ─── It fails OPEN, always ─────────────────────────────────────────────────────────────────────────\n * A missing file (the first call of a session), a rotated one, an unreadable one — all of them mean\n * \"no evidence\", which is 0, which blocks nothing. A logging failure may never become a refusal.\n */\nexport class SessionCallHistory {\n /**\n * How many times `command` already appears as a Bash target in THIS session's call log.\n *\n * The comparison is against the same one-line normalization `InvocationLog` writes, so a command\n * spanning two lines in the payload and the same command on one line count as the same command.\n */\n priorBashCalls(root: string, command: string): number {\n const wanted = logTarget.oneLine(command);\n if (wanted === '') return 0;\n return this.lines(root).filter((line: string): boolean => this.isBashCallOf(line, wanted)).length;\n }\n\n /**\n * One logged call is `[<iso>]\\tBash\\t<target>\\tbranch=…` — so field 1 is the tool and field 2 the\n * command. Split rather than `includes`, because a command that MENTIONS another command in its\n * text would otherwise count as that command.\n */\n private isBashCallOf(line: string, wanted: string): boolean {\n const fields = line.split('\\t');\n return fields.length > 2 && fields[1] === 'Bash' && fields[2] === wanted;\n }\n\n private lines(root: string): readonly string[] {\n // webpieces-disable no-unmanaged-exceptions -- a log that cannot be read is NO EVIDENCE, which\n // is the fail-open answer; a logging failure must never become a refusal\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const file = dotWebpieces.logsFile(root, CALLS_STREAM, logStream.writerFile('.log'));\n if (!fs.existsSync(file)) return [];\n return fs.readFileSync(file, 'utf8').split('\\n');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return [];\n }\n }\n}\n"]}