@webpieces/ai-hook-rules 0.3.201 → 0.3.202

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.3.201",
3
+ "version": "0.3.202",
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",
@@ -35,7 +35,7 @@
35
35
  "directory": "packages/tooling/ai-hook-rules"
36
36
  },
37
37
  "dependencies": {
38
- "@webpieces/rules-config": "0.3.201"
38
+ "@webpieces/rules-config": "0.3.202"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
@@ -0,0 +1,15 @@
1
+ export type SyncPhase = 'SPAWN_ATTEMPT' | 'START' | 'SKIP_INPROGRESS' | 'FINISH' | 'ERROR';
2
+ export declare class SyncLogEvent {
3
+ phase: SyncPhase;
4
+ pid: number;
5
+ branchArg: string;
6
+ detail: string;
7
+ constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string);
8
+ }
9
+ /**
10
+ * Append one tab-separated line per refresher event to `.webpieces/hooks/main-sync.log`. `root` is
11
+ * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
12
+ * the refresher (or the hook that spawns it).
13
+ */
14
+ export declare function logSyncEvent(root: string, event: SyncLogEvent): void;
15
+ export declare function syncStderrLogPath(root: string): string;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyncLogEvent = void 0;
4
+ exports.logSyncEvent = logSyncEvent;
5
+ exports.syncStderrLogPath = syncStderrLogPath;
6
+ const tslib_1 = require("tslib");
7
+ const fs = tslib_1.__importStar(require("fs"));
8
+ const path = tslib_1.__importStar(require("path"));
9
+ const to_error_1 = require("./to-error");
10
+ // Observability for the detached background refresher (sync-main.ts). The refresher runs AFTER the
11
+ // spawning hook has exited, with stdio discarded, so when it fails to update main-sync-status.json
12
+ // there is normally no trace at all. This log captures its lifecycle — SPAWN_ATTEMPT (parent side),
13
+ // then START / SKIP_INPROGRESS / FINISH / ERROR (child side) — so we can tell whether the detached
14
+ // child never launched, was killed mid-run (START with no FINISH), or threw. Modeled on
15
+ // decision-log.ts: same .webpieces/hooks dir, 512KB size rotation, toError-compliant catches.
16
+ const HOOKS_DIR = '.webpieces/hooks';
17
+ const LOG_FILE = 'main-sync.log';
18
+ const LOG_FILE_PREV = 'main-sync.1.log';
19
+ const STDERR_FILE = 'main-sync.stderr.log';
20
+ const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors decision-log)
21
+ const MAX_DETAIL_LEN = 300;
22
+ // Data-only record of one refresher lifecycle event (per CLAUDE.md: classes for data).
23
+ class SyncLogEvent {
24
+ phase;
25
+ pid;
26
+ branchArg;
27
+ detail;
28
+ constructor(phase, pid, branchArg, detail) {
29
+ this.phase = phase;
30
+ this.pid = pid;
31
+ this.branchArg = branchArg;
32
+ this.detail = detail;
33
+ }
34
+ }
35
+ exports.SyncLogEvent = SyncLogEvent;
36
+ /**
37
+ * Append one tab-separated line per refresher event to `.webpieces/hooks/main-sync.log`. `root` is
38
+ * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
39
+ * the refresher (or the hook that spawns it).
40
+ */
41
+ function logSyncEvent(root, event) {
42
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
43
+ try {
44
+ const timestamp = new Date().toISOString();
45
+ const hooksDir = path.join(root, HOOKS_DIR);
46
+ fs.mkdirSync(hooksDir, { recursive: true });
47
+ const logPath = path.join(hooksDir, LOG_FILE);
48
+ rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));
49
+ const line = [
50
+ `[${timestamp}]`,
51
+ event.phase,
52
+ `pid=${String(event.pid)}`,
53
+ event.branchArg,
54
+ oneLine(event.detail),
55
+ ].join('\t') + '\n';
56
+ fs.appendFileSync(logPath, line);
57
+ }
58
+ catch (err) {
59
+ const error = (0, to_error_1.toError)(err);
60
+ void error;
61
+ }
62
+ }
63
+ // Absolute path the detached child's stdout/stderr are redirected to (opened with fs.openSync(p,'a')
64
+ // by the spawner), so even a crash BEFORE our own logging runs — e.g. a module-load failure — is
65
+ // captured instead of vanishing into /dev/null. Callers must ensure the hooks dir exists first
66
+ // (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).
67
+ function syncStderrLogPath(root) {
68
+ return path.join(root, HOOKS_DIR, STDERR_FILE);
69
+ }
70
+ // Collapse newlines/tabs and cap length so one event is always one log line.
71
+ function oneLine(value) {
72
+ const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
73
+ return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';
74
+ }
75
+ function rotateLogFile(logPath, prevPath) {
76
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
77
+ try {
78
+ const stat = fs.statSync(logPath);
79
+ if (stat.size > MAX_LOG_BYTES) {
80
+ if (fs.existsSync(prevPath))
81
+ fs.unlinkSync(prevPath);
82
+ fs.renameSync(logPath, prevPath);
83
+ }
84
+ }
85
+ catch (err) {
86
+ const error = (0, to_error_1.toError)(err);
87
+ void error;
88
+ }
89
+ }
90
+ //# sourceMappingURL=main-sync-log.js.map
@@ -0,0 +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 { toError } from './to-error';\n\n// Observability for the detached background refresher (sync-main.ts). The refresher runs AFTER the\n// spawning hook has exited, with stdio discarded, so when it fails to update main-sync-status.json\n// there is normally no trace at all. This log captures its lifecycle — SPAWN_ATTEMPT (parent side),\n// then START / SKIP_INPROGRESS / FINISH / ERROR (child side) — so we can tell whether the detached\n// child never launched, was killed mid-run (START with no FINISH), or threw. Modeled on\n// decision-log.ts: same .webpieces/hooks dir, 512KB size rotation, toError-compliant catches.\nconst HOOKS_DIR = '.webpieces/hooks';\nconst LOG_FILE = 'main-sync.log';\nconst LOG_FILE_PREV = 'main-sync.1.log';\nconst STDERR_FILE = 'main-sync.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/hooks/main-sync.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 const hooksDir = path.join(root, HOOKS_DIR);\n fs.mkdirSync(hooksDir, { recursive: true });\n\n const logPath = path.join(hooksDir, LOG_FILE);\n rotateLogFile(logPath, path.join(hooksDir, 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 hooks dir exists first\n// (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).\nexport function syncStderrLogPath(root: string): string {\n return path.join(root, HOOKS_DIR, 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"]}
@@ -3,5 +3,10 @@
3
3
  * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
4
4
  * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on
5
5
  * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
6
+ *
7
+ * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
8
+ * `.webpieces/hooks/main-sync.log`. The child's stdout/stderr are redirected to a sibling file (not
9
+ * /dev/null) so a crash before the child's own logging is still captured. If main-sync.log shows
10
+ * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
6
11
  */
7
12
  export declare function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes?: number): void;
@@ -3,24 +3,43 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.triggerMainSyncRefresh = triggerMainSyncRefresh;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
+ const fs = tslib_1.__importStar(require("fs"));
6
7
  const path = tslib_1.__importStar(require("path"));
7
8
  const rules_config_1 = require("@webpieces/rules-config");
8
9
  const to_error_1 = require("./to-error");
10
+ const main_sync_log_1 = require("./main-sync-log");
9
11
  /**
10
12
  * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,
11
13
  * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
12
14
  * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on
13
15
  * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
16
+ *
17
+ * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
18
+ * `.webpieces/hooks/main-sync.log`. The child's stdout/stderr are redirected to a sibling file (not
19
+ * /dev/null) so a crash before the child's own logging is still captured. If main-sync.log shows
20
+ * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
14
21
  */
15
22
  function triggerMainSyncRefresh(workspaceRoot, hangTimeoutMinutes = rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES) {
16
23
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
17
24
  try {
18
25
  const refresher = path.join(__dirname, 'sync-main.js');
26
+ // SPAWN_ATTEMPT first — this also creates .webpieces/hooks so the stderr fd below can open.
27
+ (0, main_sync_log_1.logSyncEvent)(workspaceRoot, new main_sync_log_1.SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));
28
+ // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash
29
+ // before the child's own logging — e.g. a module-load failure — is still captured.
30
+ const errFd = fs.openSync((0, main_sync_log_1.syncStderrLogPath)(workspaceRoot), 'a');
19
31
  const child = (0, child_process_1.spawn)(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {
20
32
  detached: true,
21
- stdio: 'ignore',
33
+ stdio: ['ignore', errFd, errFd],
34
+ });
35
+ // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit
36
+ // before this handler runs, but on POSIX a successful exec has already happened by now.
37
+ child.once('error', (err) => {
38
+ (0, main_sync_log_1.logSyncEvent)(workspaceRoot, new main_sync_log_1.SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));
22
39
  });
23
40
  child.unref();
41
+ // The child has its own dup'd copy of the fd after spawn; close the parent's copy.
42
+ fs.closeSync(errFd);
24
43
  }
25
44
  catch (err) {
26
45
  const error = (0, to_error_1.toError)(err);
@@ -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":";;AAaA,wDAcC;;AA3BD,iDAAsC;AACtC,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AAErC;;;;;GAKG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,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,QAAQ;SAClB,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;IAClB,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 path from 'path';\n\nimport { DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\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 */\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number = DEFAULT_HANG_TIMEOUT_MINUTES): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n const child = spawn(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {\n detached: true,\n stdio: 'ignore',\n });\n child.unref();\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":";;AAoBA,wDA2BC;;AA/CD,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AACrC,mDAAgF;AAEhF;;;;;;;;;;GAUG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,4FAA4F;QAC5F,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/**\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/hooks/main-sync.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 main-sync.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 // 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/hooks 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"]}
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.main = main;
4
4
  const rules_config_1 = require("@webpieces/rules-config");
5
5
  const to_error_1 = require("./to-error");
6
+ const main_sync_log_1 = require("./main-sync-log");
6
7
  /**
7
8
  * The detached, fire-and-forget refresher spawned (by file path, not a bin) from
8
9
  * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +
@@ -19,16 +20,24 @@ const to_error_1 = require("./to-error");
19
20
  function main() {
20
21
  const repoRoot = process.argv[2] ?? process.cwd();
21
22
  const hangTimeoutMinutes = Number(process.argv[3]) || rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES;
23
+ const startedMs = Date.now();
24
+ // First action: prove the detached child actually started. If main-sync.log has no START line
25
+ // for a spawn, the child never launched (or died before this point).
26
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));
22
27
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
23
28
  try {
24
- if ((0, rules_config_1.isRefreshInProgress)(repoRoot, hangTimeoutMinutes))
29
+ if ((0, rules_config_1.isRefreshInProgress)(repoRoot, hangTimeoutMinutes)) {
30
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));
25
31
  return;
32
+ }
26
33
  const lock = (0, rules_config_1.inProcessLock)();
27
34
  (0, rules_config_1.writeMainSyncLock)(repoRoot, lock);
28
35
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
29
36
  try {
30
37
  const status = (0, rules_config_1.computeMainSyncStatus)(repoRoot);
31
38
  (0, rules_config_1.writeMainSyncStatus)(repoRoot, status);
39
+ // FINISH after a successful write — START-without-FINISH means we were killed mid-run.
40
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ms=${String(Date.now() - startedMs)}`));
32
41
  }
33
42
  finally {
34
43
  // Always flip the lock off so a compute failure can't wedge the guard until the
@@ -38,9 +47,9 @@ function main() {
38
47
  }
39
48
  catch (err) {
40
49
  const error = (0, to_error_1.toError)(err);
41
- void error;
42
- // Detached: swallow so a transient git/fs error never leaves poison state. The next hook
43
- // call spawns a fresh refresher.
50
+ // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call
51
+ // spawns a fresh refresher) but record WHY it died so the failure isn't invisible.
52
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));
44
53
  }
45
54
  }
46
55
  if (require.main === module) {
@@ -1 +1 @@
1
- {"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":";;AAyBA,oBAyBC;AAlDD,0DAQiC;AAEjC,yCAAqC;AAErC;;;;;;;;;;;;GAYG;AACH,SAAgB,IAAI;IAChB,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAClD,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,2CAA4B,CAAC;IAEnF,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,IAAA,kCAAmB,EAAC,QAAQ,EAAE,kBAAkB,CAAC;YAAE,OAAO;QAE9D,MAAM,IAAI,GAAG,IAAA,4BAAa,GAAE,CAAC;QAC7B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAClC,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAA,oCAAqB,EAAC,QAAQ,CAAC,CAAC;YAC/C,IAAA,kCAAmB,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC1C,CAAC;gBAAS,CAAC;YACP,gFAAgF;YAChF,8BAA8B;YAC9B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,yFAAyF;QACzF,iCAAiC;IACrC,CAAC;AACL,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,IAAI,EAAE,CAAC;AACX,CAAC","sourcesContent":["import {\n DEFAULT_HANG_TIMEOUT_MINUTES,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) return;\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Detached: swallow so a transient git/fs error never leaves poison state. The next hook\n // call spawns a fresh refresher.\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
1
+ {"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":";;AA0BA,oBAsCC;AAhED,0DAQiC;AAEjC,yCAAqC;AACrC,mDAA6D;AAE7D;;;;;;;;;;;;GAYG;AACH,SAAgB,IAAI;IAChB,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAClD,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,2CAA4B,CAAC;IACnF,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAE7B,8FAA8F;IAC9F,qEAAqE;IACrE,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAE/G,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,IAAA,kCAAmB,EAAC,QAAQ,EAAE,kBAAkB,CAAC,EAAE,CAAC;YACpD,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,iBAAiB,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,gCAAgC,CAAC,CAAC,CAAC;YAChH,OAAO;QACX,CAAC;QAED,MAAM,IAAI,GAAG,IAAA,4BAAa,GAAE,CAAC;QAC7B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAClC,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAA,oCAAqB,EAAC,QAAQ,CAAC,CAAC;YAC/C,IAAA,kCAAmB,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YACtC,uFAAuF;YACvF,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CACnC,QAAQ,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EACpC,UAAU,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,aAAa,MAAM,CAAC,QAAQ,cAAc,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,aAAa,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,EAAE,CAC/L,CAAC,CAAC;QACP,CAAC;gBAAS,CAAC;YACP,gFAAgF;YAChF,8BAA8B;YAC9B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,8FAA8F;QAC9F,qFAAqF;QACrF,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,MAAM,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;IACnH,CAAC;AACL,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,IAAI,EAAE,CAAC;AACX,CAAC","sourcesContent":["import {\n DEFAULT_HANG_TIMEOUT_MINUTES,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If main-sync.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, status.branch,\n `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}