@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.
- package/README.md +8 -2
- package/bin/construct +259 -11
- package/bin/construct-postinstall.mjs +23 -2
- package/lib/auto-docs.mjs +11 -7
- package/lib/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- package/lib/embed/daemon.mjs +20 -0
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/gates-audit.mjs +18 -12
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +48 -1
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/server/index.mjs +31 -9
- package/lib/server/static/index.html +1 -15
- package/lib/specialist-contracts-enforce.mjs +24 -10
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +375 -0
- package/lib/worker/trace.mjs +19 -2
- package/package.json +6 -2
- package/platforms/claude/settings.template.json +28 -27
- package/scripts/sync-specialists.mjs +269 -75
- package/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- 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,
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
|
13
|
-
* specialist sub-agent is active
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
20
|
+
* Silent and instant when there's nothing to flag.
|
|
19
21
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* @
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
148
|
-
|
|
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]
|
|
159
|
-
`
|
|
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
|
-
|
|
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
|
-
//
|
|
260
|
-
if (!gate || gate.jobs.length === 0) { echo(); }
|
|
179
|
+
// helpers
|
|
261
180
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 {
|
|
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 =
|
|
122
|
+
const owner = ownerForEvent(eventType);
|
|
120
123
|
|
|
121
124
|
if (!owner) {
|
|
122
125
|
return {
|
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* lib/hooks/registry-sync.mjs — Registry
|
|
3
|
+
* lib/hooks/registry-sync.mjs — Registry-change reminder hook.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
36
|
+
const isNonInteractive =
|
|
37
|
+
process.env.CI === 'true' ||
|
|
38
|
+
process.env.NODE_ENV === 'test';
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 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
|