@webpieces/ai-hook-rules 0.4.584 → 0.4.587

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.
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logStream = exports.LogStream = void 0;
4
+ /**
5
+ * WHICH LOG FILE does this hook invocation append to?
6
+ *
7
+ * ─── The bug this exists to fix ────────────────────────────────────────────────────────────────────
8
+ * Log paths used to be keyed by the git WORKTREE alone (`<local>/logs/…`). Three separate things share
9
+ * a worktree, so three separate things shared one file:
10
+ *
11
+ * 1. **Parallel hooks.** The hooks reference says "when multiple PreToolUse hooks match a tool call,
12
+ * ALL matching hooks run in parallel". `wp-ai-rules-hook` and `wp-ai-guards-hook` both match
13
+ * Write/Edit/MultiEdit, so on every file edit TWO PROCESSES append to the same file at the same
14
+ * time. L-1's `guarantee-root.sh` makes it three.
15
+ * 2. **Subagents.** A subagent without worktree isolation shares the coordinator's tree.
16
+ * 3. **Whole sessions.** Four Claude Code windows on one clone are four coordinators, and `agent_id`
17
+ * is absent for every one of them — so agent identity alone cannot tell them apart.
18
+ *
19
+ * `O_APPEND` is indivisible only under `PIPE_BUF`, which is **512 bytes on macOS**. Measured
20
+ * 2026-08-06 across three repos: `guard-invocations.log` 208/3306 lines (6.3%) exceed it, max 608 B;
21
+ * `guard-sync-decisions.log` 209/4097 (5.1%), max 625 B. So this tears TODAY, and the corrupted line
22
+ * is exactly the long one — the `recover=` line a human needs most.
23
+ *
24
+ * ─── The key: three dimensions, one FLAT filename ──────────────────────────────────────────────────
25
+ * <local>/logs/<sessionId>-<agentId | "coordinator">-<hook>-<file>.log
26
+ *
27
+ * sessionId separates concurrent Claude Code windows (`session_id`, on every hook payload)
28
+ * agentId separates subagents within one window (`agent_id`, subagent-only — absent = coordinator)
29
+ * hook separates the PARALLEL hooks ('guards' | 'rules' | 'guarantee-root')
30
+ *
31
+ * One writer per FILE, by construction, so appends cannot interleave and nothing needs a lock.
32
+ *
33
+ * DELIBERATELY FLAT, not `sessions/<id>/<agent>/<hook>/<file>`. A nested tree makes the common
34
+ * question — "show me everything that happened, in time order" — into a directory walk, when it should
35
+ * be one glob: `ls logs/` shows every stream at once, `logs/<sid>-*` is one window, `*-<agent>-*` is one
36
+ * subagent, `*-guards-*` is one hook. Rotation is unchanged because `.1.log` is still a suffix.
37
+ *
38
+ * `transcript_path` is also unique per session, but it is a filesystem PATH — long, and full of
39
+ * separators that would have to be flattened anyway — and `session_id` is its stable identifier, so
40
+ * session_id is the better key.
41
+ *
42
+ * The tree is still visible — every line already carries `root=` / `projectDir=` / `tree=` columns —
43
+ * so nothing is lost by the filename not encoding it.
44
+ *
45
+ * ─── There is no un-split path ─────────────────────────────────────────────────────────────────────
46
+ * Every name is prefixed, always. A caller that never identifies renders as
47
+ * `unknown-coordinator-hook-<base>` — a distinct, greppable stream, NOT the shared file. Keeping a
48
+ * bare-name fallback would have meant two reachable spellings of one filename, with the tearing one
49
+ * reached by doing nothing; that is the widening-as-absence this whole class exists to remove, so it
50
+ * is not offered.
51
+ */
52
+ class LogStream {
53
+ // ALWAYS a real identity. There is no "unset" state and no bare-name branch, so there is exactly
54
+ // ONE spelling of a log filename and a writer cannot reach the shared, tearing stream by doing
55
+ // nothing. A caller with no Claude Code payload (the openclaw adapter, library consumers, specs)
56
+ // gets UNIDENTIFIED below — which still prefixes, with `unknown`, so it is a distinct greppable
57
+ // stream rather than a merge point.
58
+ sessionId = 'unknown';
59
+ agentId = '';
60
+ hook = 'hook';
61
+ /**
62
+ * Called once per invocation by the adapter that parsed the payload. `agentId` is empty for the
63
+ * coordinator — that absence IS the signal, see AgentIdentity — and renders as `coordinator`.
64
+ * An empty `sessionId` renders as `unknown`: visible, never merged into another stream.
65
+ */
66
+ identify(sessionId, agentId, hook) {
67
+ this.sessionId = sessionId;
68
+ this.agentId = agentId;
69
+ this.hook = hook;
70
+ }
71
+ /**
72
+ * This caller's name for `base` — `<sessionId>-<agentId|coordinator>-<hook>-<base>`, ALWAYS.
73
+ *
74
+ * Takes the WHOLE filename (`guard-invocations.log`, and separately `guard-invocations.1.log`) so
75
+ * the rotation sibling gets the identical prefix and rotation keeps working untouched.
76
+ */
77
+ fileName(base) {
78
+ const agent = segment(this.agentId === '' ? 'coordinator' : this.agentId);
79
+ return `${segment(this.sessionId)}-${agent}-${segment(this.hook)}-${base}`;
80
+ }
81
+ }
82
+ exports.LogStream = LogStream;
83
+ /**
84
+ * One path segment, sanitised. `session_id` and `agent_id` arrive from a JSON payload, so they are
85
+ * UNTRUSTED INPUT being used to build a filesystem path: `../../../etc` must become a harmless name and
86
+ * never escape the logs directory. Everything outside `[A-Za-z0-9._-]` collapses to `_`, a leading dot
87
+ * is neutralised so nothing becomes a hidden file or `..`, and the result is capped and never empty.
88
+ */
89
+ // webpieces-disable no-function-outside-class -- pure string sanitiser, the module's own leaf helper beside the class it serves
90
+ function segment(raw) {
91
+ const cleaned = raw
92
+ .replace(/[^A-Za-z0-9._-]/g, '_') // kills every separator, so nothing can traverse
93
+ .replace(/\.{2,}/g, '_') // and no run of dots survives, so no segment reads as `..`
94
+ .replace(/^\.+/, '_') // nor becomes a hidden file
95
+ .slice(0, 64);
96
+ return cleaned === '' ? 'unknown' : cleaned;
97
+ }
98
+ /**
99
+ * Process-wide instance. The hook adapters identify it once at the top of the invocation and every
100
+ * writer downstream reads it, which is what keeps `logGuardDecision()` / `logRejection()` signatures
101
+ * unchanged — the alternative was threading three more parameters through every call site.
102
+ */
103
+ exports.logStream = new LogStream();
104
+ //# sourceMappingURL=log-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-stream.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/log-stream.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAa,SAAS;IAClB,iGAAiG;IACjG,+FAA+F;IAC/F,iGAAiG;IACjG,gGAAgG;IAChG,oCAAoC;IAC5B,SAAS,GAAG,SAAS,CAAC;IACtB,OAAO,GAAG,EAAE,CAAC;IACb,IAAI,GAAG,MAAM,CAAC;IAEtB;;;;OAIG;IACH,QAAQ,CAAC,SAAiB,EAAE,OAAe,EAAE,IAAY;QACrD,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;IAED;;;;;OAKG;IACH,QAAQ,CAAC,IAAY;QACjB,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1E,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;IAC/E,CAAC;CACJ;AA/BD,8BA+BC;AAED;;;;;GAKG;AACH,gIAAgI;AAChI,SAAS,OAAO,CAAC,GAAW;IACxB,MAAM,OAAO,GAAG,GAAG;SACd,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAG,iDAAiD;SACpF,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAY,2DAA2D;SAC9F,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAe,4BAA4B;SAC/D,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClB,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AAChD,CAAC;AAED;;;;GAIG;AACU,QAAA,SAAS,GAAG,IAAI,SAAS,EAAE,CAAC","sourcesContent":["import { dotWebpieces } from '@webpieces/rules-config';\nimport * as path from 'path';\n\n/**\n * WHICH LOG FILE does this hook invocation append to?\n *\n * ─── The bug this exists to fix ────────────────────────────────────────────────────────────────────\n * Log paths used to be keyed by the git WORKTREE alone (`<local>/logs/…`). Three separate things share\n * a worktree, so three separate things shared one file:\n *\n * 1. **Parallel hooks.** The hooks reference says \"when multiple PreToolUse hooks match a tool call,\n * ALL matching hooks run in parallel\". `wp-ai-rules-hook` and `wp-ai-guards-hook` both match\n * Write/Edit/MultiEdit, so on every file edit TWO PROCESSES append to the same file at the same\n * time. L-1's `guarantee-root.sh` makes it three.\n * 2. **Subagents.** A subagent without worktree isolation shares the coordinator's tree.\n * 3. **Whole sessions.** Four Claude Code windows on one clone are four coordinators, and `agent_id`\n * is absent for every one of them — so agent identity alone cannot tell them apart.\n *\n * `O_APPEND` is indivisible only under `PIPE_BUF`, which is **512 bytes on macOS**. Measured\n * 2026-08-06 across three repos: `guard-invocations.log` 208/3306 lines (6.3%) exceed it, max 608 B;\n * `guard-sync-decisions.log` 209/4097 (5.1%), max 625 B. So this tears TODAY, and the corrupted line\n * is exactly the long one — the `recover=` line a human needs most.\n *\n * ─── The key: three dimensions, one FLAT filename ──────────────────────────────────────────────────\n * <local>/logs/<sessionId>-<agentId | \"coordinator\">-<hook>-<file>.log\n *\n * sessionId separates concurrent Claude Code windows (`session_id`, on every hook payload)\n * agentId separates subagents within one window (`agent_id`, subagent-only — absent = coordinator)\n * hook separates the PARALLEL hooks ('guards' | 'rules' | 'guarantee-root')\n *\n * One writer per FILE, by construction, so appends cannot interleave and nothing needs a lock.\n *\n * DELIBERATELY FLAT, not `sessions/<id>/<agent>/<hook>/<file>`. A nested tree makes the common\n * question — \"show me everything that happened, in time order\" — into a directory walk, when it should\n * be one glob: `ls logs/` shows every stream at once, `logs/<sid>-*` is one window, `*-<agent>-*` is one\n * subagent, `*-guards-*` is one hook. Rotation is unchanged because `.1.log` is still a suffix.\n *\n * `transcript_path` is also unique per session, but it is a filesystem PATH — long, and full of\n * separators that would have to be flattened anyway — and `session_id` is its stable identifier, so\n * session_id is the better key.\n *\n * The tree is still visible — every line already carries `root=` / `projectDir=` / `tree=` columns —\n * so nothing is lost by the filename not encoding it.\n *\n * ─── There is no un-split path ─────────────────────────────────────────────────────────────────────\n * Every name is prefixed, always. A caller that never identifies renders as\n * `unknown-coordinator-hook-<base>` — a distinct, greppable stream, NOT the shared file. Keeping a\n * bare-name fallback would have meant two reachable spellings of one filename, with the tearing one\n * reached by doing nothing; that is the widening-as-absence this whole class exists to remove, so it\n * is not offered.\n */\nexport class LogStream {\n // ALWAYS a real identity. There is no \"unset\" state and no bare-name branch, so there is exactly\n // ONE spelling of a log filename and a writer cannot reach the shared, tearing stream by doing\n // nothing. A caller with no Claude Code payload (the openclaw adapter, library consumers, specs)\n // gets UNIDENTIFIED below — which still prefixes, with `unknown`, so it is a distinct greppable\n // stream rather than a merge point.\n private sessionId = 'unknown';\n private agentId = '';\n private hook = 'hook';\n\n /**\n * Called once per invocation by the adapter that parsed the payload. `agentId` is empty for the\n * coordinator — that absence IS the signal, see AgentIdentity — and renders as `coordinator`.\n * An empty `sessionId` renders as `unknown`: visible, never merged into another stream.\n */\n identify(sessionId: string, agentId: string, hook: string): void {\n this.sessionId = sessionId;\n this.agentId = agentId;\n this.hook = hook;\n }\n\n /**\n * This caller's name for `base` — `<sessionId>-<agentId|coordinator>-<hook>-<base>`, ALWAYS.\n *\n * Takes the WHOLE filename (`guard-invocations.log`, and separately `guard-invocations.1.log`) so\n * the rotation sibling gets the identical prefix and rotation keeps working untouched.\n */\n fileName(base: string): string {\n const agent = segment(this.agentId === '' ? 'coordinator' : this.agentId);\n return `${segment(this.sessionId)}-${agent}-${segment(this.hook)}-${base}`;\n }\n}\n\n/**\n * One path segment, sanitised. `session_id` and `agent_id` arrive from a JSON payload, so they are\n * UNTRUSTED INPUT being used to build a filesystem path: `../../../etc` must become a harmless name and\n * never escape the logs directory. Everything outside `[A-Za-z0-9._-]` collapses to `_`, a leading dot\n * is neutralised so nothing becomes a hidden file or `..`, and the result is capped and never empty.\n */\n// webpieces-disable no-function-outside-class -- pure string sanitiser, the module's own leaf helper beside the class it serves\nfunction segment(raw: string): string {\n const cleaned = raw\n .replace(/[^A-Za-z0-9._-]/g, '_') // kills every separator, so nothing can traverse\n .replace(/\\.{2,}/g, '_') // and no run of dots survives, so no segment reads as `..`\n .replace(/^\\.+/, '_') // nor becomes a hidden file\n .slice(0, 64);\n return cleaned === '' ? 'unknown' : cleaned;\n}\n\n/**\n * Process-wide instance. The hook adapters identify it once at the top of the invocation and every\n * writer downstream reads it, which is what keeps `logGuardDecision()` / `logRejection()` signatures\n * unchanged — the alternative was threading three more parameters through every call site.\n */\nexport const logStream = new LogStream();\n"]}
@@ -7,7 +7,8 @@ export declare class SyncLogEvent {
7
7
  constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string);
8
8
  }
9
9
  /**
10
- * Append one tab-separated line per refresher event to `.webpieces/logs/guard-async-work.log`. `root` is
10
+ * Append one tab-separated line per refresher event to
11
+ * `.webpieces/logs/<stream>guard-async-work.log` (see LogStream for the prefix). `root` is
11
12
  * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
12
13
  * the refresher (or the hook that spawns it).
13
14
  */
@@ -8,13 +8,16 @@ const fs = tslib_1.__importStar(require("fs"));
8
8
  const path = tslib_1.__importStar(require("path"));
9
9
  const rules_config_1 = require("@webpieces/rules-config");
10
10
  const to_error_1 = require("./to-error");
11
+ const log_stream_1 = require("./log-stream");
11
12
  // The ASYNC log — observability for the detached background refresher (sync-main.ts) that writes
12
13
  // main-sync-status.json. Its companion is the SYNC log (sync-decisions.log, decision-log.ts) which
13
14
  // records what the hook DECIDED using that cache. The refresher runs AFTER the spawning hook has
14
15
  // exited, with stdio discarded, so when it fails to update the cache there is normally no trace.
15
16
  // This log captures its lifecycle — SPAWN_ATTEMPT (parent side), then START / SKIP_INPROGRESS /
16
17
  // FINISH / ERROR (child side) — so we can tell whether the detached child never launched, was killed
17
- // mid-run (START with no FINISH), or threw. Writes to `.webpieces/logs/guard-async-work.log` (see
18
+ // mid-run (START with no FINISH), or threw. Writes to
19
+ // `.webpieces/logs/<stream>guard-async-work.log`, where <stream> is LogStream's
20
+ // `<sessionId>-<agentId|coordinator>-<hook>-` prefix (see
18
21
  // LOGS_STATE_DIR: every webpieces log lives under `logs/`, never beside the non-log state in `hooks/`).
19
22
  const LOG_FILE = 'guard-async-work.log';
20
23
  const LOG_FILE_PREV = 'guard-async-work.1.log';
@@ -36,7 +39,8 @@ class SyncLogEvent {
36
39
  }
37
40
  exports.SyncLogEvent = SyncLogEvent;
38
41
  /**
39
- * Append one tab-separated line per refresher event to `.webpieces/logs/guard-async-work.log`. `root` is
42
+ * Append one tab-separated line per refresher event to
43
+ * `.webpieces/logs/<stream>guard-async-work.log` (see LogStream for the prefix). `root` is
40
44
  * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
41
45
  * the refresher (or the hook that spawns it).
42
46
  */
@@ -48,8 +52,8 @@ function logSyncEvent(root, event) {
48
52
  // log, so its appends cannot interleave with another agent's.
49
53
  const logsDir = rules_config_1.dotWebpieces.logs(root);
50
54
  fs.mkdirSync(logsDir, { recursive: true });
51
- const logPath = path.join(logsDir, LOG_FILE);
52
- rotateLogFile(logPath, path.join(logsDir, LOG_FILE_PREV));
55
+ const logPath = path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE));
56
+ rotateLogFile(logPath, path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE_PREV)));
53
57
  const line = [
54
58
  `[${timestamp}]`,
55
59
  event.phase,
@@ -69,7 +73,7 @@ function logSyncEvent(root, event) {
69
73
  // captured instead of vanishing into /dev/null. Callers must ensure the log dir exists first
70
74
  // (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).
71
75
  function syncStderrLogPath(root) {
72
- return rules_config_1.dotWebpieces.logsFile(root, STDERR_FILE);
76
+ return rules_config_1.dotWebpieces.logsFile(root, log_stream_1.logStream.fileName(STDERR_FILE));
73
77
  }
74
78
  // Collapse newlines/tabs and cap length so one event is always one log line.
75
79
  function oneLine(value) {
@@ -1 +1 @@
1
- {"version":3,"file":"main-sync-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\n\n// The ASYNC log — observability for the detached background refresher (sync-main.ts) that writes\n// main-sync-status.json. Its companion is the SYNC log (sync-decisions.log, decision-log.ts) which\n// records what the hook DECIDED using that cache. The refresher runs AFTER the spawning hook has\n// exited, with stdio discarded, so when it fails to update the cache there is normally no trace.\n// This log captures its lifecycle — SPAWN_ATTEMPT (parent side), then START / SKIP_INPROGRESS /\n// FINISH / ERROR (child side) — so we can tell whether the detached child never launched, was killed\n// mid-run (START with no FINISH), or threw. Writes to `.webpieces/logs/guard-async-work.log` (see\n// LOGS_STATE_DIR: every webpieces log lives under `logs/`, never beside the non-log state in `hooks/`).\nconst LOG_FILE = 'guard-async-work.log';\nconst LOG_FILE_PREV = 'guard-async-work.1.log';\nconst STDERR_FILE = 'guard-async-work.stderr.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors decision-log)\nconst MAX_DETAIL_LEN = 300;\n\nexport type SyncPhase = 'SPAWN_ATTEMPT' | 'START' | 'SKIP_INPROGRESS' | 'FINISH' | 'ERROR';\n\n// Data-only record of one refresher lifecycle event (per CLAUDE.md: classes for data).\nexport class SyncLogEvent {\n phase: SyncPhase;\n pid: number;\n branchArg: string;\n detail: string;\n\n constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string) {\n this.phase = phase;\n this.pid = pid;\n this.branchArg = branchArg;\n this.detail = detail;\n }\n}\n\n/**\n * Append one tab-separated line per refresher event to `.webpieces/logs/guard-async-work.log`. `root` is\n * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail\n * the refresher (or the hook that spawns it).\n */\nexport function logSyncEvent(root: string, event: SyncLogEvent): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: this is the refresher's own lifecycle trace for THIS worktree. One writer per\n // log, so its appends cannot interleave with another agent's.\n const logsDir = dotWebpieces.logs(root);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, LOG_FILE);\n rotateLogFile(logPath, path.join(logsDir, LOG_FILE_PREV));\n\n const line = [\n `[${timestamp}]`,\n event.phase,\n `pid=${String(event.pid)}`,\n event.branchArg,\n oneLine(event.detail),\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// Absolute path the detached child's stdout/stderr are redirected to (opened with fs.openSync(p,'a')\n// by the spawner), so even a crash BEFORE our own logging runs — e.g. a module-load failure — is\n// captured instead of vanishing into /dev/null. Callers must ensure the log dir exists first\n// (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).\nexport function syncStderrLogPath(root: string): string {\n return dotWebpieces.logsFile(root, STDERR_FILE);\n}\n\n// Collapse newlines/tabs and cap length so one event 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_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_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":"main-sync-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-log.ts"],"names":[],"mappings":";;;AA+CA,oCAwBC;AAMD,8CAEC;;AA/ED,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuD;AAEvD,yCAAqC;AACrC,6CAAyC;AAEzC,iGAAiG;AACjG,mGAAmG;AACnG,iGAAiG;AACjG,iGAAiG;AACjG,gGAAgG;AAChG,qGAAqG;AACrG,sDAAsD;AACtD,gFAAgF;AAChF,0DAA0D;AAC1D,wGAAwG;AACxG,MAAM,QAAQ,GAAG,sBAAsB,CAAC;AACxC,MAAM,aAAa,GAAG,wBAAwB,CAAC;AAC/C,MAAM,WAAW,GAAG,6BAA6B,CAAC;AAClD,MAAM,aAAa,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC,uDAAuD;AACzF,MAAM,cAAc,GAAG,GAAG,CAAC;AAI3B,uFAAuF;AACvF,MAAa,YAAY;IACrB,KAAK,CAAY;IACjB,GAAG,CAAS;IACZ,SAAS,CAAS;IAClB,MAAM,CAAS;IAEf,YAAY,KAAgB,EAAE,GAAW,EAAE,SAAiB,EAAE,MAAc;QACxE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;CACJ;AAZD,oCAYC;AAED;;;;;GAKG;AACH,SAAgB,YAAY,CAAC,IAAY,EAAE,KAAmB;IAC1D,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QAC3C,6FAA6F;QAC7F,8DAA8D;QAC9D,MAAM,OAAO,GAAG,2BAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACxC,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAE3C,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,sBAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;QACjE,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,sBAAS,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAE9E,MAAM,IAAI,GAAG;YACT,IAAI,SAAS,GAAG;YAChB,KAAK,CAAC,KAAK;YACX,OAAO,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE;YAC1B,KAAK,CAAC,SAAS;YACf,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC;SACxB,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;QACpB,EAAE,CAAC,cAAc,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACrC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;IACf,CAAC;AACL,CAAC;AAED,qGAAqG;AACrG,iGAAiG;AACjG,6FAA6F;AAC7F,+DAA+D;AAC/D,SAAgB,iBAAiB,CAAC,IAAY;IAC1C,OAAO,2BAAY,CAAC,QAAQ,CAAC,IAAI,EAAE,sBAAS,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC;AACxE,CAAC;AAED,6EAA6E;AAC7E,SAAS,OAAO,CAAC,KAAa;IAC1B,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;IACrD,OAAO,IAAI,CAAC,MAAM,IAAI,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,cAAc,CAAC,GAAG,GAAG,CAAC;AACtF,CAAC;AAED,SAAS,aAAa,CAAC,OAAe,EAAE,QAAgB;IACpD,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAClC,IAAI,IAAI,CAAC,IAAI,GAAG,aAAa,EAAE,CAAC;YAC5B,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC;gBAAE,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;YACrD,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QACrC,CAAC;IACL,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;IACf,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logStream } from './log-stream';\n\n// The ASYNC log — observability for the detached background refresher (sync-main.ts) that writes\n// main-sync-status.json. Its companion is the SYNC log (sync-decisions.log, decision-log.ts) which\n// records what the hook DECIDED using that cache. The refresher runs AFTER the spawning hook has\n// exited, with stdio discarded, so when it fails to update the cache there is normally no trace.\n// This log captures its lifecycle — SPAWN_ATTEMPT (parent side), then START / SKIP_INPROGRESS /\n// FINISH / ERROR (child side) — so we can tell whether the detached child never launched, was killed\n// mid-run (START with no FINISH), or threw. Writes to\n// `.webpieces/logs/<stream>guard-async-work.log`, where <stream> is LogStream's\n// `<sessionId>-<agentId|coordinator>-<hook>-` prefix (see\n// LOGS_STATE_DIR: every webpieces log lives under `logs/`, never beside the non-log state in `hooks/`).\nconst LOG_FILE = 'guard-async-work.log';\nconst LOG_FILE_PREV = 'guard-async-work.1.log';\nconst STDERR_FILE = 'guard-async-work.stderr.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors decision-log)\nconst MAX_DETAIL_LEN = 300;\n\nexport type SyncPhase = 'SPAWN_ATTEMPT' | 'START' | 'SKIP_INPROGRESS' | 'FINISH' | 'ERROR';\n\n// Data-only record of one refresher lifecycle event (per CLAUDE.md: classes for data).\nexport class SyncLogEvent {\n phase: SyncPhase;\n pid: number;\n branchArg: string;\n detail: string;\n\n constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string) {\n this.phase = phase;\n this.pid = pid;\n this.branchArg = branchArg;\n this.detail = detail;\n }\n}\n\n/**\n * Append one tab-separated line per refresher event to\n * `.webpieces/logs/<stream>guard-async-work.log` (see LogStream for the prefix). `root` is\n * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail\n * the refresher (or the hook that spawns it).\n */\nexport function logSyncEvent(root: string, event: SyncLogEvent): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n // LOCAL scope: this is the refresher's own lifecycle trace for THIS worktree. One writer per\n // log, so its appends cannot interleave with another agent's.\n const logsDir = dotWebpieces.logs(root);\n fs.mkdirSync(logsDir, { recursive: true });\n\n const logPath = path.join(logsDir, logStream.fileName(LOG_FILE));\n rotateLogFile(logPath, path.join(logsDir, logStream.fileName(LOG_FILE_PREV)));\n\n const line = [\n `[${timestamp}]`,\n event.phase,\n `pid=${String(event.pid)}`,\n event.branchArg,\n oneLine(event.detail),\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// Absolute path the detached child's stdout/stderr are redirected to (opened with fs.openSync(p,'a')\n// by the spawner), so even a crash BEFORE our own logging runs — e.g. a module-load failure — is\n// captured instead of vanishing into /dev/null. Callers must ensure the log dir exists first\n// (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).\nexport function syncStderrLogPath(root: string): string {\n return dotWebpieces.logsFile(root, logStream.fileName(STDERR_FILE));\n}\n\n// Collapse newlines/tabs and cap length so one event 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_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_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"]}
@@ -6,7 +6,8 @@ export declare function resetMainSyncRefreshLatchForTest(): void;
6
6
  * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
7
7
  *
8
8
  * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
9
- * `.webpieces/logs/guard-async-work.log`. The child's stdout/stderr are redirected to a sibling file (not
9
+ * `.webpieces/logs/<stream>guard-async-work.log` (LogStream prefixes every name). The child's
10
+ * stdout/stderr are redirected to a sibling file (not
10
11
  * /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows
11
12
  * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
12
13
  */
@@ -23,7 +23,8 @@ function resetMainSyncRefreshLatchForTest() {
23
23
  * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
24
24
  *
25
25
  * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
26
- * `.webpieces/logs/guard-async-work.log`. The child's stdout/stderr are redirected to a sibling file (not
26
+ * `.webpieces/logs/<stream>guard-async-work.log` (LogStream prefixes every name). The child's
27
+ * stdout/stderr are redirected to a sibling file (not
27
28
  * /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows
28
29
  * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
29
30
  */
@@ -1 +1 @@
1
- {"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;AAcA,4EAEC;AAaD,wDAmCC;;AAhED,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AACrC,mDAAgF;AAEhF,qGAAqG;AACrG,yEAAyE;AACzE,IAAI,gBAAgB,GAAG,KAAK,CAAC;AAE7B,8GAA8G;AAC9G,SAAgB,gCAAgC;IAC5C,gBAAgB,GAAG,KAAK,CAAC;AAC7B,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,gGAAgG;IAChG,mGAAmG;IACnG,mGAAmG;IACnG,2FAA2F;IAC3F,wFAAwF;IACxF,IAAI,gBAAgB;QAAE,OAAO;IAC7B,gBAAgB,GAAG,IAAI,CAAC;IAExB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,2FAA2F;QAC3F,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,SAAS,EAAE,CAAC,CAAC,CAAC;QAE3G,6FAA6F;QAC7F,mFAAmF;QACnF,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAA,iCAAiB,EAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,CAAC,kBAAkB,CAAC,CAAC,EAAE;YAC1F,QAAQ,EAAE,IAAI;YACd,KAAK,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAClC,CAAC,CAAC;QACH,8FAA8F;QAC9F,wFAAwF;QACxF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAU,EAAQ,EAAE;YACrC,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACjH,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,mFAAmF;QACnF,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,0EAA0E;IAC9E,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent, syncStderrLogPath } from './main-sync-log';\n\n// Per-process latch for the spawn below. A hook process handles exactly one tool call, so this makes\n// the refresher at-most-once per tool call. Exported reset is test-only.\nlet alreadyTriggered = false;\n\n// webpieces-disable no-function-outside-class -- test-only latch reset, matching this module's function shape\nexport function resetMainSyncRefreshLatchForTest(): void {\n alreadyTriggered = false;\n}\n\n/**\n * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,\n * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow\n * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on\n * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.\n *\n * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to\n * `.webpieces/logs/guard-async-work.log`. The child's stdout/stderr are redirected to a sibling file (not\n * /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows\n * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.\n */\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number = DEFAULT_HANG_TIMEOUT_MINUTES): void {\n // ONE refresher per hook process. Several call sites fire this on a single tool call — the Read\n // fast path in hook-core AND read-stale-guard's own check(), for one — which is why the log showed\n // two SPAWN_ATTEMPTs ~20ms apart from the same pid on every cycle. The loser only ever reached the\n // lock and exited, so the second child was pure waste (and one more `git fetch` racing the\n // agent's). The child's lock still guards against refreshers from OTHER hook processes.\n if (alreadyTriggered) return;\n alreadyTriggered = true;\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n // SPAWN_ATTEMPT first — this also creates .webpieces/logs so the stderr fd below can open.\n logSyncEvent(workspaceRoot, new SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));\n\n // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash\n // before the child's own logging — e.g. a module-load failure — is still captured.\n const errFd = fs.openSync(syncStderrLogPath(workspaceRoot), 'a');\n const child = spawn(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {\n detached: true,\n stdio: ['ignore', errFd, errFd],\n });\n // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit\n // before this handler runs, but on POSIX a successful exec has already happened by now.\n child.once('error', (err: Error): void => {\n logSyncEvent(workspaceRoot, new SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));\n });\n child.unref();\n // The child has its own dup'd copy of the fd after spawn; close the parent's copy.\n fs.closeSync(errFd);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Spawning the background refresh must never block or fail the tool call.\n }\n}\n"]}
1
+ {"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;AAcA,4EAEC;AAcD,wDAmCC;;AAjED,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AACrC,mDAAgF;AAEhF,qGAAqG;AACrG,yEAAyE;AACzE,IAAI,gBAAgB,GAAG,KAAK,CAAC;AAE7B,8GAA8G;AAC9G,SAAgB,gCAAgC;IAC5C,gBAAgB,GAAG,KAAK,CAAC;AAC7B,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,gGAAgG;IAChG,mGAAmG;IACnG,mGAAmG;IACnG,2FAA2F;IAC3F,wFAAwF;IACxF,IAAI,gBAAgB;QAAE,OAAO;IAC7B,gBAAgB,GAAG,IAAI,CAAC;IAExB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,2FAA2F;QAC3F,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,SAAS,EAAE,CAAC,CAAC,CAAC;QAE3G,6FAA6F;QAC7F,mFAAmF;QACnF,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAA,iCAAiB,EAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,CAAC,kBAAkB,CAAC,CAAC,EAAE;YAC1F,QAAQ,EAAE,IAAI;YACd,KAAK,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAClC,CAAC,CAAC;QACH,8FAA8F;QAC9F,wFAAwF;QACxF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAU,EAAQ,EAAE;YACrC,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACjH,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,mFAAmF;QACnF,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,0EAA0E;IAC9E,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent, syncStderrLogPath } from './main-sync-log';\n\n// Per-process latch for the spawn below. A hook process handles exactly one tool call, so this makes\n// the refresher at-most-once per tool call. Exported reset is test-only.\nlet alreadyTriggered = false;\n\n// webpieces-disable no-function-outside-class -- test-only latch reset, matching this module's function shape\nexport function resetMainSyncRefreshLatchForTest(): void {\n alreadyTriggered = false;\n}\n\n/**\n * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,\n * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow\n * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on\n * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.\n *\n * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to\n * `.webpieces/logs/<stream>guard-async-work.log` (LogStream prefixes every name). The child's\n * stdout/stderr are redirected to a sibling file (not\n * /dev/null) so a crash before the child's own logging is still captured. If guard-async-work.log shows\n * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.\n */\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number = DEFAULT_HANG_TIMEOUT_MINUTES): void {\n // ONE refresher per hook process. Several call sites fire this on a single tool call — the Read\n // fast path in hook-core AND read-stale-guard's own check(), for one — which is why the log showed\n // two SPAWN_ATTEMPTs ~20ms apart from the same pid on every cycle. The loser only ever reached the\n // lock and exited, so the second child was pure waste (and one more `git fetch` racing the\n // agent's). The child's lock still guards against refreshers from OTHER hook processes.\n if (alreadyTriggered) return;\n alreadyTriggered = true;\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n // SPAWN_ATTEMPT first — this also creates .webpieces/logs so the stderr fd below can open.\n logSyncEvent(workspaceRoot, new SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));\n\n // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash\n // before the child's own logging — e.g. a module-load failure — is still captured.\n const errFd = fs.openSync(syncStderrLogPath(workspaceRoot), 'a');\n const child = spawn(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {\n detached: true,\n stdio: ['ignore', errFd, errFd],\n });\n // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit\n // before this handler runs, but on POSIX a successful exec has already happened by now.\n child.once('error', (err: Error): void => {\n logSyncEvent(workspaceRoot, new SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));\n });\n child.unref();\n // The child has its own dup'd copy of the fd after spawn; close the parent's copy.\n fs.closeSync(errFd);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Spawning the background refresh must never block or fail the tool call.\n }\n}\n"]}
@@ -6,6 +6,7 @@ const tslib_1 = require("tslib");
6
6
  const fs = tslib_1.__importStar(require("fs"));
7
7
  const path = tslib_1.__importStar(require("path"));
8
8
  const rules_config_1 = require("@webpieces/rules-config");
9
+ const log_stream_1 = require("./log-stream");
9
10
  // The rejection log SPLITS across the two state dirs on purpose: the `.log` index goes to `logs/`
10
11
  // with every other webpieces log, while the dated `hooks/<YYYY-MM-DD>/writeInfo-*.md` DETAIL files
11
12
  // are not logs and stay in `hooks/` (see LOGS_STATE_DIR).
@@ -40,8 +41,8 @@ function logRejection(toolKind, input, result, cwd) {
40
41
  const detailRelPath = `${rules_config_1.HOOKS_STATE_DIR}/${dateStr}/${detailFileName}`;
41
42
  const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);
42
43
  fs.writeFileSync(path.join(dayDir, detailFileName), detail);
43
- const logPath = path.join(logsDir, LOG_FILE);
44
- rotateLogFile(logPath, path.join(logsDir, LOG_FILE_PREV));
44
+ const logPath = path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE));
45
+ rotateLogFile(logPath, path.join(logsDir, log_stream_1.logStream.fileName(LOG_FILE_PREV)));
45
46
  const logLine = `[${timestamp}]\t${toolKind}\t${relativePath}\t[${ruleNames.join(',')}]\t${detailRelPath}\n`;
46
47
  fs.appendFileSync(logPath, logLine);
47
48
  rotateOldDays(hooksDir, MAX_AGE_DAYS);
@@ -1 +1 @@
1
- {"version":3,"file":"rejection-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/rejection-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, RepoRootFinder, HOOKS_STATE_DIR } from '@webpieces/rules-config';\n\nimport type { ToolKind, NormalizedToolInput, BlockedResult } from './types';\n\n// The rejection log SPLITS across the two state dirs on purpose: the `.log` index goes to `logs/`\n// with every other webpieces log, while the dated `hooks/<YYYY-MM-DD>/writeInfo-*.md` DETAIL files\n// are not logs and stay in `hooks/` (see LOGS_STATE_DIR).\nconst LOG_FILE = 'hook-rejection.log';\nconst LOG_FILE_PREV = 'hook-rejection.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded\nconst MAX_AGE_DAYS = 7;\n\nconst RULE_NAME_RE = /^\\[([^\\]]+)\\] \\(/gm;\n\nexport function logRejection(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n result: BlockedResult,\n cwd: string,\n): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // `.webpieces/` lives at the repo root, NOT the AI's cwd — resolve it so a hook fired while\n // the AI is in a subdirectory never scatters a stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const now = new Date();\n const timestamp = now.toISOString();\n const epochMs = String(now.getTime());\n const dateStr = timestamp.slice(0, 10);\n\n // LOCAL scope — a rejection is this worktree's event, and a per-worktree log has exactly one\n // writer, so its appends and its daily detail files cannot collide with another agent's.\n const hooksDir = dotWebpieces.localFile(root, HOOKS_STATE_DIR);\n const logsDir = dotWebpieces.logs(root);\n const dayDir = path.join(hooksDir, dateStr);\n fs.mkdirSync(dayDir, { recursive: true });\n fs.mkdirSync(logsDir, { recursive: true });\n\n const relativePath = computeRelativePath(input.filePath, root);\n const ruleNames = extractRuleNames(result.report);\n const detailFileName = `writeInfo-${epochMs}.md`;\n // Relative to the STATE DIR, not to the log file's own directory: the index now lives in\n // `logs/` while the detail lives in `hooks/<date>/`, so a bare `<date>/<file>` would no\n // longer resolve from where the reader found the line.\n const detailRelPath = `${HOOKS_STATE_DIR}/${dateStr}/${detailFileName}`;\n\n const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);\n fs.writeFileSync(path.join(dayDir, detailFileName), detail);\n\n const logPath = path.join(logsDir, LOG_FILE);\n rotateLogFile(logPath, path.join(logsDir, LOG_FILE_PREV));\n\n const logLine = `[${timestamp}]\\t${toolKind}\\t${relativePath}\\t[${ruleNames.join(',')}]\\t${detailRelPath}\\n`;\n fs.appendFileSync(logPath, logLine);\n\n rotateOldDays(hooksDir, MAX_AGE_DAYS);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\nfunction computeRelativePath(filePath: string, cwd: string): string {\n if (filePath.startsWith(cwd)) {\n const rel = filePath.slice(cwd.length);\n if (rel.startsWith('/')) return rel.slice(1);\n return rel;\n }\n return filePath;\n}\n\n/**\n * The rule names a block report cites — every `[<rule-name>] (` header it opens with. Exported because\n * two audit streams need the same answer from the same regex: this file's rejection index, and the\n * `rule=` field guard-invocations.log now carries (see InvocationLog.finish). Two scrapers would be\n * two answers to one question.\n */\n// webpieces-disable no-function-outside-class -- pure regex scraper beside this module's other module-scope helpers; exported so the invocation log and the rejection index scrape rule names with the SAME code.\nexport function extractRuleNames(report: string): string[] {\n const names: string[] = [];\n let match = RULE_NAME_RE.exec(report);\n while (match !== null) {\n names.push(match[1]);\n match = RULE_NAME_RE.exec(report);\n }\n RULE_NAME_RE.lastIndex = 0;\n return names;\n}\n\nfunction buildDetailContent(\n timestamp: string,\n toolKind: ToolKind,\n relativePath: string,\n ruleNames: string[],\n report: string,\n input: NormalizedToolInput,\n): string {\n const lines: string[] = [];\n lines.push('# Hook Rejection Detail');\n lines.push('');\n lines.push(`- **Timestamp:** ${timestamp}`);\n lines.push(`- **Tool:** ${toolKind}`);\n lines.push(`- **File:** ${relativePath}`);\n lines.push(`- **Rules violated:** ${ruleNames.join(', ')}`);\n lines.push('');\n lines.push('## Report');\n lines.push('');\n lines.push('```');\n lines.push(report.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('## Content Being Written');\n lines.push('');\n\n if (toolKind === 'Write') {\n const content = input.edits.length > 0 ? input.edits[0].newString : '';\n lines.push('```typescript');\n lines.push(content.trimEnd());\n lines.push('```');\n } else {\n for (let i = 0; i < input.edits.length; i += 1) {\n const edit = input.edits[i];\n lines.push(`### Edit ${String(i + 1)} of ${String(input.edits.length)}`);\n lines.push('');\n lines.push('**old_string:**');\n lines.push('```typescript');\n lines.push(edit.oldString.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('**new_string:**');\n lines.push('```typescript');\n lines.push(edit.newString.trimEnd());\n lines.push('```');\n lines.push('');\n }\n }\n\n return lines.join('\\n') + '\\n';\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 err;\n }\n}\n\nfunction rotateOldDays(hooksDir: string, maxAgeDays: number): void {\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(hooksDir);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n return;\n }\n\n for (const entry of entries) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(entry)) continue;\n const dirDate = new Date(entry + 'T00:00:00Z');\n if (isNaN(dirDate.getTime())) continue;\n if (dirDate.getTime() < cutoff) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(path.join(hooksDir, entry), { recursive: true, force: true });\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n }\n }\n}\n"]}
1
+ {"version":3,"file":"rejection-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/rejection-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { dotWebpieces, RepoRootFinder, HOOKS_STATE_DIR } from '@webpieces/rules-config';\n\nimport type { ToolKind, NormalizedToolInput, BlockedResult } from './types';\nimport { logStream } from './log-stream';\n\n// The rejection log SPLITS across the two state dirs on purpose: the `.log` index goes to `logs/`\n// with every other webpieces log, while the dated `hooks/<YYYY-MM-DD>/writeInfo-*.md` DETAIL files\n// are not logs and stay in `hooks/` (see LOGS_STATE_DIR).\nconst LOG_FILE = 'hook-rejection.log';\nconst LOG_FILE_PREV = 'hook-rejection.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded\nconst MAX_AGE_DAYS = 7;\n\nconst RULE_NAME_RE = /^\\[([^\\]]+)\\] \\(/gm;\n\nexport function logRejection(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n result: BlockedResult,\n cwd: string,\n): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // `.webpieces/` lives at the repo root, NOT the AI's cwd — resolve it so a hook fired while\n // the AI is in a subdirectory never scatters a stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const now = new Date();\n const timestamp = now.toISOString();\n const epochMs = String(now.getTime());\n const dateStr = timestamp.slice(0, 10);\n\n // LOCAL scope — a rejection is this worktree's event, and a per-worktree log has exactly one\n // writer, so its appends and its daily detail files cannot collide with another agent's.\n const hooksDir = dotWebpieces.localFile(root, HOOKS_STATE_DIR);\n const logsDir = dotWebpieces.logs(root);\n const dayDir = path.join(hooksDir, dateStr);\n fs.mkdirSync(dayDir, { recursive: true });\n fs.mkdirSync(logsDir, { recursive: true });\n\n const relativePath = computeRelativePath(input.filePath, root);\n const ruleNames = extractRuleNames(result.report);\n const detailFileName = `writeInfo-${epochMs}.md`;\n // Relative to the STATE DIR, not to the log file's own directory: the index now lives in\n // `logs/` while the detail lives in `hooks/<date>/`, so a bare `<date>/<file>` would no\n // longer resolve from where the reader found the line.\n const detailRelPath = `${HOOKS_STATE_DIR}/${dateStr}/${detailFileName}`;\n\n const detail = buildDetailContent(timestamp, toolKind, relativePath, ruleNames, result.report, input);\n fs.writeFileSync(path.join(dayDir, detailFileName), detail);\n\n const logPath = path.join(logsDir, logStream.fileName(LOG_FILE));\n rotateLogFile(logPath, path.join(logsDir, logStream.fileName(LOG_FILE_PREV)));\n\n const logLine = `[${timestamp}]\\t${toolKind}\\t${relativePath}\\t[${ruleNames.join(',')}]\\t${detailRelPath}\\n`;\n fs.appendFileSync(logPath, logLine);\n\n rotateOldDays(hooksDir, MAX_AGE_DAYS);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n}\n\nfunction computeRelativePath(filePath: string, cwd: string): string {\n if (filePath.startsWith(cwd)) {\n const rel = filePath.slice(cwd.length);\n if (rel.startsWith('/')) return rel.slice(1);\n return rel;\n }\n return filePath;\n}\n\n/**\n * The rule names a block report cites — every `[<rule-name>] (` header it opens with. Exported because\n * two audit streams need the same answer from the same regex: this file's rejection index, and the\n * `rule=` field guard-invocations.log now carries (see InvocationLog.finish). Two scrapers would be\n * two answers to one question.\n */\n// webpieces-disable no-function-outside-class -- pure regex scraper beside this module's other module-scope helpers; exported so the invocation log and the rejection index scrape rule names with the SAME code.\nexport function extractRuleNames(report: string): string[] {\n const names: string[] = [];\n let match = RULE_NAME_RE.exec(report);\n while (match !== null) {\n names.push(match[1]);\n match = RULE_NAME_RE.exec(report);\n }\n RULE_NAME_RE.lastIndex = 0;\n return names;\n}\n\nfunction buildDetailContent(\n timestamp: string,\n toolKind: ToolKind,\n relativePath: string,\n ruleNames: string[],\n report: string,\n input: NormalizedToolInput,\n): string {\n const lines: string[] = [];\n lines.push('# Hook Rejection Detail');\n lines.push('');\n lines.push(`- **Timestamp:** ${timestamp}`);\n lines.push(`- **Tool:** ${toolKind}`);\n lines.push(`- **File:** ${relativePath}`);\n lines.push(`- **Rules violated:** ${ruleNames.join(', ')}`);\n lines.push('');\n lines.push('## Report');\n lines.push('');\n lines.push('```');\n lines.push(report.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('## Content Being Written');\n lines.push('');\n\n if (toolKind === 'Write') {\n const content = input.edits.length > 0 ? input.edits[0].newString : '';\n lines.push('```typescript');\n lines.push(content.trimEnd());\n lines.push('```');\n } else {\n for (let i = 0; i < input.edits.length; i += 1) {\n const edit = input.edits[i];\n lines.push(`### Edit ${String(i + 1)} of ${String(input.edits.length)}`);\n lines.push('');\n lines.push('**old_string:**');\n lines.push('```typescript');\n lines.push(edit.oldString.trimEnd());\n lines.push('```');\n lines.push('');\n lines.push('**new_string:**');\n lines.push('```typescript');\n lines.push(edit.newString.trimEnd());\n lines.push('```');\n lines.push('');\n }\n }\n\n return lines.join('\\n') + '\\n';\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 err;\n }\n}\n\nfunction rotateOldDays(hooksDir: string, maxAgeDays: number): void {\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n let entries: string[];\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n entries = fs.readdirSync(hooksDir);\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n return;\n }\n\n for (const entry of entries) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(entry)) continue;\n const dirDate = new Date(entry + 'T00:00:00Z');\n if (isNaN(dirDate.getTime())) continue;\n if (dirDate.getTime() < cutoff) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(path.join(hooksDir, entry), { recursive: true, force: true });\n } catch (err: unknown) {\n //const error = toError(err);\n void err;\n }\n }\n }\n}\n"]}
@@ -95,12 +95,17 @@ fi
95
95
  # forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.
96
96
  PAYLOAD="$(cat)"
97
97
  CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
98
+ CMD_LOG="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"\\]*\).*/\1/p')"
99
+ [ -n "$CMD_LOG" ] || CMD_LOG="$CMD"
98
100
  TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
101
+ WP_SID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
102
+ WP_AID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"agent_id"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
99
103
  FILE="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
100
104
  WP_CWD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
101
105
  [ -n "$WP_CWD" ] || WP_CWD="$ROOT" # no cwd in the payload (older client, or a hand-run) → the shim's own tree
102
106
  # Best-effort AUDIT TRAIL of what L0 did with this call — every call, not just the broken ones. One
103
- # tab-separated line per invocation into this TREE's own logs/ai-hook-shim.log (gitignored), so the
107
+ # tab-separated line per invocation into this TREE's own
108
+ # logs/<session>-<agent|coordinator>-<binName>-ai-hook-shim.log (gitignored), so the
104
109
  # observed behaviour can be diffed against the matrix in guards/L0-tooling.md. NEVER breaks or blocks the
105
110
  # hook: every write is swallowed, and nothing ever goes to stdout (stdout is the PreToolUse decision
106
111
  # channel — a stray byte there would corrupt allow/deny).
@@ -133,17 +138,29 @@ wp_resolve_log_dir() {
133
138
  WP_LOG_DIR="$_wp_primary/.webpieces/worktrees/$WP_TREE/logs"
134
139
  fi
135
140
  }
141
+ wp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()
142
+ printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\.\{2,\}/_/g' -e 's/^\.\{1,\}/_/' | cut -c1-64
143
+ }
136
144
  wp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label
137
145
  {
138
146
  [ -n "$WP_LOG_DIR" ] || wp_resolve_log_dir
139
147
  mkdir -p "$WP_LOG_DIR" 2>/dev/null || return 0
140
- _wp_f="$WP_LOG_DIR/ai-hook-shim.log"
148
+ # Same flat scheme as LogStream.fileName(): <session>-<agent|coordinator>-<hook>-<base>. $BIN_NAME
149
+ # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those
150
+ # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above
151
+ # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching
152
+ # LogStream.fileName(), which has none either.
153
+ # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.
154
+ # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the
155
+ # torn-append case this exists to remove. Twin of LogStream.fileName(), which has no bare branch.
156
+ _wp_pfx="$(wp_clean "${WP_SID:-unknown}")-$(wp_clean "${WP_AID:-coordinator}")-$BIN_NAME-"
157
+ _wp_f="$WP_LOG_DIR/${_wp_pfx}ai-hook-shim.log"
141
158
  # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs
142
159
  # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).
143
160
  _wp_sz="$(wc -c < "$_wp_f" 2>/dev/null | tr -d ' ')"
144
161
  case "$_wp_sz" in ''|*[!0-9]*) _wp_sz=0 ;; esac
145
- [ "$_wp_sz" -gt 524288 ] && mv -f "$_wp_f" "$WP_LOG_DIR/ai-hook-shim.1.log" 2>/dev/null
146
- printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$BIN_NAME" "$TOOL" "tree=$WP_TREE" "fault=$1" "$2" "$CMD" >> "$_wp_f"
162
+ [ "$_wp_sz" -gt 524288 ] && mv -f "$_wp_f" "$WP_LOG_DIR/${_wp_pfx}ai-hook-shim.1.log" 2>/dev/null
163
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$BIN_NAME" "$TOOL" "tree=$WP_TREE" "fault=$1" "$2" "$CMD_LOG" >> "$_wp_f"
147
164
  } 2>/dev/null || true
148
165
  }
149
166
  BROKEN_BIN=""
@@ -0,0 +1,110 @@
1
+ #!/bin/sh
2
+ # webpieces L-1 hook — GUARANTEE ROOT. Generated by renderGuaranteeRoot(); do not hand-edit.
3
+ #
4
+ # Registered ABSOLUTE in .claude/settings.json, matcher "Bash":
5
+ # sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh"
6
+ #
7
+ # It exists because the GUARD hooks beside it are registered RELATIVE, so that each git tree is
8
+ # governed by its own @webpieces release. A relative hook that cannot resolve does not block — the
9
+ # harness logs it and lets the tool call proceed UNGUARDED. This file makes that unreachable by
10
+ # refusing any cd that would park the shell where the relative hooks cannot launch.
11
+ #
12
+ # Three tests, no config, no binary, no network:
13
+ # 1. destination holds .git -> ALLOW (tree root, worktree, or foreign clone)
14
+ # 2. destination is outside $CLAUDE_PROJECT_DIR -> ALLOW (the harness resets the cwd next call)
15
+ # 3. otherwise -> DENY (sticky AND unguarded)
16
+ #
17
+ # A denied cd never runs, so the shell never leaves the root and there is nothing to recover from.
18
+
19
+ PAYLOAD="$(cat)"
20
+ CWD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
21
+ TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
22
+
23
+ # The command PREFIX, not the whole command — note there is no closing " in this pattern.
24
+ # WHY: a JSON payload escapes an embedded double quote as \", and `[^"\\]*` stops dead at that
25
+ # backslash, so the usual "capture between quotes" form yields the EMPTY STRING for any command
26
+ # containing a quote at all (measured: `cd /a/b && echo "hi"` -> ''). An empty CMD here would mean
27
+ # "no cd found" -> ALLOW, i.e. this guard would fail OPEN for every quoted command — the exact hazard
28
+ # it exists to close. Capturing only up to the first quote/backslash is enough, because everything
29
+ # L-1 needs (is the FIRST word a cd, and what is its target) lives in the prefix; a quote can only
30
+ # appear later, in the part we do not need.
31
+ CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"\\]*\).*/\1/p')"
32
+
33
+ # Only Bash can move the shell. Anything else, and any payload we cannot read, is not ours.
34
+ [ "$TOOL" = "Bash" ] || exit 0
35
+ [ -n "$CMD" ] || exit 0
36
+
37
+ # --- cd audit (best-effort; never blocks, never touches stdout) --------------------------------
38
+ SID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
39
+ AID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"agent_id"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p')"
40
+ # Untrusted payload values are used as path segments, so anything outside [A-Za-z0-9._-] collapses to _
41
+ # and a leading dot is neutralised — ../../etc can never escape the logs directory.
42
+ clean() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' \
43
+ | sed -e 's/\.\{2,\}/_/g' -e 's/^\.\{1,\}/_/' | cut -c1-64; }
44
+ wp_cd_log() { # $1 = verdict, $2 = destination (may be empty)
45
+ {
46
+ [ -n "$CLAUDE_PROJECT_DIR" ] || return 0
47
+ _d="$CLAUDE_PROJECT_DIR/.webpieces/logs"
48
+ mkdir -p "$_d" 2>/dev/null || return 0
49
+ # Flat name, same scheme as LogStream.fileName(): <session>-<agent|coordinator>-guarantee-root-<base>
50
+ # ALWAYS prefixed; a missing session_id renders as 'unknown'. No bare-name branch anywhere.
51
+ _p="$(clean "${SID:-unknown}")-$(clean "${AID:-coordinator}")-guarantee-root-"
52
+ _f="$_d/${_p}cd-audit.log"
53
+ _sz="$(wc -c < "$_f" 2>/dev/null | tr -d ' ')"
54
+ case "$_sz" in ''|*[!0-9]*) _sz=0 ;; esac
55
+ [ "$_sz" -gt 524288 ] && mv -f "$_f" "$_d/${_p}cd-audit.1.log" 2>/dev/null
56
+ printf '%s\t%s\tdest=%s\tcwd=%s\t%s\n' \
57
+ "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$1" "$2" "$CWD" "$CMD" >> "$_f"
58
+ } 2>/dev/null || true
59
+ }
60
+
61
+
62
+ # Does the command OPEN with cd/pushd? Only a LEADING cd counts — the same rule effective-tree.ts
63
+ # enforces, because a later cd cannot retroactively move a command that has already run.
64
+ FIRST="$(printf '%s' "$CMD" | sed -n 's/^[[:space:]]*\([^[:space:]]\{1,\}\).*/\1/p')"
65
+ case "$FIRST" in
66
+ cd|pushd) ;;
67
+ *) exit 0 ;; # no leading cd: nothing to audit, nothing to judge
68
+ esac
69
+
70
+ # The target: a single-quoted path first (that is how a path with spaces is spelled), else a bare word.
71
+ DEST="$(printf '%s' "$CMD" | sed -n "s/^[[:space:]]*[a-z]\{2,5\}[[:space:]]\{1,\}'\([^']*\)'.*/\1/p")"
72
+ [ -n "$DEST" ] || DEST="$(printf '%s' "$CMD" | sed -n 's/^[[:space:]]*[a-z]\{2,5\}[[:space:]]\{1,\}\([^[:space:];&|]\{1,\}\).*/\1/p')"
73
+
74
+ REASON=""
75
+ if [ -z "$DEST" ] || [ "$DEST" = "-" ]; then
76
+ REASON='A bare cd (or cd -) moves the shell somewhere the guards cannot predict - a bare cd goes to your home directory, where the webpieces hooks do not exist and every later tool call would run UNGUARDED. Name the directory: cd /abs/path && <your command>.'
77
+ else
78
+ # A target the guard cannot expand is a target it cannot judge. sh has no regex here, so test the
79
+ # four unexpandable shapes directly.
80
+ case "$DEST" in
81
+ *'$'*|*'`'*|'~'|'~/'*) REASON='This cd target is not a literal path, so the guards cannot tell where the shell will end up. Use a literal absolute path: cd /abs/path && <your command>. A $VAR, ~, $(...) or backtick is never expanded by the guard.' ;;
82
+ esac
83
+ fi
84
+
85
+ if [ -z "$REASON" ]; then
86
+ # Resolve against the shell's real cwd. A destination that does not exist needs no verdict: the cd
87
+ # itself will fail and the shell stays exactly where it is.
88
+ ABS="$(CDPATH= cd -- "${CWD:-.}" 2>/dev/null && CDPATH= cd -- "$DEST" 2>/dev/null && pwd)"
89
+ if [ -z "$ABS" ]; then wp_cd_log ALLOW-NO-SUCH-DIR "$DEST"; exit 0; fi
90
+
91
+ # TEST 1 — a git tree of any kind. A worktree's .git is a FILE, a clone's is a DIR; -e covers both.
92
+ if [ -e "$ABS/.git" ]; then wp_cd_log ALLOW-GIT-TREE "$ABS"; exit 0; fi
93
+
94
+ # TEST 2 — outside the governed project. The harness resets the cwd before the next call, so at most
95
+ # one command runs there, on paths we do not govern anyway.
96
+ case "$ABS/" in
97
+ "$CLAUDE_PROJECT_DIR"/*) ;;
98
+ *) wp_cd_log ALLOW-OUTSIDE "$ABS"; exit 0 ;;
99
+ esac
100
+
101
+ # TEST 3 — inside a governed tree with no shim beside it: sticky AND unguarded.
102
+ REASON="$(printf 'The webpieces guard hooks are registered RELATIVE (.claude/webpieces/ai-hook.sh) so that each git tree is governed by its own release. %s has no .claude/webpieces/ai-hook.sh, so a shell parked there launches NO hooks at all and every later tool call runs UNGUARDED - and a cd that stays inside the project PERSISTS to your next call. Run it from the tree root instead: cd %s && <your command>. Tools that take their own directory (git -C, pnpm -C, pnpm --filter, nx) need no cd at all.' "$ABS" "$CLAUDE_PROJECT_DIR")"
103
+ fi
104
+
105
+ wp_cd_log DENY "${ABS:-$DEST}"
106
+
107
+ BS='\' # one literal backslash, so the \u001b escape never sits in this source
108
+ ESC="${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \u001b -> ESC
109
+ printf '{"systemMessage":"%s🛑 %s%s","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "${ESC}[31;1m" "$REASON" "${ESC}[0m" "$REASON"
110
+ exit 0 # the decision is carried by permissionDecision deny, not the exit code