@geraldmaron/construct 1.0.12 → 1.0.14

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.
Files changed (88) hide show
  1. package/README.md +8 -2
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +11 -7
  5. package/lib/dashboard-static.mjs +7 -4
  6. package/lib/doc-stamp.mjs +16 -0
  7. package/lib/docs-verify.mjs +1 -8
  8. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  9. package/lib/embed/daemon.mjs +20 -0
  10. package/lib/embed/docs-lifecycle.mjs +19 -0
  11. package/lib/embed/inbox.mjs +85 -2
  12. package/lib/embed/recommendation-store.mjs +29 -0
  13. package/lib/gates-audit.mjs +18 -12
  14. package/lib/hooks/_lib/input.mjs +52 -0
  15. package/lib/hooks/adaptive-lint.mjs +4 -0
  16. package/lib/hooks/agent-tracker.mjs +59 -15
  17. package/lib/hooks/audit-reads.mjs +83 -42
  18. package/lib/hooks/audit-trail.mjs +28 -18
  19. package/lib/hooks/bash-output-logger.mjs +8 -2
  20. package/lib/hooks/block-no-verify.mjs +4 -0
  21. package/lib/hooks/ci-status-check.mjs +4 -0
  22. package/lib/hooks/comment-lint.mjs +6 -4
  23. package/lib/hooks/config-protection.mjs +4 -0
  24. package/lib/hooks/context-watch.mjs +4 -0
  25. package/lib/hooks/context-window-recovery.mjs +4 -0
  26. package/lib/hooks/dep-audit.mjs +12 -5
  27. package/lib/hooks/doc-coupling-check.mjs +5 -1
  28. package/lib/hooks/edit-accumulator.mjs +25 -10
  29. package/lib/hooks/edit-error-recovery.mjs +4 -0
  30. package/lib/hooks/edit-guard.mjs +4 -0
  31. package/lib/hooks/guard-bash.mjs +4 -0
  32. package/lib/hooks/mcp-audit.mjs +4 -0
  33. package/lib/hooks/mcp-health-check.mjs +4 -0
  34. package/lib/hooks/model-fallback.mjs +7 -11
  35. package/lib/hooks/policy-engine.mjs +4 -0
  36. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  37. package/lib/hooks/post-merge-tracking.mjs +82 -0
  38. package/lib/hooks/pre-compact.mjs +4 -0
  39. package/lib/hooks/pre-push-gate.mjs +84 -231
  40. package/lib/hooks/proactive-activation.mjs +5 -2
  41. package/lib/hooks/readme-age-check.mjs +4 -0
  42. package/lib/hooks/registry-sync.mjs +35 -20
  43. package/lib/hooks/rule-verifier.mjs +3 -0
  44. package/lib/hooks/scan-secrets.mjs +4 -0
  45. package/lib/hooks/session-optimize.mjs +4 -0
  46. package/lib/hooks/session-reflect.mjs +4 -0
  47. package/lib/hooks/session-start.mjs +48 -1
  48. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  49. package/lib/hooks/stop-notify.mjs +13 -2
  50. package/lib/hooks/stop-typecheck.mjs +4 -0
  51. package/lib/hooks/test-watch.mjs +8 -4
  52. package/lib/init-unified.mjs +90 -23
  53. package/lib/intake/attribution.mjs +77 -0
  54. package/lib/intake/intake-config.mjs +3 -0
  55. package/lib/intake/manifest.mjs +107 -0
  56. package/lib/intake/poll-lock.mjs +136 -0
  57. package/lib/intake/prepare.mjs +42 -4
  58. package/lib/logging/rotate.mjs +394 -0
  59. package/lib/mcp/tools/project.mjs +2 -2
  60. package/lib/mcp/tools/telemetry.mjs +1 -1
  61. package/lib/opencode-config.mjs +10 -3
  62. package/lib/orchestration/routing-tables.mjs +176 -0
  63. package/lib/orchestration-policy.mjs +18 -106
  64. package/lib/parity.mjs +48 -7
  65. package/lib/profiles/lifecycle.mjs +19 -1
  66. package/lib/project-init-shared.mjs +11 -5
  67. package/lib/project-root.mjs +104 -0
  68. package/lib/roles/catalog.mjs +1 -1
  69. package/lib/roles/event-bus.mjs +29 -9
  70. package/lib/roles/router.mjs +8 -7
  71. package/lib/server/index.mjs +31 -9
  72. package/lib/server/static/index.html +1 -15
  73. package/lib/specialist-contracts-enforce.mjs +24 -10
  74. package/lib/status.mjs +1 -1
  75. package/lib/storage/backup.mjs +2 -2
  76. package/lib/telemetry/intent-verifications.mjs +16 -3
  77. package/lib/telemetry/skill-calls.mjs +12 -3
  78. package/lib/tracking-surfaces.mjs +375 -0
  79. package/lib/worker/trace.mjs +19 -2
  80. package/package.json +6 -2
  81. package/platforms/claude/settings.template.json +28 -27
  82. package/scripts/sync-specialists.mjs +269 -75
  83. package/specialists/registry.json +158 -13
  84. package/lib/hooks/env-check.mjs +0 -83
  85. package/lib/hooks/read-tracker.mjs +0 -61
  86. package/lib/policy/unified-gates.mjs +0 -96
  87. package/lib/server/static/assets/index-ab25c707.js +0 -70
  88. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -7,7 +7,9 @@
7
7
  * For Bash, only records commands that mutate state (git/npm/docker/cargo etc.
8
8
  * write operations, rm, mv, chmod). Read-only commands are skipped.
9
9
  *
10
- * Each record is a single JSONL line written to ~/.cx/audit-trail.jsonl with:
10
+ * Each record is a single JSONL line written to
11
+ * <project>/.cx/audit-trail.jsonl (falls back to ~/.cx/audit-trail.jsonl
12
+ * when the hook fires outside a Construct project) with:
11
13
  * - timestamp ISO
12
14
  * - session_id (from hook input when available)
13
15
  * - tool_name
@@ -21,18 +23,26 @@
21
23
  * - prev_line_hash (chained tamper-evidence — hash of previous JSONL line)
22
24
  *
23
25
  * The prev_line_hash chain means any after-the-fact reordering or deletion
24
- * breaks the chain and is detectable by a simple replay.
26
+ * breaks the chain and is detectable by a simple replay. Bounded via the
27
+ * `audit-trail` channel registered in lib/logging/rotate.mjs LIMITS.
25
28
  *
26
29
  * @p95ms 15
27
30
  * @maxBlockingScope none (PostToolUse, non-blocking)
31
+ *
32
+ * @lifecycle PostToolUse
33
+ * @matcher Edit|Write|MultiEdit|NotebookEdit|Bash
34
+ * @exits 0 = pass
28
35
  */
29
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
36
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
30
37
  import { createHash } from 'node:crypto';
31
- import { join, resolve } from 'node:path';
38
+ import { join, resolve, dirname } from 'node:path';
32
39
  import { homedir } from 'node:os';
40
+ import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
41
+ import { resolveProjectScopedPath } from '../project-root.mjs';
42
+ import { logHookFailure } from './_lib/log.mjs';
33
43
 
34
44
  const CX_DIR = join(homedir(), '.cx');
35
- const AUDIT_FILE = join(CX_DIR, 'audit-trail.jsonl');
45
+ const AUDIT_FILE = resolveProjectScopedPath('audit-trail.jsonl', { ensureDir: false });
36
46
  const LAST_AGENT = join(CX_DIR, 'last-agent.json');
37
47
  const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']);
38
48
 
@@ -69,14 +79,9 @@ function readLastAgent() {
69
79
 
70
80
  function readPrevLineHash() {
71
81
  try {
72
- if (!existsSync(AUDIT_FILE)) return null;
73
- const size = statSync(AUDIT_FILE).size;
74
- if (size === 0) return null;
75
- const readFrom = Math.max(0, size - 2048);
76
- const fs = readFileSync(AUDIT_FILE, 'utf8').slice(readFrom);
77
- const lines = fs.split('\n').filter(Boolean);
78
- if (lines.length === 0) return null;
79
- return sha256(lines[lines.length - 1]);
82
+ const lastLine = readLastLineAcrossSegments(AUDIT_FILE);
83
+ if (lastLine === null) return null;
84
+ return sha256(lastLine);
80
85
  } catch {
81
86
  return null;
82
87
  }
@@ -89,7 +94,8 @@ function truncateSnippet(s, limit = 280) {
89
94
  }
90
95
 
91
96
  let input = {};
92
- try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
97
+ try { input = JSON.parse(readFileSync(0, 'utf8')); }
98
+ catch (err) { logHookFailure({ hook: 'audit-trail', err, phase: 'parse' }); process.exit(0); }
93
99
 
94
100
  const toolName = input?.tool_name || '';
95
101
  if (!MUTATING_TOOLS.has(toolName)) process.exit(0);
@@ -129,7 +135,9 @@ if (toolName !== 'Bash' && target) {
129
135
  if (existsSync(absPath)) {
130
136
  contentHash = sha256(readFileSync(absPath, 'utf8')).slice(0, 32);
131
137
  }
132
- } catch { /* best effort */ }
138
+ } catch (err) {
139
+ logHookFailure({ hook: 'audit-trail', err, phase: 'hash', input: { target: absPath } });
140
+ }
133
141
  }
134
142
 
135
143
  const record = {
@@ -146,8 +154,10 @@ const record = {
146
154
  };
147
155
 
148
156
  try {
149
- mkdirSync(CX_DIR, { recursive: true });
150
- appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', 'utf8');
151
- } catch { /* best effort — audit failure must not block the user's work */ }
157
+ mkdirSync(dirname(AUDIT_FILE), { recursive: true });
158
+ appendBounded('audit-trail', AUDIT_FILE, JSON.stringify(record) + '\n');
159
+ } catch (err) {
160
+ logHookFailure({ hook: 'audit-trail', err, phase: 'append' });
161
+ }
152
162
 
153
163
  process.exit(0);
@@ -14,10 +14,15 @@
14
14
  *
15
15
  * @p95ms 20
16
16
  * @maxBlockingScope none (PostToolUse, non-blocking)
17
+ *
18
+ * @lifecycle PostToolUse
19
+ * @matcher Bash
20
+ * @exits 0 = pass
17
21
  */
18
- import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
22
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
19
23
  import { join } from 'node:path';
20
24
  import { homedir } from 'node:os';
25
+ import { appendBounded } from '../logging/rotate.mjs';
21
26
 
22
27
  const SIZE_THRESHOLD_CHARS = 4000;
23
28
  const LOG_DIR = join(homedir(), '.cx', 'bash-logs');
@@ -61,7 +66,8 @@ try {
61
66
  );
62
67
 
63
68
  try {
64
- appendFileSync(
69
+ appendBounded(
70
+ 'bash-warn-flags',
65
71
  WARN_FLAGS,
66
72
  `Bash output ${kb} KB saved to ${logPath} — prefer grepping the log over re-running.\n`,
67
73
  );
@@ -12,6 +12,10 @@
12
12
  *
13
13
  * @p95ms 5
14
14
  * @maxBlockingScope PreToolUse
15
+ *
16
+ * @lifecycle PreToolUse
17
+ * @matcher Bash
18
+ * @exits 0 = pass | 2 = block tool call
15
19
  */
16
20
  import { readFileSync } from 'node:fs';
17
21
 
@@ -14,6 +14,10 @@
14
14
  *
15
15
  * @p95ms 4000
16
16
  * @maxBlockingScope none (UserPromptSubmit, non-blocking)
17
+ *
18
+ * @lifecycle UserPromptSubmit
19
+ * @matcher *
20
+ * @exits 0 = pass
17
21
  */
18
22
 
19
23
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
@@ -7,13 +7,17 @@
7
7
  * narrative voice, point-in-time notes, noise sentinels. The agent sees the
8
8
  * block reason and is expected to fix the comment before continuing.
9
9
  *
10
- * Bypass: set CONSTRUCT_SKIP_COMMENT_LINT=1 in the environment.
10
+ * No bypass. If the policy fires on a legitimate comment, the policy is wrong
11
+ * — fix the policy in lib/comment-lint.mjs, not the call site.
11
12
  *
12
13
  * Input: reads tool_input.file_path from either the legacy TOOL_INPUT_FILE_PATH
13
14
  * env var or modern stdin JSON ({ tool_input: { file_path } }).
14
15
  *
16
+ * @lifecycle PostToolUse
17
+ * @matcher Write|Edit|MultiEdit
15
18
  * @p95ms 60
16
19
  * @maxBlockingScope PostToolUse
20
+ * @exits 0 = pass | 2 = block tool call
17
21
  */
18
22
 
19
23
  import { readFileSync } from 'node:fs';
@@ -21,8 +25,6 @@ import path from 'node:path';
21
25
  import { fileURLToPath } from 'node:url';
22
26
  import { lintFile, formatResults } from '../comment-lint.mjs';
23
27
 
24
- if (process.env.CONSTRUCT_SKIP_COMMENT_LINT === '1') process.exit(0);
25
-
26
28
  let filePath = process.env.TOOL_INPUT_FILE_PATH;
27
29
  if (!filePath) {
28
30
  try {
@@ -59,6 +61,6 @@ if (artifactOnly) {
59
61
  }
60
62
 
61
63
  process.stderr.write(
62
- '\nComment policy blocked this edit. Remove the violations or set CONSTRUCT_SKIP_COMMENT_LINT=1.\n',
64
+ '\nComment policy blocked this edit. Remove the violations before continuing.\n',
63
65
  );
64
66
  process.exit(2);
@@ -16,6 +16,10 @@
16
16
  *
17
17
  * @p95ms 5
18
18
  * @maxBlockingScope PreToolUse
19
+ *
20
+ * @lifecycle PreToolUse
21
+ * @matcher Write|Edit|MultiEdit
22
+ * @exits 0 = pass | 2 = block tool call
19
23
  */
20
24
  import { readFileSync } from 'fs';
21
25
  import { logHookFailure } from './_lib/log.mjs';
@@ -26,6 +26,10 @@
26
26
  *
27
27
  * @p95ms 20
28
28
  * @maxBlockingScope UserPromptSubmit
29
+ *
30
+ * @lifecycle UserPromptSubmit
31
+ * @matcher *
32
+ * @exits 0 = pass
29
33
  */
30
34
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
31
35
  import { join } from 'node:path';
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 10
8
8
  * @maxBlockingScope none (PostToolUse, non-blocking)
9
+ *
10
+ * @lifecycle PostToolUseFailure
11
+ * @matcher *
12
+ * @exits 0 = pass
9
13
  */
10
14
  import { readFileSync, writeFileSync, mkdirSync } from 'fs';
11
15
  import { homedir } from 'os';
@@ -8,11 +8,17 @@
8
8
  *
9
9
  * @p95ms 5000
10
10
  * @maxBlockingScope none (PostToolUse, async, non-blocking)
11
+ *
12
+ * @lifecycle PostToolUse
13
+ * @matcher Write|Edit
14
+ * @exits 0 = pass
11
15
  */
12
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs';
13
- import { execSync } from 'child_process';
14
- import { homedir } from 'os';
15
- import { join, dirname, basename } from 'path';
16
+ import { readFileSync, existsSync } from 'node:fs';
17
+ import { execSync } from 'node:child_process';
18
+ import { homedir } from 'node:os';
19
+ import { join, dirname, basename } from 'node:path';
20
+ import { appendBounded } from '../logging/rotate.mjs';
21
+ import { logHookFailure } from './_lib/log.mjs';
16
22
 
17
23
  const filePath = process.env.TOOL_INPUT_FILE_PATH || '';
18
24
  if (!filePath) process.exit(0);
@@ -28,7 +34,8 @@ const projectDir = dirname(filePath);
28
34
  const warnFlagsPath = join(homedir(), '.cx', 'warn-flags.txt');
29
35
 
30
36
  function appendWarn(msg) {
31
- try { appendFileSync(warnFlagsPath, msg + '\n'); } catch { /* best effort */ }
37
+ try { appendBounded('bash-warn-flags', warnFlagsPath, msg + '\n'); }
38
+ catch (err) { logHookFailure({ hook: 'dep-audit', err, phase: 'append' }); }
32
39
  process.stderr.write(`[dep-audit] ${msg}\n`);
33
40
  emitVulnerability(msg);
34
41
  }
@@ -12,6 +12,10 @@
12
12
  *
13
13
  * @p95ms 15
14
14
  * @maxBlockingScope none (PostToolUse, non-blocking)
15
+ *
16
+ * @lifecycle PostToolUse
17
+ * @matcher Write|Edit|MultiEdit
18
+ * @exits 0 = pass
15
19
  */
16
20
 
17
21
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
@@ -73,7 +77,7 @@ writeState(state);
73
77
 
74
78
  if (advisory != null) {
75
79
  process.stderr.write(
76
- `[doc-coupling] ${state.codeEdits} code file edit(s) this session under lib/|bin/|src/|app/; CHANGELOG.md / docs / .cx/context still not touched. Update docs before the next commit (the pre-commit gate will block otherwise; bypass: CONSTRUCT_SKIP_DOCS=1).\n`,
80
+ `[doc-coupling] ${state.codeEdits} code file edit(s) this session under lib/|bin/|src/|app/; CHANGELOG.md / docs / .cx/context still not touched. Update docs before the next commit (the pre-commit gate will block otherwise).\n`,
77
81
  );
78
82
  }
79
83
 
@@ -1,39 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/hooks/edit-accumulator.mjs — Edit accumulator hook batches and summarizes recent file edits for context.
3
+ * lib/hooks/edit-accumulator.mjs — accumulates edited paths for the next
4
+ * typecheck batch.
4
5
  *
5
- * Runs as PostToolUse after Edit/Write. Maintains a rolling log of recent edits in ~/.cx/edit-log.json for use by other hooks and efficiency reporting.
6
+ * Runs as PostToolUse after Edit/Write. Maintains two pieces of session
7
+ * state at ~/.cx/: a total edit counter for surface-area summaries and a
8
+ * deduped queue of TS/JS files awaiting typecheck. The typecheck queue is
9
+ * bounded via the `edit-accumulator` channel in lib/logging/rotate.mjs.
6
10
  *
7
11
  * @p95ms 10
8
12
  * @maxBlockingScope none (PostToolUse, non-blocking)
13
+ *
14
+ * @lifecycle PostToolUse
15
+ * @matcher Write|Edit|MultiEdit
16
+ * @exits 0 = pass
9
17
  */
10
- import { appendFileSync, readFileSync, writeFileSync, existsSync } from 'fs';
11
- import { homedir } from 'os';
18
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import path from 'node:path';
21
+ import { appendBounded } from '../logging/rotate.mjs';
22
+ import { logHookFailure } from './_lib/log.mjs';
12
23
 
13
24
  const TS_EXTS = new Set(['.ts','.tsx','.js','.mjs','.jsx','.cjs']);
14
25
  const filePath = process.env.TOOL_INPUT_FILE_PATH || '';
15
26
 
16
27
  if (!filePath) process.exit(0);
17
28
 
18
- const ext = '.' + filePath.split('.').pop();
29
+ const ext = path.extname(filePath);
19
30
  const home = homedir();
20
- const countPath = `${home}/.cx/files-changed-count.txt`;
31
+ const countPath = path.join(home, '.cx', 'files-changed-count.txt');
21
32
 
22
33
  // Always increment the total files-changed counter for notify
23
34
  try {
24
35
  const current = existsSync(countPath) ? parseInt(readFileSync(countPath, 'utf8').trim() || '0', 10) : 0;
25
36
  writeFileSync(countPath, String(current + 1));
26
- } catch { /* best effort */ }
37
+ } catch (err) {
38
+ logHookFailure({ hook: 'edit-accumulator', err, phase: 'count' });
39
+ }
27
40
 
28
41
  // Only accumulate TS/JS files for typecheck
29
42
  if (!TS_EXTS.has(ext)) process.exit(0);
30
43
 
31
- const tcPath = `${home}/.cx/pending-typecheck.txt`;
44
+ const tcPath = path.join(home, '.cx', 'pending-typecheck.txt');
32
45
  try {
33
46
  const existing = existsSync(tcPath) ? readFileSync(tcPath, 'utf8').split('\n').filter(Boolean) : [];
34
47
  if (!existing.includes(filePath)) {
35
- appendFileSync(tcPath, filePath + '\n');
48
+ appendBounded('edit-accumulator', tcPath, filePath + '\n');
36
49
  }
37
- } catch { /* best effort */ }
50
+ } catch (err) {
51
+ logHookFailure({ hook: 'edit-accumulator', err, phase: 'append' });
52
+ }
38
53
 
39
54
  process.exit(0);
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 10
8
8
  * @maxBlockingScope none (PostToolUse, non-blocking)
9
+ *
10
+ * @lifecycle PostToolUseFailure
11
+ * @matcher *
12
+ * @exits 0 = pass
9
13
  */
10
14
  import { readFileSync } from 'fs';
11
15
 
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 20
8
8
  * @maxBlockingScope PreToolUse
9
+ *
10
+ * @lifecycle PreToolUse
11
+ * @matcher Edit|MultiEdit|Write
12
+ * @exits 0 = pass | 2 = block tool call
9
13
  */
10
14
  import { readFileSync, existsSync } from 'fs';
11
15
  import { createHash } from 'crypto';
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 5
8
8
  * @maxBlockingScope PreToolUse
9
+ *
10
+ * @lifecycle PreToolUse
11
+ * @matcher Bash
12
+ * @exits 0 = pass | 2 = block tool call
9
13
  */
10
14
  import { createInterface } from 'readline';
11
15
  import { readFileSync, existsSync } from 'fs';
@@ -7,6 +7,10 @@
7
7
  *
8
8
  * @p95ms 10
9
9
  * @maxBlockingScope none (PostToolUse, non-blocking)
10
+ *
11
+ * @lifecycle PostToolUse
12
+ * @matcher mcp__.*
13
+ * @exits 0 = pass
10
14
  */
11
15
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
12
16
  import { join } from 'path';
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 51
8
8
  * @maxBlockingScope none (PreToolUse, warn-only)
9
+ *
10
+ * @lifecycle PreToolUse
11
+ * @matcher mcp__.*
12
+ * @exits 0 = pass | 2 = block tool call
9
13
  */
10
14
  import { readFileSync, writeFileSync, existsSync } from 'fs';
11
15
  import { homedir } from 'os';
@@ -15,8 +15,12 @@
15
15
  *
16
16
  * @p95ms 150
17
17
  * @maxBlockingScope none (PostToolUse, non-blocking)
18
+ *
19
+ * @lifecycle PostToolUseFailure
20
+ * @matcher *
21
+ * @exits 0 = pass
18
22
  */
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
20
24
  import { execFileSync } from 'node:child_process';
21
25
  import { homedir } from 'node:os';
22
26
  import { join } from 'node:path';
@@ -27,20 +31,12 @@ import {
27
31
  selectFallbackModel,
28
32
  writeProviderCooldown,
29
33
  } from '../model-router.mjs';
34
+ import { readHookInput } from './_lib/input.mjs';
30
35
 
31
36
  const cooldownPath = join(homedir(), '.cx', 'provider-cooldowns.json');
32
37
  const envPath = join(process.cwd(), '.env');
33
38
 
34
- function readInput() {
35
- try {
36
- const raw = readFileSync(0, 'utf8');
37
- return raw ? JSON.parse(raw) : {};
38
- } catch {
39
- return {};
40
- }
41
- }
42
-
43
- const input = readInput();
39
+ const input = readHookInput();
44
40
 
45
41
  const classified = classifyProviderFailure(input);
46
42
  if (!classified || !classified.retryable) process.exit(0);
@@ -15,6 +15,10 @@
15
15
  *
16
16
  * @p95ms 80
17
17
  * @maxBlockingScope PreToolUse, Stop, UserPromptSubmit
18
+ *
19
+ * @lifecycle PreToolUse
20
+ * @matcher Write|Edit|MultiEdit|NotebookEdit|TodoWrite|Read|Grep|Glob|LS|NotebookRead
21
+ * @exits 0 = pass | 2 = block tool call
18
22
  */
19
23
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
24
  import { execSync } from 'node:child_process';
@@ -10,6 +10,10 @@
10
10
  *
11
11
  * @p95ms 1500
12
12
  * @maxBlockingScope none (async, non-blocking)
13
+ *
14
+ * @lifecycle PostToolUse
15
+ * @matcher Bash
16
+ * @exits 0 = pass
13
17
  */
14
18
 
15
19
  import { readFileSync } from 'node:fs';
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/hooks/post-merge-tracking.mjs — close beads referenced by a merged PR.
4
+ *
5
+ * Runs as a PostToolUse hook on Bash. Watches for `gh pr merge <N>` (or
6
+ * `gh pr merge --auto`) invocations that exited successfully; parses the
7
+ * PR body via `gh pr view <N> --json body`; extracts every `Refs:` /
8
+ * `Closes:` / `Fixes:` line that names a `construct-XXX` bead; closes each
9
+ * still-open bead with reason `Merged via PR #N (<sha>)`. Idempotent on
10
+ * beads already closed.
11
+ *
12
+ * The PR-body parse is the source of truth — not the merge commit subject
13
+ * — because the bundle PR pattern stores the bead refs in the body's
14
+ * "Beads issue" section and the squash-merge subject often drops them.
15
+ *
16
+ * Best-effort. Exits 0 on any parse or shell failure so the surrounding
17
+ * tool call is never affected. Stderr lines are advisory; the actual
18
+ * outcome is the `bd close` state, which is what the next Stop hook's
19
+ * tracking refresh will read.
20
+ *
21
+ * @lifecycle PostToolUse
22
+ * @matcher Bash
23
+ * @p95ms 3000
24
+ * @maxBlockingScope none (PostToolUse, non-blocking)
25
+ * @exits 0 = pass
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+ import { readHookInput } from './_lib/input.mjs';
30
+ import { logHookFailure } from './_lib/log.mjs';
31
+
32
+ const input = readHookInput();
33
+ const command = input?.tool_input?.command || input?.command || '';
34
+ const exitCode = input?.tool_response?.exit_code ?? input?.tool_response?.exitCode;
35
+
36
+ const ghPrMergeMatch = /\bgh\s+pr\s+merge\b(?:\s+(\d+))?/.exec(command);
37
+ if (!ghPrMergeMatch) process.exit(0);
38
+ if (exitCode !== undefined && exitCode !== 0) process.exit(0);
39
+
40
+ const cwd = input?.cwd || process.cwd();
41
+ const prNumber = await resolvePrNumber(ghPrMergeMatch[1], cwd);
42
+ if (!prNumber) process.exit(0);
43
+
44
+ try {
45
+ const { closeBeadsFromPrRefs } = await import('../tracking-surfaces.mjs');
46
+ const mergeCommitSha = readMergeCommitSha(cwd);
47
+ const result = await closeBeadsFromPrRefs({ prNumber, mergeCommitSha, cwd });
48
+ if (result?.closed?.length > 0) {
49
+ process.stderr.write(`[post-merge-tracking] closed beads: ${result.closed.join(', ')}\n`);
50
+ }
51
+ if (result?.errors?.length > 0) {
52
+ process.stderr.write(`[post-merge-tracking] failed to close: ${result.errors.map((e) => e.id).join(', ')}\n`);
53
+ }
54
+ } catch (err) {
55
+ logHookFailure({ hook: 'post-merge-tracking', err, phase: 'close' });
56
+ }
57
+
58
+ process.exit(0);
59
+
60
+ async function resolvePrNumber(captured, cwd) {
61
+ if (captured) return captured;
62
+ // Bare `gh pr merge --auto` / `gh pr merge -s` invocations omit the PR
63
+ // number. Resolve via the most recent merge commit subject, which `gh`
64
+ // standardly tails with `(#N)`.
65
+ try {
66
+ const lastLog = spawnSync('git', ['log', '-1', '--pretty=format:%s'], { cwd, encoding: 'utf8', timeout: 2000 });
67
+ if (lastLog.status !== 0) return null;
68
+ const m = /#(\d+)/.exec(lastLog.stdout);
69
+ return m ? m[1] : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function readMergeCommitSha(cwd) {
76
+ try {
77
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 2000 });
78
+ return result.status === 0 ? result.stdout.trim() : '';
79
+ } catch {
80
+ return '';
81
+ }
82
+ }
@@ -11,6 +11,10 @@
11
11
  *
12
12
  * @p95ms 100
13
13
  * @maxBlockingScope PreCompact
14
+ *
15
+ * @lifecycle PreCompact
16
+ * @matcher *
17
+ * @exits 0 = pass
14
18
  */
15
19
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
16
20
  import { homedir } from 'os';