@geraldmaron/construct 1.0.21 → 1.0.24
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 +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/vector-store.mjs +0 -100
package/lib/embed/reconcile.mjs
CHANGED
|
@@ -26,8 +26,8 @@ export async function reconcileObservationEmbeddings(rootDir, { env = process.en
|
|
|
26
26
|
const result = { checked: 0, reembedded: 0, model: null, skipped: null };
|
|
27
27
|
|
|
28
28
|
try {
|
|
29
|
-
if (!(await client.isHealthy())
|
|
30
|
-
result.skipped = '
|
|
29
|
+
if (!(await client.isHealthy())) {
|
|
30
|
+
result.skipped = 'unhealthy';
|
|
31
31
|
return result;
|
|
32
32
|
}
|
|
33
33
|
|
package/lib/features.mjs
CHANGED
|
@@ -63,27 +63,27 @@ function readOpenCodeConfig(overrides = {}) {
|
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function
|
|
67
|
-
if (overrides.
|
|
66
|
+
function getVSCodeUserMcpPaths(overrides = {}) {
|
|
67
|
+
if (overrides.vscodeMcpPaths) return overrides.vscodeMcpPaths.filter(existsSync);
|
|
68
68
|
const home = getHomeDir(overrides);
|
|
69
69
|
const { platform } = process;
|
|
70
70
|
if (platform === 'darwin') {
|
|
71
71
|
return [
|
|
72
|
-
join(home, 'Library', 'Application Support', 'Code', 'User', '
|
|
73
|
-
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', '
|
|
72
|
+
join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
|
|
73
|
+
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
|
|
74
74
|
].filter(existsSync);
|
|
75
75
|
}
|
|
76
76
|
if (platform === 'linux') {
|
|
77
77
|
return [
|
|
78
|
-
join(home, '.config', 'Code', 'User', '
|
|
79
|
-
join(home, '.config', 'Code - Insiders', 'User', '
|
|
78
|
+
join(home, '.config', 'Code', 'User', 'mcp.json'),
|
|
79
|
+
join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
|
|
80
80
|
].filter(existsSync);
|
|
81
81
|
}
|
|
82
82
|
if (platform === 'win32') {
|
|
83
83
|
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
84
84
|
return [
|
|
85
|
-
join(appData, 'Code', 'User', '
|
|
86
|
-
join(appData, 'Code - Insiders', 'User', '
|
|
85
|
+
join(appData, 'Code', 'User', 'mcp.json'),
|
|
86
|
+
join(appData, 'Code - Insiders', 'User', 'mcp.json'),
|
|
87
87
|
].filter(existsSync);
|
|
88
88
|
}
|
|
89
89
|
return [];
|
|
@@ -230,9 +230,9 @@ function getMcpPlatforms(mcp, overrides = {}) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// --- VS Code / Copilot ---
|
|
233
|
-
for (const p of
|
|
234
|
-
const
|
|
235
|
-
if (configHasAny(
|
|
233
|
+
for (const p of getVSCodeUserMcpPaths(overrides)) {
|
|
234
|
+
const config = readJSON(p);
|
|
235
|
+
if (configHasAny(config?.servers, matchIds)) {
|
|
236
236
|
const label = p.includes('Insiders') ? 'VS Code Insiders' : 'VS Code';
|
|
237
237
|
if (!platforms.includes(label)) platforms.push(label);
|
|
238
238
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/git-hooks-path.mjs — wire a git repo's `core.hooksPath` to `.beads/hooks`
|
|
3
|
+
* so Construct's pre-commit secret-scan, policy gates, and the beads dispatcher
|
|
4
|
+
* activate.
|
|
5
|
+
*
|
|
6
|
+
* Project-scoped: it mutates the git config of the directory passed in, so it
|
|
7
|
+
* is owned by `construct init` (project scaffolding), never `construct install`
|
|
8
|
+
* (machine setup must not touch the cwd repo — ADR-0027 §3). The git default
|
|
9
|
+
* (`.git/hooks`, set or unset) is treated as "no active choice" and overwritten;
|
|
10
|
+
* a non-default custom hooksPath is left alone with a warning.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
export function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
18
|
+
const hooksDir = path.join(cwd, '.beads', 'hooks');
|
|
19
|
+
if (!fs.existsSync(path.join(hooksDir, 'pre-commit'))) {
|
|
20
|
+
return { status: 'skipped', reason: 'no .beads/hooks/pre-commit in this directory' };
|
|
21
|
+
}
|
|
22
|
+
const inGit = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
23
|
+
if (inGit.status !== 0) {
|
|
24
|
+
return { status: 'skipped', reason: 'not a git working tree' };
|
|
25
|
+
}
|
|
26
|
+
const current = spawnSync('git', ['config', '--get', 'core.hooksPath'], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
27
|
+
const currentValue = current.status === 0 ? (current.stdout || '').trim() : '';
|
|
28
|
+
const desired = '.beads/hooks';
|
|
29
|
+
if (currentValue === desired) {
|
|
30
|
+
return { status: 'ok', message: 'core.hooksPath already wired to .beads/hooks' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Treat the git default (`.git/hooks` or an absolute path to it) as
|
|
34
|
+
// equivalent to unset. A user who has accepted the default has not made an
|
|
35
|
+
// active choice that justifies "leave alone" semantics; without this branch
|
|
36
|
+
// an install that happens to land while `.git/hooks` is already in play
|
|
37
|
+
// leaves policy gates inactive permanently.
|
|
38
|
+
|
|
39
|
+
const isGitDefault =
|
|
40
|
+
!currentValue ||
|
|
41
|
+
currentValue === '.git/hooks' ||
|
|
42
|
+
currentValue === path.join(cwd, '.git', 'hooks') ||
|
|
43
|
+
currentValue.replace(/\/+$/, '').endsWith(`${path.sep}.git${path.sep}hooks`);
|
|
44
|
+
if (!isGitDefault) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'warning',
|
|
47
|
+
message: `core.hooksPath is set to '${currentValue}'. Leaving alone. Set to '.beads/hooks' to activate Construct policy gates.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const result = spawnSync('git', ['config', 'core.hooksPath', desired], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
51
|
+
if (result.status !== 0) {
|
|
52
|
+
return {
|
|
53
|
+
status: 'error',
|
|
54
|
+
message: `failed to set core.hooksPath: ${(result.stderr || '').trim() || 'unknown error'}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: 'set',
|
|
59
|
+
message: 'core.hooksPath set to .beads/hooks (activates pre-commit secret-scan + Construct policy gates + beads dispatcher)',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/home-namespace.mjs — per-home derivation of local-service identifiers so
|
|
3
|
+
* isolated HOMEs (test sandboxes, multiple users on one machine, parallel CI)
|
|
4
|
+
* never collide on the same Postgres container/port or memory port (construct-lb7b).
|
|
5
|
+
*
|
|
6
|
+
* The canonical `construct-postgres`:54329 container and the memory :8765 port are
|
|
7
|
+
* singular per machine; two HOMEs sharing them clobber each other's data and
|
|
8
|
+
* fight over the port. Each identifier here is derived from a stable hash of the
|
|
9
|
+
* resolved home directory, so every HOME gets its own deterministic namespace.
|
|
10
|
+
*
|
|
11
|
+
* Explicit env overrides win (CONSTRUCT_PG_PORT, MEMORY_PORT, CONSTRUCT_PG_CONTAINER),
|
|
12
|
+
* so a pinned setup keeps its values. The LaunchAgent label stays singular — it is
|
|
13
|
+
* a machine-level guard, not a per-home resource.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { homeDir } from './paths.mjs';
|
|
18
|
+
|
|
19
|
+
const PG_PORT_BASE = 54329;
|
|
20
|
+
const MEMORY_PORT_BASE = 8765;
|
|
21
|
+
const PORT_SPAN = 2000;
|
|
22
|
+
|
|
23
|
+
function digest(home) {
|
|
24
|
+
return createHash('sha256').update(home).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function homeNamespaceSuffix(home = homeDir()) {
|
|
28
|
+
return digest(home).slice(0, 8);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Distinct hash slices for the two ports so they do not move in lockstep across
|
|
32
|
+
// homes (which would re-create collisions one base apart).
|
|
33
|
+
|
|
34
|
+
function offsetFromSlice(home, start) {
|
|
35
|
+
return parseInt(digest(home).slice(start, start + 8), 16) % PORT_SPAN;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function postgresPort(env = process.env, home = homeDir()) {
|
|
39
|
+
const override = env.CONSTRUCT_PG_PORT;
|
|
40
|
+
if (override && /^\d+$/.test(override)) return Number(override);
|
|
41
|
+
return PG_PORT_BASE + offsetFromSlice(home, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function memoryPort(env = process.env, home = homeDir()) {
|
|
45
|
+
const override = env.MEMORY_PORT;
|
|
46
|
+
if (override && /^\d+$/.test(override)) return Number(override);
|
|
47
|
+
return MEMORY_PORT_BASE + offsetFromSlice(home, 8);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function postgresContainerName(env = process.env, home = homeDir()) {
|
|
51
|
+
return env.CONSTRUCT_PG_CONTAINER || `construct-postgres-${homeNamespaceSuffix(home)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The legacy singular identifiers prior versions wrote. The postgres-namespace
|
|
55
|
+
// reconciliation (lib/reconcile/) uses these to detect state that predates the
|
|
56
|
+
// per-home derivation.
|
|
57
|
+
|
|
58
|
+
export const LEGACY_PG_CONTAINER = 'construct-postgres';
|
|
59
|
+
export const LEGACY_PG_PORT = 54329;
|
|
60
|
+
export const LEGACY_MEMORY_PORT = 8765;
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lib/hooks/ci-status-check.mjs — UserPromptSubmit hook: inject remote CI status into agent context.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* agent
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Reads a 60s on-disk cache of the last `gh run list` result for the current
|
|
6
|
+
* branch and, when the cached conclusion is `failure`, emits a stderr line the
|
|
7
|
+
* agent sees in its next observation. The synchronous fast path does no
|
|
8
|
+
* subprocess spawning beyond a 50ms `git branch` probe — every cache miss is
|
|
9
|
+
* served by detaching a background refresher (stale-while-revalidate), so the
|
|
10
|
+
* UserPromptSubmit critical path stays under its 200ms budget regardless of
|
|
11
|
+
* `gh` / network latency. Without this hook, an agent has no way to know
|
|
12
|
+
* remote CI is red without explicitly running gh.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* The background refresher is `scripts/refresh-ci-status.mjs` invoked with
|
|
15
|
+
* `--branch=<name> --cwd=<dir> --cache=<path>`; it inherits no stdio and
|
|
16
|
+
* detaches so a slow `gh` invocation can't keep the hook process alive.
|
|
12
17
|
* Skipped silently if gh is not installed, not authenticated, or the dir is
|
|
13
18
|
* not a git repo.
|
|
14
19
|
*
|
|
15
|
-
* @p95ms
|
|
20
|
+
* @p95ms 200
|
|
16
21
|
* @maxBlockingScope none (UserPromptSubmit, non-blocking)
|
|
17
22
|
*
|
|
18
23
|
* @lifecycle UserPromptSubmit
|
|
@@ -20,13 +25,16 @@
|
|
|
20
25
|
* @exits 0 = pass
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync
|
|
24
|
-
import { execSync } from 'node:child_process';
|
|
28
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
29
|
+
import { execSync, spawn } from 'node:child_process';
|
|
25
30
|
import { homedir } from 'node:os';
|
|
26
|
-
import { join, dirname } from 'node:path';
|
|
31
|
+
import { join, dirname, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
27
33
|
|
|
28
34
|
const CACHE_PATH = join(homedir(), '.cx', 'ci-status-cache.json');
|
|
29
35
|
const CACHE_TTL_MS = 60_000;
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const REFRESHER = resolve(HERE, '..', '..', 'scripts', 'refresh-ci-status.mjs');
|
|
30
38
|
|
|
31
39
|
let cwd = process.cwd();
|
|
32
40
|
try {
|
|
@@ -34,12 +42,20 @@ try {
|
|
|
34
42
|
if (input?.cwd) cwd = input.cwd;
|
|
35
43
|
} catch {}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// 50ms hard timeout on `git branch --show-current`: bounded enough that even
|
|
46
|
+
// a hung git invocation cannot blow the 200ms hook budget.
|
|
47
|
+
|
|
48
|
+
function safeGitBranch() {
|
|
49
|
+
try {
|
|
50
|
+
return execSync('git branch --show-current', {
|
|
51
|
+
cwd,
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
timeout: 50,
|
|
54
|
+
}).toString().trim();
|
|
55
|
+
} catch { return null; }
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
const branch =
|
|
58
|
+
const branch = safeGitBranch();
|
|
43
59
|
if (!branch) process.exit(0);
|
|
44
60
|
|
|
45
61
|
const cacheKey = `${cwd}::${branch}`;
|
|
@@ -48,39 +64,45 @@ try { cache = JSON.parse(readFileSync(CACHE_PATH, 'utf8')); } catch {}
|
|
|
48
64
|
const cached = cache[cacheKey];
|
|
49
65
|
const now = Date.now();
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
// Stale-while-revalidate: serve any cached value (even stale) on the
|
|
68
|
+
// critical path, and detach a background refresher when the cache is stale
|
|
69
|
+
// or absent. The first prompt against a never-cached branch sees nothing;
|
|
70
|
+
// the second prompt (after the detached refresher has written) sees the
|
|
71
|
+
// real status. That tradeoff buys a flat ~50ms p95.
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
runUrl = cached.runUrl;
|
|
59
|
-
} else {
|
|
60
|
-
const json = safeExec(
|
|
61
|
-
`gh run list --branch=${JSON.stringify(branch)} --limit=1 --json conclusion,databaseId,url`,
|
|
62
|
-
{ timeout: 4000 },
|
|
63
|
-
);
|
|
64
|
-
if (!json) process.exit(0);
|
|
73
|
+
const isStale = !cached || (now - cached.fetchedAt) >= CACHE_TTL_MS;
|
|
74
|
+
|
|
75
|
+
if (isStale && existsSync(REFRESHER)) {
|
|
65
76
|
try {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
const child = spawn(process.execPath, [
|
|
78
|
+
REFRESHER,
|
|
79
|
+
`--branch=${branch}`,
|
|
80
|
+
`--cwd=${cwd}`,
|
|
81
|
+
`--cache=${CACHE_PATH}`,
|
|
82
|
+
], {
|
|
83
|
+
cwd,
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: 'ignore',
|
|
86
|
+
});
|
|
87
|
+
child.unref();
|
|
77
88
|
} catch {}
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
if (conclusion === 'failure') {
|
|
91
|
+
if (cached?.conclusion === 'failure') {
|
|
81
92
|
process.stderr.write(
|
|
82
|
-
`[ci] Last CI run on branch '${branch}' FAILED (run ${runId}).\n ${runUrl || ''}\n Investigate with: gh run view ${runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
|
|
93
|
+
`[ci] Last CI run on branch '${branch}' FAILED (run ${cached.runId}).\n ${cached.runUrl || ''}\n Investigate with: gh run view ${cached.runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
|
|
83
94
|
);
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
// First-prompt edge case: no cache exists yet AND we just kicked off the
|
|
98
|
+
// refresher. We still exit 0 (silent). On the next prompt, the cache will
|
|
99
|
+
// be populated and any failure surfaces.
|
|
100
|
+
|
|
101
|
+
if (!cached) {
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(CACHE_PATH), { recursive: true });
|
|
104
|
+
if (!existsSync(CACHE_PATH)) writeFileSync(CACHE_PATH, '{}');
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
process.exit(0);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @lifecycle PreToolUse
|
|
23
23
|
* @matcher Write|Edit|MultiEdit
|
|
24
|
-
* @exits 0 =
|
|
24
|
+
* @exits 0 = always, emits audit event on violation
|
|
25
25
|
*/
|
|
26
26
|
import { readFileSync } from 'fs';
|
|
27
27
|
import { execFileSync } from 'child_process';
|
|
@@ -90,10 +90,10 @@ async function emitEvent(type, category, summary) {
|
|
|
90
90
|
const base = filePath.split('/').pop();
|
|
91
91
|
if (PROTECTED.some(r => r.test(base)) && isTrackedInGit(filePath)) {
|
|
92
92
|
process.stderr.write(
|
|
93
|
-
`[config-protection] The code quality rules are protected.
|
|
93
|
+
`[config-protection] The code quality rules are protected. You are editing a tracked config file. Ensure you are not weakening rules just to bypass errors.\nFile: ${filePath}\n`
|
|
94
94
|
);
|
|
95
|
-
await emitEvent('config.protection.violation', 'code-quality-config', `
|
|
96
|
-
process.exit(
|
|
95
|
+
await emitEvent('config.protection.violation', 'code-quality-config', `Edited code-quality-config: ${filePath}`);
|
|
96
|
+
process.exit(0);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
if (META_FILES.some(r => r.test(filePath))) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lib/hooks/orchestration-dispatch-guard.mjs — backstop against solo-authoring an orchestrated deliverable.
|
|
4
|
+
*
|
|
5
|
+
* The orchestration_policy tool classifies a request into immediate / focused /
|
|
6
|
+
* orchestrated. An orchestrated verdict means the orchestrator must dispatch the
|
|
7
|
+
* specialist chain (cx-architect, cx-security, cx-devil-advocate, …) before
|
|
8
|
+
* authoring the deliverable — not write it solo. The persona instructs this, but
|
|
9
|
+
* instruction is not enforcement: this hook is the Claude-only barrier.
|
|
10
|
+
*
|
|
11
|
+
* Three roles, one file, branched on event + tool:
|
|
12
|
+
* - PostToolUse on *orchestration_policy: record the verdict. orchestrated →
|
|
13
|
+
* {pending:true,dispatched:false}; immediate/focused → cleared. Each new
|
|
14
|
+
* classification resets state for the new request.
|
|
15
|
+
* - PostToolUse on a dispatch tool (*orchestration_run, *worker_run, Task) →
|
|
16
|
+
* {dispatched:true}: the chain is running, authoring is now legitimate.
|
|
17
|
+
* - PreToolUse on Write|Edit|MultiEdit → block (exit 2) when a substantial
|
|
18
|
+
* documentation/strategy deliverable is authored while pending && !dispatched.
|
|
19
|
+
*
|
|
20
|
+
* Scoped to documentation/strategy artifacts (.md outside the routine planning
|
|
21
|
+
* and tracking files), not code — solo code authoring is harder to separate
|
|
22
|
+
* from legitimate pre-dispatch scaffolding, so it is left to the persona
|
|
23
|
+
* contract rather than over-blocked here. Only the deterministic
|
|
24
|
+
* orchestrated-but-not-dispatched case blocks. Fails open on any internal error.
|
|
25
|
+
*
|
|
26
|
+
* @p95ms 8
|
|
27
|
+
* @maxBlockingScope orchestrated deliverable authoring without prior dispatch
|
|
28
|
+
*
|
|
29
|
+
* @lifecycle PreToolUse, PostToolUse
|
|
30
|
+
* @matcher Write|Edit|MultiEdit, mcp__.*__orchestration_policy, mcp__.*__orchestration_run, mcp__.*__worker_run, Task
|
|
31
|
+
* @exits 0 = pass · 2 = block (author after dispatch instead)
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
34
|
+
import { join, basename, relative, isAbsolute } from 'path';
|
|
35
|
+
|
|
36
|
+
const STALE_MS = 2 * 60 * 60 * 1000;
|
|
37
|
+
const SUBSTANTIAL_CHARS = 1500;
|
|
38
|
+
const SUBSTANTIAL_LINES = 40;
|
|
39
|
+
|
|
40
|
+
let input = {};
|
|
41
|
+
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
42
|
+
|
|
43
|
+
const event = input?.hook_event_name || '';
|
|
44
|
+
const toolName = input?.tool_name || '';
|
|
45
|
+
const cwd = input?.cwd || process.cwd();
|
|
46
|
+
|
|
47
|
+
function statePath(root) {
|
|
48
|
+
return join(root, '.cx', 'runtime', 'orchestration-guard.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readState(root) {
|
|
52
|
+
try { return JSON.parse(readFileSync(statePath(root), 'utf8')); } catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeState(root, state) {
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(join(root, '.cx', 'runtime'), { recursive: true });
|
|
58
|
+
writeFileSync(statePath(root), JSON.stringify(state));
|
|
59
|
+
} catch { /* fail open */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The marker only lives inside an already-initialized construct project; never
|
|
63
|
+
// seed .cx into a project that does not have it.
|
|
64
|
+
|
|
65
|
+
function hasCx(root) {
|
|
66
|
+
return existsSync(join(root, '.cx'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPolicyTool(name) {
|
|
70
|
+
return /orchestration_policy$/.test(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isDispatchTool(name) {
|
|
74
|
+
return name === 'Task' || /orchestration_run$/.test(name) || /worker_run$/.test(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// PostToolUse: a classification verdict updates the marker. orchestrated arms
|
|
78
|
+
// the guard; immediate/focused disarms it. The verdict text can be nested in an
|
|
79
|
+
// MCP content envelope, so match against the stringified response.
|
|
80
|
+
|
|
81
|
+
if (event === 'PostToolUse' && isPolicyTool(toolName)) {
|
|
82
|
+
if (!hasCx(cwd)) process.exit(0);
|
|
83
|
+
const blob = JSON.stringify(input?.tool_response ?? '').replace(/\\/g, '');
|
|
84
|
+
const orchestrated = /"track"\s*:\s*"orchestrated"/.test(blob);
|
|
85
|
+
if (orchestrated) writeState(cwd, { pending: true, dispatched: false, ts: Date.now() });
|
|
86
|
+
else writeState(cwd, { pending: false, ts: Date.now() });
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (event === 'PostToolUse' && isDispatchTool(toolName)) {
|
|
91
|
+
if (!hasCx(cwd)) process.exit(0);
|
|
92
|
+
const state = readState(cwd);
|
|
93
|
+
if (state?.pending) writeState(cwd, { ...state, dispatched: true });
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// PreToolUse on an authoring tool: block only the deterministic failure — a
|
|
98
|
+
// substantial documentation/strategy deliverable authored while a fresh
|
|
99
|
+
// orchestrated verdict is pending and no dispatch has run.
|
|
100
|
+
|
|
101
|
+
if (event === 'PreToolUse' && /^(Write|Edit|MultiEdit)$/.test(toolName)) {
|
|
102
|
+
if (!hasCx(cwd)) process.exit(0);
|
|
103
|
+
const state = readState(cwd);
|
|
104
|
+
if (!state?.pending || state?.dispatched) process.exit(0);
|
|
105
|
+
if (typeof state.ts === 'number' && Date.now() - state.ts > STALE_MS) process.exit(0);
|
|
106
|
+
|
|
107
|
+
const ti = input?.tool_input || {};
|
|
108
|
+
const filePath = ti.file_path || ti.filePath || '';
|
|
109
|
+
if (!isDeliverableArtifact(filePath, cwd)) process.exit(0);
|
|
110
|
+
|
|
111
|
+
const authored = authoredContent(toolName, ti);
|
|
112
|
+
const lineCount = authored ? authored.split('\n').length : 0;
|
|
113
|
+
if (authored.length < SUBSTANTIAL_CHARS && lineCount < SUBSTANTIAL_LINES) process.exit(0);
|
|
114
|
+
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
'⛔ orchestration-dispatch-guard: orchestration_policy classified this request as ORCHESTRATED, '
|
|
117
|
+
+ 'but no specialist dispatch (orchestration_run / Task) has run yet.\n'
|
|
118
|
+
+ 'You may not author the deliverable solo. Emit the task-packet and dispatch the chain '
|
|
119
|
+
+ '(e.g. cx-architect owns structure, cx-security challenges the credential/state model, '
|
|
120
|
+
+ 'cx-devil-advocate pressure-tests the approach), then synthesize their verdicts in Construct\'s voice.\n'
|
|
121
|
+
+ 'If this write IS post-dispatch synthesis, run the dispatch tool first so the guard clears.\n',
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
process.exit(0);
|
|
127
|
+
|
|
128
|
+
// A deliverable is a markdown doc/strategy artifact outside the routine
|
|
129
|
+
// planning and tracking files. Code paths are intentionally excluded — see
|
|
130
|
+
// the file header.
|
|
131
|
+
|
|
132
|
+
function isDeliverableArtifact(filePath, root) {
|
|
133
|
+
if (!filePath || !/\.mdx?$/.test(filePath)) return false;
|
|
134
|
+
let rel = filePath;
|
|
135
|
+
try { rel = isAbsolute(filePath) ? relative(root, filePath) : filePath; } catch { rel = filePath; }
|
|
136
|
+
rel = rel.replace(/\\/g, '/');
|
|
137
|
+
if (rel.startsWith('..')) return false;
|
|
138
|
+
if (rel.startsWith('.cx/')) return false;
|
|
139
|
+
const base = basename(rel).toLowerCase();
|
|
140
|
+
const routine = new Set(['plan.md', 'changelog.md', 'readme.md', 'agents.md', 'contributing.md']);
|
|
141
|
+
if (routine.has(base)) return false;
|
|
142
|
+
if (rel.startsWith('.cx/handoffs/') || rel.includes('/handoffs/')) return false;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function authoredContent(name, ti) {
|
|
147
|
+
if (name === 'Write') return String(ti.content ?? '');
|
|
148
|
+
if (name === 'Edit') return String(ti.new_string ?? '');
|
|
149
|
+
if (name === 'MultiEdit' && Array.isArray(ti.edits)) {
|
|
150
|
+
return ti.edits.map((e) => String(e?.new_string ?? '')).join('\n');
|
|
151
|
+
}
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
@@ -116,18 +116,27 @@ if (isGhPrCreate || isGhPrEdit) {
|
|
|
116
116
|
echo();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
// git push: refuse
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// git push: refuse agent-prefixed branches. Match the branch name(s) actually
|
|
120
|
+
// being pushed — the current branch plus any explicit refspec — anchored at the
|
|
121
|
+
// start, so a `.claude/agents` path argument anywhere in the command does not
|
|
122
|
+
// trip the gate.
|
|
123
|
+
|
|
124
|
+
const AGENT_BRANCH_PREFIX = /^(claude|cursor|copilot|codex|aider|devin)\//;
|
|
125
|
+
const pushTargets = [currentBranch()];
|
|
126
|
+
const pushArgs = (command.match(/\bgit\s+push\b([^|;&\n]*)/)?.[1] || '')
|
|
127
|
+
.trim().split(/\s+/).filter((a) => a && !a.startsWith('-'));
|
|
128
|
+
for (const arg of pushArgs.slice(1)) pushTargets.push(arg.split(':').pop());
|
|
129
|
+
|
|
130
|
+
if (pushTargets.some((b) => b && AGENT_BRANCH_PREFIX.test(b))) {
|
|
122
131
|
process.stderr.write(
|
|
123
|
-
`[pre-push-gate] Refusing to push
|
|
132
|
+
`[pre-push-gate] Refusing to push an agent-prefixed branch to remote (user policy).\n` +
|
|
124
133
|
` Use a non-agent branch name.\n`,
|
|
125
134
|
);
|
|
126
135
|
emitRoleEvent({
|
|
127
136
|
type: 'push_gate.fail',
|
|
128
|
-
summary: '
|
|
137
|
+
summary: 'agent-prefixed branch push refused',
|
|
129
138
|
hookInput: input,
|
|
130
|
-
context: { command },
|
|
139
|
+
context: { command, pushTargets },
|
|
131
140
|
});
|
|
132
141
|
process.exit(2);
|
|
133
142
|
}
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
import { readFileSync, existsSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// 500ms keeps the per-turn Stop-hook tax small (ADR-0029): reflection is
|
|
26
|
+
// best-effort and a LanceDB write that overruns the budget is left to the
|
|
27
|
+
// embed daemon's reconcile to backfill. CONSTRUCT_REFLECT_BUDGET_MS raises the
|
|
28
|
+
// ceiling where the write must complete deterministically (slow disks, tests).
|
|
29
|
+
const HARD_BUDGET_MS = Number(process.env.CONSTRUCT_REFLECT_BUDGET_MS) || 500;
|
|
26
30
|
const startedAt = Date.now();
|
|
27
31
|
|
|
28
32
|
// Opt-out before any heavy work — the env check is cheaper than parsing input.
|
|
@@ -155,8 +155,16 @@ const costLogPath = join(home, '.cx', 'session-cost.jsonl');
|
|
|
155
155
|
const lastAgentPath = join(home, '.cx', 'last-agent.json');
|
|
156
156
|
const checkpointPath = join(home, '.cx', 'transcript-checkpoints.json');
|
|
157
157
|
|
|
158
|
+
// Consume-clear like warn-flags/files-changed below: a TS result is reported
|
|
159
|
+
// once, not re-fired on every subsequent no-op Stop. stop-typecheck rewrites
|
|
160
|
+
// the file each turn it actually runs.
|
|
158
161
|
let tsResult = 'unchecked';
|
|
159
|
-
try {
|
|
162
|
+
try {
|
|
163
|
+
if (existsSync(tsResultPath)) {
|
|
164
|
+
tsResult = readFileSync(tsResultPath, 'utf8').trim() || 'unchecked';
|
|
165
|
+
writeFileSync(tsResultPath, 'unchecked');
|
|
166
|
+
}
|
|
167
|
+
} catch { /* non-critical */ }
|
|
160
168
|
|
|
161
169
|
let warnings = [];
|
|
162
170
|
try {
|
|
@@ -326,25 +334,32 @@ try {
|
|
|
326
334
|
}
|
|
327
335
|
} catch { /* review is best-effort */ }
|
|
328
336
|
|
|
337
|
+
// Stop fires per-turn, so a desktop banner only earns the interruption when the
|
|
338
|
+
// turn produced something actionable: files changed or a warning to review.
|
|
339
|
+
// A bare "TS OK" (the sticky-pass default on a no-op turn) is noise — the ledger
|
|
340
|
+
// and session bookkeeping above still run; only the banner is suppressed.
|
|
329
341
|
const platform = process.platform;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
const shouldNotify = (hasWarnings || fileCount > 0) && Boolean(body);
|
|
343
|
+
if (shouldNotify) {
|
|
344
|
+
try {
|
|
345
|
+
if (platform === 'darwin') {
|
|
346
|
+
const escaped = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
347
|
+
const titleEsc = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
348
|
+
execSync(`osascript -e 'display notification "${escaped}" with title "${titleEsc}"'`, {
|
|
349
|
+
timeout: 5000,
|
|
350
|
+
stdio: 'pipe',
|
|
351
|
+
});
|
|
352
|
+
} else if (platform === 'linux') {
|
|
353
|
+
execSync(`notify-send "${title}" "${body.replace(/"/g, '\\"')}"`, {
|
|
354
|
+
timeout: 5000,
|
|
355
|
+
stdio: 'pipe',
|
|
356
|
+
});
|
|
357
|
+
} else {
|
|
358
|
+
process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
344
361
|
process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
|
|
345
362
|
}
|
|
346
|
-
} catch {
|
|
347
|
-
process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
|
|
348
363
|
}
|
|
349
364
|
|
|
350
365
|
process.exit(0);
|
|
@@ -31,8 +31,11 @@ const pending = existsSync(tcPath)
|
|
|
31
31
|
? readFileSync(tcPath, 'utf8').split('\n').filter(Boolean)
|
|
32
32
|
: [];
|
|
33
33
|
|
|
34
|
+
// No files queued means no typecheck ran this turn — record 'unchecked', not a
|
|
35
|
+
// misleading 'pass'. A stale 'pass' here is what made stop-notify fire a bare
|
|
36
|
+
// "TS OK" banner on every no-op turn.
|
|
34
37
|
if (pending.length === 0) {
|
|
35
|
-
try { writeFileSync(tsResultPath, '
|
|
38
|
+
try { writeFileSync(tsResultPath, 'unchecked'); } catch { /* best effort */ }
|
|
36
39
|
process.exit(0);
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -55,8 +58,10 @@ function findTsConfig(startDir) {
|
|
|
55
58
|
const startDir = dirname(pending[0]);
|
|
56
59
|
const tsconfig = findTsConfig(startDir);
|
|
57
60
|
|
|
61
|
+
// Files were edited but no tsconfig governs them — the typecheck couldn't run,
|
|
62
|
+
// so the honest state is 'unchecked', not 'pass'.
|
|
58
63
|
if (!tsconfig) {
|
|
59
|
-
try { writeFileSync(tsResultPath, '
|
|
64
|
+
try { writeFileSync(tsResultPath, 'unchecked'); } catch { /* best effort */ }
|
|
60
65
|
process.exit(0);
|
|
61
66
|
}
|
|
62
67
|
|