@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
@@ -1,32 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pre-push-gate.mjs — PreToolUse / Bash
3
+ * lib/hooks/pre-push-gate.mjs — PreToolUse / Bash
4
4
  *
5
+ * Local pre-push gate, shrunk to what only the developer's machine can know.
5
6
  * Intercepts three command shapes:
6
- * git push refuses claude/* branches, refuses on red remote CI,
7
- * runs project test/build/evals/docs in parallel
7
+ * git push refuses claude/* branches, blocks re-pushing the
8
+ * exact SHA CI just rejected. No test/build/lint
9
+ * CI is the source of truth.
8
10
  * gh pr create lints the --body / --body-file against the PR
9
- * template policy before the PR is opened
10
- * gh pr edit same body lint when --body / --body-file is changed
11
+ * template policy before the PR is opened (CI can't
12
+ * the body doesn't exist until the PR is created).
13
+ * gh pr edit same body lint when --body / --body-file is changed.
11
14
  *
12
- * Template-policy severity is conditional: a hard block (exit 2) when a
13
- * specialist sub-agent is active (CONSTRUCT_AGENT_ID set, or the cx-tracker
14
- * shows a fresh dispatch in the last 10 minutes); a warning otherwise so a
15
- * human driving the session can override editorial nits without ceremony.
16
- * CI still enforces the same rules on the resulting PR.
15
+ * Template-policy severity for PR body lint is conditional: a hard block
16
+ * (exit 2) when a specialist sub-agent is active; a warning otherwise so a
17
+ * human driving the session can override editorial nits. CI still enforces
18
+ * the same rules on the resulting PR.
17
19
  *
18
- * Silent and instant when there's nothing to run.
20
+ * Silent and instant when there's nothing to flag.
19
21
  *
20
- * Bypasses (env vars):
21
- * CONSTRUCT_SKIP_PREPUSH=1 skip the entire push gate
22
- * CONSTRUCT_ALLOW_CLAUDE_PUSH=1 allow pushing a claude/* branch
23
- * CONSTRUCT_SKIP_PR_LINT=1 skip the gh pr body lint
22
+ * No bypass mechanism. If a check fires wrong, the check is wrong — repair
23
+ * the policy or the matcher; do not add a skip env var.
24
24
  *
25
- * @p95ms 30000
25
+ * @lifecycle PreToolUse
26
+ * @matcher Bash
27
+ * @p95ms 5000
26
28
  * @maxBlockingScope PreToolUse
29
+ * @exits 0 = pass | 2 = block tool call
27
30
  */
28
31
  import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
29
- import { execSync, spawn, spawnSync } from 'child_process';
32
+ import { execSync, spawnSync } from 'child_process';
30
33
  import { dirname, join, resolve } from 'path';
31
34
  import { tmpdir } from 'os';
32
35
  import { logHookFailure } from './_lib/log.mjs';
@@ -48,7 +51,18 @@ if (!isGitPush && !isGhPrCreate && !isGhPrEdit) { echo(); }
48
51
 
49
52
  const cwd = input?.cwd || process.cwd();
50
53
 
51
- if ((isGhPrCreate || isGhPrEdit) && process.env.CONSTRUCT_SKIP_PR_LINT !== '1') {
54
+ const PROTECTED_BRANCHES = new Set(['main', 'staging', 'master']);
55
+
56
+ function currentBranch() {
57
+ try {
58
+ return execSync('git branch --show-current', { cwd, timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] })
59
+ .toString().trim();
60
+ } catch { return ''; }
61
+ }
62
+
63
+ // gh pr create / gh pr edit body lint
64
+
65
+ if (isGhPrCreate || isGhPrEdit) {
52
66
  const { body, bodyFile } = extractGhPrBody(command);
53
67
  const hasArgs = body != null || bodyFile != null;
54
68
 
@@ -85,7 +99,6 @@ if ((isGhPrCreate || isGhPrEdit) && process.env.CONSTRUCT_SKIP_PR_LINT !== '1')
85
99
  ? '[pre-push-gate] PR body fails template policy (specialist agent active — blocking):'
86
100
  : '[pre-push-gate] PR body fails template policy (no specialist agent active — warning only):';
87
101
  process.stderr.write(`${banner}\n${out}\n`);
88
- process.stderr.write(`Override: CONSTRUCT_SKIP_PR_LINT=1 <command>\n`);
89
102
  emitRoleEvent({
90
103
  type: agentActive ? 'push_gate.fail' : 'push_gate.warn',
91
104
  summary: `PR body fails template policy (${severity})`,
@@ -103,36 +116,12 @@ if ((isGhPrCreate || isGhPrEdit) && process.env.CONSTRUCT_SKIP_PR_LINT !== '1')
103
116
  echo();
104
117
  }
105
118
 
106
- if (process.env.CONSTRUCT_SKIP_PREPUSH === '1') { echo(); }
107
-
108
- function extractGhPrBody(cmd) {
109
- let m = cmd.match(/--body-file[\s=]+(["']?)(\S+?)\1(?=\s|$)/);
110
- if (m) return { bodyFile: m[2] };
111
- m = cmd.match(/(?:^|\s)-F[\s=]+(["']?)(\S+?)\1(?=\s|$)/);
112
- if (m) return { bodyFile: m[2] };
113
- m = cmd.match(/--body[\s=]+"((?:\\.|[^"\\])*)"/s);
114
- if (m) return { body: m[1].replace(/\\(["\\])/g, '$1') };
115
- m = cmd.match(/--body[\s=]+'([^']*)'/s);
116
- if (m) return { body: m[1] };
117
- return {};
118
- }
119
-
120
- function findRepoFile(startDir, relPath) {
121
- let dir = startDir;
122
- for (let i = 0; i < 10; i++) {
123
- const candidate = join(dir, relPath);
124
- if (existsSync(candidate)) return candidate;
125
- const parent = dirname(dir);
126
- if (parent === dir) break;
127
- dir = parent;
128
- }
129
- return null;
130
- }
119
+ // git push: refuse claude/* unconditionally.
131
120
 
132
- if (/\bclaude\//.test(command) && process.env.CONSTRUCT_ALLOW_CLAUDE_PUSH !== '1') {
121
+ if (/\bclaude\//.test(command)) {
133
122
  process.stderr.write(
134
123
  `[pre-push-gate] Refusing to push a claude/* branch to remote (user policy).\n` +
135
- ` Use a non-agent branch name, or override with CONSTRUCT_ALLOW_CLAUDE_PUSH=1 git push ...\n`,
124
+ ` Use a non-agent branch name.\n`,
136
125
  );
137
126
  emitRoleEvent({
138
127
  type: 'push_gate.fail',
@@ -143,208 +132,72 @@ if (/\bclaude\//.test(command) && process.env.CONSTRUCT_ALLOW_CLAUDE_PUSH !== '1
143
132
  process.exit(2);
144
133
  }
145
134
 
135
+ // Prior remote CI red-check: block only when HEAD == the SHA that failed
136
+ // (the developer is about to re-push the exact broken commit). If HEAD has
137
+ // moved past the failed SHA, those new commits may well BE the fix; let
138
+ // them push so CI can re-evaluate.
139
+
146
140
  try {
147
- const branch = execSync('git branch --show-current', { cwd, timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] })
148
- .toString().trim();
149
- if (branch && branch !== 'main' && branch !== 'dev' && branch !== 'master') {
141
+ const branch = currentBranch();
142
+ if (branch && !PROTECTED_BRANCHES.has(branch) && branch !== 'dev') {
150
143
  const json = execSync(
151
- `gh run list --branch=${JSON.stringify(branch)} --limit=1 --json conclusion,databaseId,url`,
144
+ `gh run list --branch=${JSON.stringify(branch)} --limit=1 --json conclusion,databaseId,url,headSha`,
152
145
  { cwd, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
153
146
  ).toString().trim();
154
147
  if (json) {
155
148
  const [run] = JSON.parse(json);
156
149
  if (run?.conclusion === 'failure') {
150
+ const headSha = execSync('git rev-parse HEAD', { cwd, timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] })
151
+ .toString().trim();
152
+ const runSha = run.headSha || '';
153
+ const runUrl = run.url || `gh run view ${run.databaseId}`;
154
+ if (runSha && headSha === runSha) {
155
+ process.stderr.write(
156
+ `[pre-push-gate] HEAD (${headSha.slice(0, 7)}) is the commit that failed CI on '${branch}'.\n` +
157
+ ` Re-pushing the same SHA will fail again. Add a fix commit before pushing.\n` +
158
+ ` Failed run: ${runUrl}\n`,
159
+ );
160
+ emitRoleEvent({
161
+ type: 'push_gate.fail',
162
+ summary: 'remote CI red on current HEAD — push blocked',
163
+ hookInput: input,
164
+ context: { branch, runId: run.databaseId, runUrl: run.url, headSha, runSha },
165
+ });
166
+ process.exit(2);
167
+ }
157
168
  process.stderr.write(
158
- `[pre-push-gate] Last CI run on '${branch}' FAILED fix it before pushing more.\n` +
159
- ` Run: ${run.url || `gh run view ${run.databaseId}`}\n` +
160
- ` Override: CONSTRUCT_SKIP_PREPUSH=1 git push ...\n`,
169
+ `[pre-push-gate] Note: last CI run on '${branch}' (${runSha.slice(0, 7) || 'unknown SHA'}) failed; HEAD (${headSha.slice(0, 7)}) is past it. Allowing push — CI will re-evaluate.\n` +
170
+ ` Prior failure: ${runUrl}\n`,
161
171
  );
162
- emitRoleEvent({
163
- type: 'push_gate.fail',
164
- summary: 'remote CI red — push blocked',
165
- hookInput: input,
166
- context: { branch, runId: run.databaseId, runUrl: run.url },
167
- });
168
- process.exit(2);
169
172
  }
170
173
  }
171
174
  }
172
175
  } catch {}
173
176
 
174
- function findUp(filename, from) {
175
- let dir = from;
176
- for (let i = 0; i < 8; i++) {
177
- const candidate = join(dir, filename);
178
- if (existsSync(candidate)) return { path: candidate, dir };
179
- const parent = dirname(dir);
180
- if (parent === dir) break;
181
- dir = parent;
182
- }
183
- return null;
184
- }
185
-
186
- // ── Node/npm project ──────────────────────────────────────────────────────────
187
- function runNpmGate(projectDir) {
188
- let pkg;
189
- try { pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')); } catch { return null; }
190
-
191
- const scripts = pkg.scripts || {};
192
- const hasTest = !!(scripts.test && !scripts.test.includes('no test specified'));
193
- const hasBuild = !!(scripts.build);
194
- const hasCi = !!(scripts['test:ci'] || scripts['test:run']);
195
-
196
- const testScript = scripts['test:ci'] ? 'test:ci' : scripts['test:run'] ? 'test:run' : 'test';
197
- const runner = existsSync(join(projectDir, 'pnpm-lock.yaml')) ? 'pnpm'
198
- : existsSync(join(projectDir, 'yarn.lock')) ? 'yarn'
199
- : 'npm';
200
-
201
- const jobs = [];
202
- if (hasTest || hasCi) jobs.push({ label: 'tests', cmd: runner, args: ['run', testScript], timeout: 90_000 });
203
- if (hasBuild) jobs.push({ label: 'build', cmd: runner, args: ['run', 'build'], timeout: 120_000 });
204
- // Use the project's package manager for audit so the lockfile format matches.
205
- const auditArgs = runner === 'pnpm'
206
- ? ['audit', '--prod', '--audit-level=high']
207
- : runner === 'yarn'
208
- ? ['npm-audit', '--groups', 'dependencies', '--level', 'high']
209
- : ['audit', '--omit=dev', '--audit-level=high'];
210
- jobs.push({ label: 'audit', cmd: runner, args: auditArgs, timeout: 30_000 });
211
-
212
- if (existsSync(join(projectDir, 'bin/construct'))) {
213
- jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
214
- jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
215
- jobs.push({ label: 'docs drift', cmd: 'node', args: ['bin/construct', 'docs:update', '--check'], timeout: 15_000 });
216
- jobs.push({ label: 'dashboard drift', cmd: 'node', args: ['bin/construct', 'dashboard:sync', '--check'], timeout: 15_000 });
217
- jobs.push({ label: 'comment policy', cmd: 'node', args: ['bin/construct', 'lint:comments'], timeout: 30_000 });
218
- jobs.push({ label: 'agents registry', cmd: 'node', args: ['bin/construct', 'lint:agents'], timeout: 15_000 });
219
- jobs.push({ label: 'contracts schema', cmd: 'node', args: ['bin/construct', 'lint:contracts'], timeout: 15_000 });
220
- }
221
-
222
- if (existsSync(join(projectDir, 'scripts/lint-prose.mjs'))) {
223
- jobs.push({ label: 'prose', cmd: 'node', args: ['scripts/lint-prose.mjs'], timeout: 15_000 });
224
- }
225
- if (existsSync(join(projectDir, 'scripts/lint-profiles.mjs'))) {
226
- jobs.push({ label: 'profiles', cmd: 'node', args: ['scripts/lint-profiles.mjs', '--quiet'], timeout: 15_000 });
227
- }
228
-
229
- return { projectDir, jobs };
230
- }
231
-
232
- // ── Rust/Cargo project ────────────────────────────────────────────────────────
233
- function runCargoGate(projectDir) {
234
- const jobs = [
235
- { label: 'tests', cmd: 'cargo', args: ['test', '--quiet'], timeout: 120_000 },
236
- { label: 'build', cmd: 'cargo', args: ['build', '--quiet'], timeout: 120_000 },
237
- ];
238
- return { projectDir, jobs };
239
- }
240
-
241
- // ── Python project ────────────────────────────────────────────────────────────
242
- function runPythonGate(projectDir) {
243
- const hasPytest = (() => { try { execSync('pytest --version', { stdio: 'pipe', timeout: 3000 }); return true; } catch { return false; } })();
244
- if (!hasPytest) return null;
245
- const jobs = [{ label: 'tests', cmd: 'pytest', args: ['--tb=short', '-q'], timeout: 90_000 }];
246
- return { projectDir, jobs };
247
- }
248
-
249
- // Detect project type
250
- let gate = null;
251
- const npm = findUp('package.json', cwd);
252
- const cargo = findUp('Cargo.toml', cwd);
253
- const pyproj = findUp('pyproject.toml', cwd) || findUp('setup.py', cwd);
254
-
255
- if (npm) gate = runNpmGate(npm.dir);
256
- else if (cargo) gate = runCargoGate(cargo.dir);
257
- else if (pyproj) gate = runPythonGate(pyproj.dir);
177
+ echo();
258
178
 
259
- // Nothing to check
260
- if (!gate || gate.jobs.length === 0) { echo(); }
179
+ // helpers
261
180
 
262
- // Jobs run concurrently. Wall-clock = max(test, build) instead of sum, so the
263
- // gate fits inside the harness's 180s timeout even when both jobs near their
264
- // individual limits.
265
- function runJob(job) {
266
- return new Promise((resolve) => {
267
- let stdoutBuf = '';
268
- let stderrBuf = '';
269
- const child = spawn(job.cmd, job.args, {
270
- cwd: gate.projectDir,
271
- stdio: ['ignore', 'pipe', 'pipe'],
272
- env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
273
- });
274
-
275
- const timer = setTimeout(() => {
276
- try { child.kill('SIGKILL'); } catch { /* already gone */ }
277
- }, job.timeout);
278
-
279
- child.stdout?.on('data', (chunk) => { stdoutBuf += chunk.toString(); });
280
- child.stderr?.on('data', (chunk) => { stderrBuf += chunk.toString(); });
281
- child.on('error', (err) => {
282
- clearTimeout(timer);
283
- resolve({ label: job.label, status: -1, stdout: stdoutBuf, stderr: stderrBuf || err.message, error: err });
284
- });
285
- child.on('close', (code) => {
286
- clearTimeout(timer);
287
- resolve({ label: job.label, status: code ?? -1, stdout: stdoutBuf, stderr: stderrBuf });
288
- });
289
- });
181
+ function extractGhPrBody(cmd) {
182
+ let m = cmd.match(/--body-file[\s=]+(["']?)(\S+?)\1(?=\s|$)/);
183
+ if (m) return { bodyFile: m[2] };
184
+ m = cmd.match(/(?:^|\s)-F[\s=]+(["']?)(\S+?)\1(?=\s|$)/);
185
+ if (m) return { bodyFile: m[2] };
186
+ m = cmd.match(/--body[\s=]+"((?:\\.|[^"\\])*)"/s);
187
+ if (m) return { body: m[1].replace(/\\(["\\])/g, '$1') };
188
+ m = cmd.match(/--body[\s=]+'([^']*)'/s);
189
+ if (m) return { body: m[1] };
190
+ return {};
290
191
  }
291
192
 
292
- const results = await Promise.all(gate.jobs.map(runJob));
293
- const failures = [];
294
-
295
- // Auto-fix docs drift: if docs:update --check fails, run docs:update to regenerate
296
- const docsDriftResult = results.find((r) => r.label === 'docs drift');
297
- if (docsDriftResult && docsDriftResult.status !== 0) {
298
- process.stderr.write('[pre-push-gate] Docs drift detected — auto-running docs:update...\n');
299
- try {
300
- const fix = spawnSync('node', ['bin/construct', 'docs:update'], {
301
- cwd: gate.projectDir,
302
- stdio: ['ignore', 'pipe', 'pipe'],
303
- timeout: 30_000,
304
- });
305
- if (fix.status === 0) {
306
- process.stderr.write('[pre-push-gate] docs:update succeeded — re-checking...\n');
307
- const recheck = spawnSync('node', ['bin/construct', 'docs:update', '--check'], {
308
- cwd: gate.projectDir,
309
- stdio: ['ignore', 'pipe', 'pipe'],
310
- timeout: 15_000,
311
- });
312
- if (recheck.status === 0) {
313
- process.stderr.write('[pre-push-gate] Docs are now clean after auto-fix.\n');
314
- results.splice(results.indexOf(docsDriftResult), 1); // remove from failure candidates
315
- } else {
316
- process.stderr.write(`[pre-push-gate] Auto-fix attempted but docs still drift — manual intervention needed.\n`);
317
- docsDriftResult.stdout = (docsDriftResult.stdout || '') + '\n[auto-fix attempted but still dirty]';
318
- }
319
- } else {
320
- process.stderr.write(`[pre-push-gate] docs:update auto-fix failed (exit ${fix.status}): ${fix.stderr.slice(0, 300)}\n`);
321
- }
322
- } catch (e) {
323
- process.stderr.write(`[pre-push-gate] docs:update auto-fix threw: ${e.message}\n`);
193
+ function findRepoFile(startDir, relPath) {
194
+ let dir = startDir;
195
+ for (let i = 0; i < 10; i++) {
196
+ const candidate = join(dir, relPath);
197
+ if (existsSync(candidate)) return candidate;
198
+ const parent = dirname(dir);
199
+ if (parent === dir) break;
200
+ dir = parent;
324
201
  }
202
+ return null;
325
203
  }
326
-
327
- for (const result of results) {
328
- if (result.status === 0) continue;
329
- const detail = (result.stderr || result.stdout || '').trim();
330
- failures.push({ label: result.label, detail: detail || `exited ${result.status}` });
331
- }
332
-
333
- if (failures.length === 0) { echo(); }
334
-
335
- const summary = failures
336
- .map((f) => `${f.label} failed — ${f.detail}`)
337
- .join('\n');
338
-
339
- process.stderr.write(
340
- `[pre-push-gate] Push blocked — fix these before pushing:\n${summary}\n\nRun the failing checks locally, then push again.\n`
341
- );
342
-
343
- emitRoleEvent({
344
- type: 'push_gate.fail',
345
- summary,
346
- hookInput: input,
347
- context: { failures },
348
- });
349
-
350
- process.exit(2);
@@ -20,9 +20,12 @@
20
20
  *
21
21
  * @p95ms 5
22
22
  * @maxBlockingScope none
23
+ *
24
+ * @unwired (not referenced from platforms/claude/settings.template.json)
25
+ * @exits 0 = pass
23
26
  */
24
27
 
25
- import { EVENT_OWNERSHIP } from '../orchestration-policy.mjs';
28
+ import { ownerForEvent } from '../orchestration/routing-tables.mjs';
26
29
  import { loadProjectConfig } from '../config/project-config.mjs';
27
30
 
28
31
  // Rate limiting: max activations per specialist per hour
@@ -116,7 +119,7 @@ export function getActivationStats(specialist) {
116
119
  * Route an event to the owning specialist.
117
120
  */
118
121
  export function routeEvent(eventType, eventData = {}) {
119
- const owner = EVENT_OWNERSHIP[eventType];
122
+ const owner = ownerForEvent(eventType);
120
123
 
121
124
  if (!owner) {
122
125
  return {
@@ -9,6 +9,10 @@
9
9
  *
10
10
  * @p95ms 1500
11
11
  * @maxBlockingScope none (async, non-blocking)
12
+ *
13
+ * @lifecycle Stop
14
+ * @matcher *
15
+ * @exits 0 = pass
12
16
  */
13
17
 
14
18
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -1,30 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/hooks/registry-sync.mjs — Registry sync hook — reminds to run construct sync after registry changes.
3
+ * lib/hooks/registry-sync.mjs — Registry-change reminder hook.
4
4
  *
5
- * Runs as PostToolUse after edits to specialists/registry.json. Checks if the file was modified and emits a reminder to run construct sync to regenerate platform adapters.
5
+ * PostToolUse on Write|Edit. Fires only when the edited file is
6
+ * specialists/registry.json (the construct repo) or an installed
7
+ * agents/registry.json mirror. Emits a one-line reminder on stderr telling
8
+ * the developer to run `construct sync` when they're ready. Does NOT
9
+ * auto-execute sync — auto-execution during tests (which legitimately
10
+ * mutate registry.json) races test cleanup and regenerates platform state
11
+ * mid-suite. The dev decides when to sync, the same way ordinary code
12
+ * changes don't auto-build.
6
13
  *
7
- * @p95ms 12000
8
- * @maxBlockingScope none (PostToolUse, non-blocking)
14
+ * The reminder is suppressed automatically when CI=true or NODE_ENV=test.
15
+ * Stderr is not used as an interactivity signal: Claude Code captures hook
16
+ * stderr rather than passing it through to a TTY, so a TTY check would
17
+ * silently suppress the reminder in real usage. No skip env var — broaden
18
+ * the detection here if the reminder fires in a context where it shouldn't.
19
+ *
20
+ * @lifecycle PostToolUse
21
+ * @matcher Write|Edit
22
+ * @p95ms 50
23
+ * @maxBlockingScope none
24
+ * @exits 0 = pass
9
25
  */
10
- import { execSync } from 'child_process';
11
26
  import path from 'node:path';
12
- import { logHookFailure } from './_lib/log.mjs';
13
- import { constructDir } from '../paths.mjs';
27
+ import { readHookInput } from './_lib/input.mjs';
28
+
29
+ const input = readHookInput();
30
+ const filePath = input?.tool_input?.file_path || process.env.TOOL_INPUT_FILE_PATH || '';
31
+ const matchesRegistry =
32
+ filePath.endsWith(path.join('agents', 'registry.json')) ||
33
+ filePath.endsWith('/specialists/registry.json');
34
+ if (!matchesRegistry) process.exit(0);
14
35
 
15
- const filePath = process.env.TOOL_INPUT_FILE_PATH || '';
16
- if (!filePath.endsWith(path.join('agents', 'registry.json')) && !filePath.endsWith('/specialists/registry.json')) process.exit(0);
36
+ const isNonInteractive =
37
+ process.env.CI === 'true' ||
38
+ process.env.NODE_ENV === 'test';
17
39
 
18
- const toolkitDir = constructDir();
19
- try {
20
- execSync('node scripts/sync-specialists.mjs', {
21
- cwd: toolkitDir,
22
- stdio: 'pipe',
23
- env: { ...process.env, CX_TOOLKIT_DIR: toolkitDir },
24
- timeout: 12_000,
25
- });
26
- } catch (err) {
27
- logHookFailure({ hook: 'registry-sync', err, phase: 'execute', input: { toolkitDir } });
28
- process.stderr.write(`[registry-sync] Sync failed: ${err.message}\n`);
40
+ if (!isNonInteractive) {
41
+ process.stderr.write(
42
+ `[registry-sync] registry.json changed — run \`construct sync\` to regenerate platform adapters when ready.\n`,
43
+ );
29
44
  }
30
45
  process.exit(0);
@@ -21,6 +21,9 @@
21
21
  *
22
22
  * @p95ms 200
23
23
  * @maxBlockingScope Stop
24
+ *
25
+ * @unwired (not referenced from platforms/claude/settings.template.json)
26
+ * @exits 0 = pass | 2 = block tool call
24
27
  */
25
28
 
26
29
  import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 30
8
8
  * @maxBlockingScope PostToolUse
9
+ *
10
+ * @lifecycle PostToolUse
11
+ * @matcher Write|Edit
12
+ * @exits 0 = pass | 2 = block tool call
9
13
  */
10
14
  import { readFileSync } from 'fs';
11
15
  import { extname } from 'path';
@@ -7,6 +7,10 @@
7
7
  *
8
8
  * @p95ms 300
9
9
  * @maxBlockingScope Stop (non-blocking, async)
10
+ *
11
+ * @lifecycle Stop
12
+ * @matcher *
13
+ * @exits 0 = pass
10
14
  */
11
15
  import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
12
16
  import { join, dirname } from 'path';
@@ -14,6 +14,10 @@
14
14
  *
15
15
  * @p95ms 300
16
16
  * @maxBlockingScope Stop
17
+ *
18
+ * @lifecycle Stop
19
+ * @matcher *
20
+ * @exits 0 = pass
17
21
  */
18
22
  import { readFileSync, existsSync } from 'node:fs';
19
23
  import { join } from 'node:path';
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 300
8
8
  * @maxBlockingScope SessionStart
9
+ *
10
+ * @lifecycle SessionStart
11
+ * @matcher *
12
+ * @exits 0 = pass
9
13
  */
10
14
  import { readFileSync, existsSync, statSync, writeFileSync } from 'fs';
11
15
  import { execSync } from 'child_process';
@@ -321,6 +325,49 @@ try {
321
325
  brokerStatusNote = `\n${buildBrokerStatusLine({ env: process.env })}\n`;
322
326
  } catch { /* best effort */ }
323
327
 
328
+ // Missing-env-vars notice. Reads .env.example from the project root,
329
+ // compares against .env and process.env, lists keys whose example value
330
+ // is a placeholder and whose live value is unset. Empty string when the
331
+ // project has no .env.example or all required keys are populated.
332
+
333
+ const envCheckNote = buildEnvCheckNote(cwd);
334
+
335
+ function buildEnvCheckNote(rootDir) {
336
+ const examplePath = join(rootDir, '.env.example');
337
+ if (!existsSync(examplePath)) return '';
338
+ const example = parseEnvFile(examplePath);
339
+ if (example.size === 0) return '';
340
+ const envFile = parseEnvFile(join(rootDir, '.env'));
341
+ const PLACEHOLDER = /^(YOUR_|<|__|\$\{|REPLACE|ADD_|INSERT_|xxx|TODO)/i;
342
+ const missing = [];
343
+ for (const [key, exampleVal] of example) {
344
+ const required = !exampleVal || PLACEHOLDER.test(exampleVal);
345
+ if (!required) continue;
346
+ const liveFromEnvFile = envFile.has(key) && envFile.get(key) !== '' && !PLACEHOLDER.test(envFile.get(key));
347
+ const liveFromProcessEnv = process.env[key] && !PLACEHOLDER.test(process.env[key]);
348
+ if (!liveFromEnvFile && !liveFromProcessEnv) missing.push(key);
349
+ }
350
+ if (missing.length === 0) return '';
351
+ const noun = missing.length === 1 ? 'variable' : 'variables';
352
+ const list = missing.map((k) => ` - ${k}`).join('\n');
353
+ return `\n## Environment check — ${missing.length} required ${noun} not set\n${list}\nAdd these to .env before running the app.\n`;
354
+ }
355
+
356
+ function parseEnvFile(p) {
357
+ const map = new Map();
358
+ if (!existsSync(p)) return map;
359
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
360
+ const stripped = line.trim();
361
+ if (!stripped || stripped.startsWith('#')) continue;
362
+ const eq = stripped.indexOf('=');
363
+ if (eq === -1) continue;
364
+ const key = stripped.slice(0, eq).trim();
365
+ const val = stripped.slice(eq + 1).trim();
366
+ if (key) map.set(key, val);
367
+ }
368
+ return map;
369
+ }
370
+
324
371
  // Permission posture: surface gaps in ~/.claude/settings.json permissions.allow
325
372
  // so the agent can ask the user to close them instead of stumbling into the
326
373
  // classifier mid-task. Empty string when no gaps — keeps the banner small.
@@ -331,7 +378,7 @@ try {
331
378
  permissionPostureNote = buildPermissionPostureLine({ cwd });
332
379
  } catch { /* best effort */ }
333
380
 
334
- process.stdout.write(`${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}\n${footer}${pendingNote}\n`);
381
+ process.stdout.write(`${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`);
335
382
  // Auto-bootstrap the policy-engine gate. session-start has just emitted
336
383
  // branch + recent commits + prior observations + context-state — that is
337
384
  // already grounding; mark the session bootstrapped so the PreToolUse rule