@geraldmaron/construct 1.0.11 → 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 (90) hide show
  1. package/README.md +55 -18
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +23 -22
  5. package/lib/cli-commands.mjs +2 -0
  6. package/lib/dashboard-static.mjs +7 -4
  7. package/lib/doc-stamp.mjs +16 -0
  8. package/lib/docs-verify.mjs +1 -8
  9. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  10. package/lib/embed/daemon.mjs +20 -0
  11. package/lib/embed/docs-lifecycle.mjs +19 -0
  12. package/lib/embed/inbox.mjs +85 -2
  13. package/lib/embed/recommendation-store.mjs +29 -0
  14. package/lib/gates-audit.mjs +18 -12
  15. package/lib/hooks/_lib/input.mjs +52 -0
  16. package/lib/hooks/adaptive-lint.mjs +4 -0
  17. package/lib/hooks/agent-tracker.mjs +59 -15
  18. package/lib/hooks/audit-reads.mjs +83 -42
  19. package/lib/hooks/audit-trail.mjs +28 -18
  20. package/lib/hooks/bash-output-logger.mjs +8 -2
  21. package/lib/hooks/block-no-verify.mjs +4 -0
  22. package/lib/hooks/ci-status-check.mjs +4 -0
  23. package/lib/hooks/comment-lint.mjs +6 -4
  24. package/lib/hooks/config-protection.mjs +4 -0
  25. package/lib/hooks/context-watch.mjs +4 -0
  26. package/lib/hooks/context-window-recovery.mjs +4 -0
  27. package/lib/hooks/dep-audit.mjs +12 -5
  28. package/lib/hooks/doc-coupling-check.mjs +5 -1
  29. package/lib/hooks/edit-accumulator.mjs +25 -10
  30. package/lib/hooks/edit-error-recovery.mjs +4 -0
  31. package/lib/hooks/edit-guard.mjs +4 -0
  32. package/lib/hooks/guard-bash.mjs +4 -0
  33. package/lib/hooks/mcp-audit.mjs +4 -0
  34. package/lib/hooks/mcp-health-check.mjs +4 -0
  35. package/lib/hooks/model-fallback.mjs +7 -11
  36. package/lib/hooks/policy-engine.mjs +4 -0
  37. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  38. package/lib/hooks/post-merge-tracking.mjs +82 -0
  39. package/lib/hooks/pre-compact.mjs +4 -0
  40. package/lib/hooks/pre-push-gate.mjs +84 -231
  41. package/lib/hooks/proactive-activation.mjs +5 -2
  42. package/lib/hooks/readme-age-check.mjs +4 -0
  43. package/lib/hooks/registry-sync.mjs +35 -20
  44. package/lib/hooks/rule-verifier.mjs +3 -0
  45. package/lib/hooks/scan-secrets.mjs +4 -0
  46. package/lib/hooks/session-optimize.mjs +4 -0
  47. package/lib/hooks/session-reflect.mjs +4 -0
  48. package/lib/hooks/session-start.mjs +48 -1
  49. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  50. package/lib/hooks/stop-notify.mjs +13 -2
  51. package/lib/hooks/stop-typecheck.mjs +4 -0
  52. package/lib/hooks/test-watch.mjs +8 -4
  53. package/lib/init-unified.mjs +90 -23
  54. package/lib/intake/attribution.mjs +77 -0
  55. package/lib/intake/intake-config.mjs +3 -0
  56. package/lib/intake/manifest.mjs +107 -0
  57. package/lib/intake/poll-lock.mjs +136 -0
  58. package/lib/intake/prepare.mjs +42 -4
  59. package/lib/logging/rotate.mjs +394 -0
  60. package/lib/mcp/tools/project.mjs +2 -2
  61. package/lib/mcp/tools/telemetry.mjs +1 -1
  62. package/lib/opencode-config.mjs +10 -3
  63. package/lib/orchestration/routing-tables.mjs +176 -0
  64. package/lib/orchestration-policy.mjs +18 -106
  65. package/lib/parity.mjs +48 -7
  66. package/lib/profiles/lifecycle.mjs +19 -1
  67. package/lib/project-init-shared.mjs +11 -5
  68. package/lib/project-root.mjs +104 -0
  69. package/lib/roles/catalog.mjs +1 -1
  70. package/lib/roles/event-bus.mjs +29 -9
  71. package/lib/roles/router.mjs +8 -7
  72. package/lib/server/index.mjs +31 -9
  73. package/lib/server/static/index.html +1 -15
  74. package/lib/specialist-contracts-enforce.mjs +24 -10
  75. package/lib/status.mjs +1 -1
  76. package/lib/storage/backup.mjs +2 -2
  77. package/lib/sync/skill-frontmatter.mjs +71 -0
  78. package/lib/telemetry/intent-verifications.mjs +16 -3
  79. package/lib/telemetry/skill-calls.mjs +12 -3
  80. package/lib/tracking-surfaces.mjs +375 -0
  81. package/lib/worker/trace.mjs +19 -2
  82. package/package.json +6 -2
  83. package/platforms/claude/settings.template.json +28 -27
  84. package/scripts/sync-specialists.mjs +296 -94
  85. package/specialists/registry.json +158 -13
  86. package/lib/hooks/env-check.mjs +0 -83
  87. package/lib/hooks/read-tracker.mjs +0 -61
  88. package/lib/policy/unified-gates.mjs +0 -96
  89. package/lib/server/static/assets/index-ab25c707.js +0 -70
  90. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -1,41 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/hooks/audit-reads.mjs — opt-in tamper-evident log of every Read tool call.
3
+ * lib/hooks/audit-reads.mjs — post-Read state tracker.
4
4
  *
5
- * Symmetric counterpart to audit-trail.mjs (which records mutations). Reads
6
- * are off by default because they happen much more often than writes; enable
7
- * with `CONSTRUCT_AUDIT_READS=1`. When enabled, every Read tool call writes
8
- * a single JSONL line to ~/.cx/audit-reads.jsonl with:
5
+ * Two responsibilities on every Read tool call:
9
6
  *
10
- * - timestamp ISO
11
- * - session_id (when supplied by the harness)
12
- * - tool_name (always Read)
13
- * - agent (from ~/.cx/last-agent.json falls back to "construct")
14
- * - cwd
15
- * - target (the file_path read)
16
- * - bytes (size of the file at read time)
17
- * - content_hash (sha256 of the file's first 64KiB at read time, truncated)
18
- * - prev_line_hash (chained tamper-evidence — hash of previous JSONL line)
7
+ * 1. ALWAYS — upsert a file-hash entry in ~/.cx/file-hashes.json keyed by
8
+ * absolute path, with the current sha256 + size + iso timestamp.
9
+ * Edit-guard reads the store to detect staleness on a subsequent Edit:
10
+ * mismatched on-disk content versus the stored hash means the Edit
11
+ * target needs a fresh Read. A read-tracker delta is recorded for the
12
+ * efficiency snapshot in the same pass.
19
13
  *
20
- * The prev_line_hash chain means any after-the-fact reordering or deletion
21
- * breaks the chain and is detectable by a simple replay (same approach as
22
- * the mutation audit).
14
+ * 2. OPT-IN when `CONSTRUCT_AUDIT_READS=1`, write a single JSONL line
15
+ * to <project>/.cx/audit-reads.jsonl (falls back to ~/.cx/ outside a
16
+ * project) with timestamp, session_id, agent, cwd, target, bytes,
17
+ * content_hash, and prev_line_hash. The prev_line_hash chain makes
18
+ * after-the-fact reordering or deletion detectable by simple replay
19
+ * (symmetric counterpart to audit-trail.mjs for mutations). Bounded
20
+ * via the `audit-reads` channel.
21
+ *
22
+ * Reads happen far more often than writes, so the audit log is opt-in.
23
+ * The hash store is always-on because edit-guard depends on it for
24
+ * correctness, not telemetry.
23
25
  *
24
26
  * @p95ms 8
25
27
  * @maxBlockingScope none (PostToolUse, non-blocking)
28
+ *
29
+ * @lifecycle PostToolUse
30
+ * @matcher Read
31
+ * @exits 0 = pass
26
32
  */
27
- import { readFileSync, appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
33
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
28
34
  import { createHash } from 'node:crypto';
29
- import { join, resolve } from 'node:path';
35
+ import { join, resolve, dirname } from 'node:path';
30
36
  import { homedir } from 'node:os';
31
37
  import { logHookFailure } from './_lib/log.mjs';
38
+ import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
39
+ import { resolveProjectScopedPath } from '../project-root.mjs';
40
+ import { flushReadTrackerDeltas, recordReadDelta } from '../read-tracker-store.mjs';
32
41
 
33
42
  const CX_DIR = join(homedir(), '.cx');
34
- const AUDIT_FILE = join(CX_DIR, 'audit-reads.jsonl');
43
+ // audit-reads.jsonl is PROJECT-SCOPED — reads happen in a specific project,
44
+ // not across the user's whole machine. resolveProjectScopedPath returns
45
+ // <project>/.cx/audit-reads.jsonl when the hook fires from inside a
46
+ // Construct project, falling back to the legacy ~/.cx path otherwise so
47
+ // existing standalone invocations keep working.
48
+
49
+ const AUDIT_FILE = resolveProjectScopedPath('audit-reads.jsonl', { ensureDir: false });
50
+ const HASH_STORE = join(CX_DIR, 'file-hashes.json');
35
51
  const LAST_AGENT = join(CX_DIR, 'last-agent.json');
36
52
  const HASH_PREFIX_BYTES = 64 * 1024;
37
-
38
- if (process.env.CONSTRUCT_AUDIT_READS !== '1') process.exit(0);
53
+ const HASH_RETENTION_MS = 2 * 60 * 60 * 1000;
39
54
 
40
55
  function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
41
56
 
@@ -48,14 +63,9 @@ function readLastAgent() {
48
63
 
49
64
  function readPrevLineHash() {
50
65
  try {
51
- if (!existsSync(AUDIT_FILE)) return null;
52
- const size = statSync(AUDIT_FILE).size;
53
- if (size === 0) return null;
54
- const readFrom = Math.max(0, size - 2048);
55
- const tail = readFileSync(AUDIT_FILE, 'utf8').slice(readFrom);
56
- const lines = tail.split('\n').filter(Boolean);
57
- if (lines.length === 0) return null;
58
- return sha256(lines[lines.length - 1]);
66
+ const lastLine = readLastLineAcrossSegments(AUDIT_FILE);
67
+ if (lastLine === null) return null;
68
+ return sha256(lastLine);
59
69
  } catch { return null; }
60
70
  }
61
71
 
@@ -71,22 +81,53 @@ if (!filePath) process.exit(0);
71
81
 
72
82
  const absPath = filePath.startsWith('/') ? filePath : resolve(cwd, filePath);
73
83
 
84
+ // Stage 1 — always-on hash store update for edit-guard staleness detection.
85
+
86
+ let fullContent = null;
74
87
  let bytes = null;
75
- let contentHash = null;
88
+ let prefixHash = null;
76
89
  try {
77
90
  if (existsSync(absPath)) {
78
- bytes = statSync(absPath).size;
79
- const buf = Buffer.alloc(Math.min(bytes, HASH_PREFIX_BYTES));
80
- const fd = await import('node:fs').then((m) => m.openSync(absPath, 'r'));
81
- const fsModule = await import('node:fs');
82
- fsModule.readSync(fd, buf, 0, buf.length, 0);
83
- fsModule.closeSync(fd);
84
- contentHash = sha256(buf).slice(0, 32);
91
+ fullContent = readFileSync(absPath, 'utf8');
92
+ bytes = Buffer.byteLength(fullContent, 'utf8');
93
+ const prefix = Buffer.from(fullContent, 'utf8').slice(0, HASH_PREFIX_BYTES);
94
+ prefixHash = sha256(prefix).slice(0, 32);
95
+
96
+ const edgeHash = createHash('sha256').update(fullContent).digest('hex').slice(0, 16);
97
+ const nowIso = new Date().toISOString();
98
+ const requestedLimit = Number(input?.tool_input?.limit || 0);
99
+ const effectiveLimit = requestedLimit > 0 ? requestedLimit : 2000;
100
+
101
+ mkdirSync(CX_DIR, { recursive: true });
102
+ let store = {};
103
+ try { store = JSON.parse(readFileSync(HASH_STORE, 'utf8')); } catch { /* fresh store */ }
104
+ store[absPath] = { hash: edgeHash, ts: nowIso, size: fullContent.length };
105
+
106
+ const cutoff = Date.now() - HASH_RETENTION_MS;
107
+ for (const [k, v] of Object.entries(store)) {
108
+ if (new Date(v.ts).getTime() < cutoff) delete store[k];
109
+ }
110
+ writeFileSync(HASH_STORE, JSON.stringify(store, null, 2));
111
+
112
+ recordReadDelta({
113
+ path: absPath,
114
+ size: fullContent.length,
115
+ limit: effectiveLimit,
116
+ ts: nowIso,
117
+ }, process.env);
118
+
119
+ if (process.env.CONSTRUCT_READ_TRACKER_FLUSH === '1') {
120
+ flushReadTrackerDeltas({ nowIso, env: process.env });
121
+ }
85
122
  }
86
123
  } catch (err) {
87
- logHookFailure({ hook: 'audit-reads', err, phase: 'hash', input: { filePath: absPath } });
124
+ logHookFailure({ hook: 'audit-reads', err, phase: 'hash-store', input: { filePath: absPath } });
88
125
  }
89
126
 
127
+ // Stage 2 — opt-in tamper-evident audit log.
128
+
129
+ if (process.env.CONSTRUCT_AUDIT_READS !== '1') process.exit(0);
130
+
90
131
  const record = {
91
132
  ts: new Date().toISOString(),
92
133
  session_id: input?.session_id || null,
@@ -95,13 +136,13 @@ const record = {
95
136
  cwd,
96
137
  target: filePath,
97
138
  bytes,
98
- content_hash: contentHash,
139
+ content_hash: prefixHash,
99
140
  prev_line_hash: readPrevLineHash(),
100
141
  };
101
142
 
102
143
  try {
103
- mkdirSync(CX_DIR, { recursive: true });
104
- appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', 'utf8');
144
+ mkdirSync(dirname(AUDIT_FILE), { recursive: true });
145
+ appendBounded('audit-reads', AUDIT_FILE, JSON.stringify(record) + '\n');
105
146
  } catch (err) {
106
147
  logHookFailure({ hook: 'audit-reads', err, phase: 'append' });
107
148
  }
@@ -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';