@geraldmaron/construct 1.0.5 → 1.0.7
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 +24 -18
- package/agents/contracts.json +617 -2
- package/agents/contracts.schema.json +83 -0
- package/agents/prompts/cx-accessibility.md +5 -3
- package/agents/prompts/cx-ai-engineer.md +9 -7
- package/agents/prompts/cx-architect.md +8 -6
- package/agents/prompts/cx-business-strategist.md +8 -6
- package/agents/prompts/cx-data-analyst.md +9 -7
- package/agents/prompts/cx-data-engineer.md +5 -3
- package/agents/prompts/cx-debugger.md +8 -6
- package/agents/prompts/cx-designer.md +7 -5
- package/agents/prompts/cx-devil-advocate.md +6 -4
- package/agents/prompts/cx-docs-keeper.md +7 -5
- package/agents/prompts/cx-engineer.md +7 -5
- package/agents/prompts/cx-evaluator.md +4 -2
- package/agents/prompts/cx-explorer.md +10 -8
- package/agents/prompts/cx-legal-compliance.md +4 -2
- package/agents/prompts/cx-operations.md +7 -5
- package/agents/prompts/cx-orchestrator.md +12 -10
- package/agents/prompts/cx-platform-engineer.md +7 -5
- package/agents/prompts/cx-product-manager.md +6 -4
- package/agents/prompts/cx-qa.md +10 -8
- package/agents/prompts/cx-rd-lead.md +11 -9
- package/agents/prompts/cx-release-manager.md +7 -5
- package/agents/prompts/cx-researcher.md +24 -22
- package/agents/prompts/cx-reviewer.md +9 -7
- package/agents/prompts/cx-security.md +12 -10
- package/agents/prompts/cx-sre.md +9 -7
- package/agents/prompts/cx-test-automation.md +5 -3
- package/agents/prompts/cx-trace-reviewer.md +10 -8
- package/agents/prompts/cx-ux-researcher.md +5 -3
- package/agents/registry.json +8 -3
- package/bin/construct +444 -53
- package/commands/build/feature.md +4 -4
- package/commands/build/fix.md +8 -8
- package/commands/design/access.md +3 -3
- package/commands/design/flow.md +3 -3
- package/commands/design/ui.md +4 -4
- package/commands/measure/experiment.md +5 -5
- package/commands/measure/metrics.md +3 -3
- package/commands/measure/results.md +4 -4
- package/commands/plan/api.md +3 -3
- package/commands/plan/challenge.md +3 -3
- package/commands/plan/decide.md +3 -3
- package/commands/plan/feature.md +7 -7
- package/commands/plan/requirements.md +3 -3
- package/commands/remember/context.md +5 -5
- package/commands/remember/handoff.md +3 -3
- package/commands/remember/runbook.md +3 -3
- package/commands/review/code.md +8 -8
- package/commands/review/quality.md +4 -4
- package/commands/review/security.md +3 -3
- package/commands/ship/ready.md +3 -3
- package/commands/ship/release.md +3 -3
- package/commands/ship/status.md +4 -4
- package/commands/understand/docs.md +3 -3
- package/commands/understand/this.md +4 -4
- package/commands/understand/why.md +8 -8
- package/commands/work/clean.md +14 -14
- package/commands/work/drive.md +10 -10
- package/commands/work/optimize-prompts.md +9 -9
- package/commands/work/parallel-review.md +8 -8
- package/db/schema/006_graph.sql +24 -0
- package/examples/provider-plugin/README.md +7 -7
- package/examples/seed-observations/README.md +6 -6
- package/examples/seed-observations/anti-patterns.md +14 -14
- package/examples/seed-observations/decisions.md +4 -4
- package/examples/seed-observations/patterns.md +14 -14
- package/lib/auto-docs.mjs +13 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +2 -2
- package/lib/comment-lint.mjs +134 -0
- package/lib/contracts/validate.mjs +323 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +310 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +211 -1
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +2 -0
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/flavors/loader.mjs +1 -1
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/mcp-audit.mjs +2 -1
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/init-unified.mjs +55 -65
- package/lib/intake/classify.mjs +108 -24
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +6 -1
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/prepare.mjs +0 -1
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/graph.mjs +213 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +189 -1
- package/lib/mcp/tools/profile.mjs +270 -0
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/observation-store.mjs +9 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/reflect.mjs +49 -12
- package/lib/server/index.mjs +22 -28
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/telemetry/client.mjs +5 -1
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/package.json +4 -1
- package/personas/construct.md +3 -1
- package/platforms/claude/CLAUDE.md +6 -6
- package/rules/common/agents.md +2 -2
- package/rules/common/beads-hygiene.md +11 -11
- package/rules/common/code-review.md +1 -1
- package/rules/common/coding-style.md +1 -1
- package/rules/common/comments.md +8 -8
- package/rules/common/commit-approval.md +4 -4
- package/rules/common/cx-agent-routing.md +2 -2
- package/rules/common/cx-skill-routing.md +2 -2
- package/rules/common/development-workflow.md +3 -2
- package/rules/common/doc-ownership.md +2 -2
- package/rules/common/efficiency.md +3 -3
- package/rules/common/framing.md +1 -1
- package/rules/common/git-workflow.md +1 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/patterns.md +1 -1
- package/rules/common/performance.md +1 -1
- package/rules/common/release-gates.md +7 -7
- package/rules/common/research.md +4 -4
- package/rules/common/review-before-change.md +58 -0
- package/rules/common/security.md +1 -1
- package/rules/common/skill-composition.md +8 -8
- package/rules/common/testing.md +1 -1
- package/rules/golang/coding-style.md +2 -2
- package/rules/golang/hooks.md +1 -1
- package/rules/golang/patterns.md +1 -1
- package/rules/golang/security.md +1 -1
- package/rules/golang/testing.md +1 -1
- package/rules/python/coding-style.md +1 -1
- package/rules/python/hooks.md +1 -1
- package/rules/python/patterns.md +1 -1
- package/rules/python/security.md +1 -1
- package/rules/python/testing.md +1 -1
- package/rules/swift/coding-style.md +3 -3
- package/rules/swift/hooks.md +2 -2
- package/rules/swift/patterns.md +2 -2
- package/rules/swift/security.md +4 -4
- package/rules/swift/testing.md +2 -2
- package/rules/typescript/coding-style.md +1 -1
- package/rules/typescript/hooks.md +1 -1
- package/rules/typescript/patterns.md +1 -1
- package/rules/typescript/security.md +1 -1
- package/rules/typescript/testing.md +1 -1
- package/rules/web/coding-style.md +1 -1
- package/rules/web/design-quality.md +1 -1
- package/rules/web/hooks.md +1 -1
- package/rules/web/patterns.md +1 -1
- package/rules/web/performance.md +1 -1
- package/rules/web/security.md +1 -1
- package/rules/web/testing.md +1 -1
- package/scripts/sync-agents.mjs +45 -14
- package/skills/ai/agent-dev.md +1 -1
- package/skills/ai/llm-security.md +1 -1
- package/skills/ai/ml-ops.md +6 -6
- package/skills/ai/orchestration-workflow.md +1 -1
- package/skills/ai/prompt-and-eval.md +1 -1
- package/skills/ai/prompt-optimizer.md +13 -13
- package/skills/ai/rag-system.md +1 -1
- package/skills/architecture/api-design.md +1 -1
- package/skills/architecture/caching.md +1 -1
- package/skills/architecture/cloud-native.md +1 -1
- package/skills/architecture/message-queue.md +1 -1
- package/skills/architecture/security-arch.md +1 -1
- package/skills/compliance/ai-disclosure.md +1 -1
- package/skills/compliance/data-privacy.md +1 -1
- package/skills/compliance/license-audit.md +2 -2
- package/skills/compliance/regulatory-review.md +1 -1
- package/skills/development/cpp.md +1 -1
- package/skills/development/go.md +1 -1
- package/skills/development/java.md +1 -1
- package/skills/development/kotlin.md +9 -9
- package/skills/development/mobile-crossplatform.md +13 -13
- package/skills/development/python.md +1 -1
- package/skills/development/rust.md +1 -1
- package/skills/development/shell.md +1 -1
- package/skills/development/swift.md +6 -6
- package/skills/development/typescript.md +1 -1
- package/skills/devops/ci-cd.md +5 -5
- package/skills/devops/containerization.md +9 -9
- package/skills/devops/cost-optimization.md +1 -1
- package/skills/devops/data-engineering.md +2 -2
- package/skills/devops/database.md +1 -1
- package/skills/devops/dependency-management.md +3 -3
- package/skills/devops/devsecops.md +1 -1
- package/skills/devops/git-workflow.md +1 -1
- package/skills/devops/incident-response.md +18 -18
- package/skills/devops/monorepo.md +5 -5
- package/skills/devops/observability.md +1 -1
- package/skills/devops/performance.md +1 -1
- package/skills/devops/testing.md +1 -1
- package/skills/docs/adr-workflow.md +2 -2
- package/skills/docs/backlog-proposal-workflow.md +1 -1
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/document-ingest-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +1 -1
- package/skills/docs/init-docs.md +15 -15
- package/skills/docs/init-project.md +1 -1
- package/skills/docs/prd-workflow.md +3 -3
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +1 -1
- package/skills/docs/product-signal-workflow.md +9 -9
- package/skills/docs/research-workflow.md +10 -10
- package/skills/docs/runbook-workflow.md +2 -2
- package/skills/docs/strategy-workflow.md +3 -3
- package/skills/exploration/repo-map.md +11 -11
- package/skills/frameworks/django.md +15 -15
- package/skills/frameworks/nextjs.md +16 -16
- package/skills/frameworks/react.md +12 -12
- package/skills/frameworks/spring-boot.md +12 -12
- package/skills/frontend-design/accessibility.md +6 -6
- package/skills/frontend-design/component-patterns.md +1 -1
- package/skills/frontend-design/engineering.md +1 -1
- package/skills/frontend-design/state-management.md +1 -1
- package/skills/frontend-design/ui-aesthetics.md +1 -1
- package/skills/frontend-design/ux-principles.md +1 -1
- package/skills/operating/orchestration-reference.md +27 -27
- package/skills/quality-gates/review-work.md +3 -3
- package/skills/quality-gates/verify-change.md +1 -1
- package/skills/quality-gates/verify-module.md +1 -1
- package/skills/quality-gates/verify-quality.md +1 -1
- package/skills/quality-gates/verify-security.md +1 -1
- package/skills/routing.md +14 -14
- package/skills/security/blue-team.md +1 -1
- package/skills/security/code-audit.md +1 -1
- package/skills/security/pentest.md +1 -1
- package/skills/security/red-team.md +1 -1
- package/skills/security/threat-intel.md +1 -1
- package/skills/security/vuln-research.md +1 -1
- package/skills/utility/clean-code.md +2 -2
- package/templates/docs/changelog-entry.md +1 -1
- package/templates/docs/construct_guide.md +18 -18
- package/templates/docs/meta-prd.md +16 -16
- package/templates/docs/one-pager.md +1 -1
- package/templates/docs/prd-business.md +1 -1
- package/templates/docs/prd-platform.md +1 -1
- package/templates/docs/prd.md +17 -17
- package/templates/docs/research-brief.md +8 -8
- package/templates/docs/rfc.md +1 -1
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/docs/strategy.md +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/daemons/contract.mjs — Shared safeguard contract for every long-running
|
|
3
|
+
* Construct process. No ambient process in Construct survives without these
|
|
4
|
+
* rails.
|
|
5
|
+
*
|
|
6
|
+
* Safeguards enforced for every daemon that uses DaemonRunner:
|
|
7
|
+
* - Bounded lifetime: max wall-clock runtime (default 24h) before self-restart
|
|
8
|
+
* with state checkpoint.
|
|
9
|
+
* - Idle shutdown: if no work for N consecutive ticks, stop and log;
|
|
10
|
+
* resume on next external trigger.
|
|
11
|
+
* - Heartbeat: every tick writes lastTickAt to a known path.
|
|
12
|
+
* - Single-writer lock: prevents duplicate daemon instances; auto-expires.
|
|
13
|
+
* - Killswitch: env var disables the daemon without code changes.
|
|
14
|
+
* - Resource caps: CPU/memory limits via process.resourceUsage thresholds.
|
|
15
|
+
*
|
|
16
|
+
* Per-item safeguards (TTL, retry budget, dead-letter) are owned by the work
|
|
17
|
+
* queue, not the runner; this module exposes a small queue helper for that.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'node:fs';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_MAX_RUNTIME_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
export const DEFAULT_IDLE_TICKS = 6;
|
|
26
|
+
export const DEFAULT_MEMORY_CAP_MB = 256;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a DaemonRunner. Spec fields:
|
|
30
|
+
* name — identifier; used in heartbeat path
|
|
31
|
+
* tick(ctx) — async; returns { didWork: boolean } each cycle
|
|
32
|
+
* intervalMs — sleep between ticks
|
|
33
|
+
* killswitchEnv — process.env key; when '=off' the daemon refuses to start
|
|
34
|
+
* heartbeatPath — file written each tick with { ts, name, ticks }
|
|
35
|
+
* lockPath — single-writer lockfile (defaults to heartbeatPath + .lock)
|
|
36
|
+
* maxRuntimeMs — bounded lifetime; default 24h
|
|
37
|
+
* maxIdleTicks — consecutive zero-work ticks before idle shutdown
|
|
38
|
+
* memoryCapMb — RSS cap; exceeding triggers self-stop + escalation
|
|
39
|
+
* onIdleShutdown — optional callback when the runner stops due to idle
|
|
40
|
+
* onMemoryExceeded — optional callback when the runner stops due to memory
|
|
41
|
+
*/
|
|
42
|
+
export function createDaemon(spec) {
|
|
43
|
+
if (!spec?.name) throw new Error('daemon spec missing name');
|
|
44
|
+
if (typeof spec.tick !== 'function') throw new Error('daemon spec missing tick(ctx)');
|
|
45
|
+
|
|
46
|
+
const cfg = {
|
|
47
|
+
intervalMs: spec.intervalMs ?? 60_000,
|
|
48
|
+
killswitchEnv: spec.killswitchEnv,
|
|
49
|
+
heartbeatPath: spec.heartbeatPath,
|
|
50
|
+
lockPath: spec.lockPath || (spec.heartbeatPath ? `${spec.heartbeatPath}.lock` : null),
|
|
51
|
+
maxRuntimeMs: spec.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS,
|
|
52
|
+
maxIdleTicks: spec.maxIdleTicks ?? DEFAULT_IDLE_TICKS,
|
|
53
|
+
memoryCapMb: spec.memoryCapMb ?? DEFAULT_MEMORY_CAP_MB,
|
|
54
|
+
onIdleShutdown: spec.onIdleShutdown,
|
|
55
|
+
onMemoryExceeded: spec.onMemoryExceeded,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let running = false;
|
|
59
|
+
let stopRequested = false;
|
|
60
|
+
let startedAt = 0;
|
|
61
|
+
let ticks = 0;
|
|
62
|
+
let idleTicks = 0;
|
|
63
|
+
|
|
64
|
+
function killswitchEngaged() {
|
|
65
|
+
if (!cfg.killswitchEnv) return false;
|
|
66
|
+
return process.env[cfg.killswitchEnv] === 'off' || process.env[cfg.killswitchEnv] === '0';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function acquireLock() {
|
|
70
|
+
if (!cfg.lockPath) return true;
|
|
71
|
+
try { mkdirSync(dirname(cfg.lockPath), { recursive: true }); } catch { /* ignore */ }
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(cfg.lockPath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), { flag: 'wx' });
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code !== 'EEXIST') throw err;
|
|
77
|
+
const holder = readLockHolder();
|
|
78
|
+
if (holder && processAlive(holder.pid)) return false;
|
|
79
|
+
// Stale lock — steal it
|
|
80
|
+
writeFileSync(cfg.lockPath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), { flag: 'w' });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function releaseLock() {
|
|
86
|
+
if (!cfg.lockPath) return;
|
|
87
|
+
try { unlinkSync(cfg.lockPath); } catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readLockHolder() {
|
|
91
|
+
if (!cfg.lockPath || !existsSync(cfg.lockPath)) return null;
|
|
92
|
+
try { return JSON.parse(readFileSync(cfg.lockPath, 'utf8')); } catch { return null; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function processAlive(pid) {
|
|
96
|
+
if (!pid) return false;
|
|
97
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function heartbeat() {
|
|
101
|
+
if (!cfg.heartbeatPath) return;
|
|
102
|
+
try { mkdirSync(dirname(cfg.heartbeatPath), { recursive: true }); } catch { /* ignore */ }
|
|
103
|
+
try {
|
|
104
|
+
writeFileSync(cfg.heartbeatPath, JSON.stringify({
|
|
105
|
+
name: spec.name, pid: process.pid, ts: Date.now(), ticks, idleTicks,
|
|
106
|
+
}));
|
|
107
|
+
} catch { /* heartbeat is best effort */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function memoryExceeded() {
|
|
111
|
+
const rssMb = process.memoryUsage().rss / (1024 * 1024);
|
|
112
|
+
return rssMb > cfg.memoryCapMb;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function run() {
|
|
116
|
+
if (killswitchEngaged()) {
|
|
117
|
+
return { stopped: true, reason: 'killswitch' };
|
|
118
|
+
}
|
|
119
|
+
if (!acquireLock()) {
|
|
120
|
+
return { stopped: true, reason: 'lock-held' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
running = true;
|
|
124
|
+
stopRequested = false;
|
|
125
|
+
startedAt = Date.now();
|
|
126
|
+
ticks = 0;
|
|
127
|
+
idleTicks = 0;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
while (running && !stopRequested) {
|
|
131
|
+
if (Date.now() - startedAt >= cfg.maxRuntimeMs) {
|
|
132
|
+
return { stopped: true, reason: 'max-runtime', ticks };
|
|
133
|
+
}
|
|
134
|
+
if (memoryExceeded()) {
|
|
135
|
+
if (cfg.onMemoryExceeded) try { await cfg.onMemoryExceeded(); } catch { /* ignore */ }
|
|
136
|
+
return { stopped: true, reason: 'memory-cap', ticks };
|
|
137
|
+
}
|
|
138
|
+
if (killswitchEngaged()) {
|
|
139
|
+
return { stopped: true, reason: 'killswitch-mid-run', ticks };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ticks++;
|
|
143
|
+
let result;
|
|
144
|
+
try { result = await spec.tick({ ticks, idleTicks }); }
|
|
145
|
+
catch (err) { result = { didWork: false, error: err.message }; }
|
|
146
|
+
|
|
147
|
+
heartbeat();
|
|
148
|
+
|
|
149
|
+
if (result?.didWork) idleTicks = 0;
|
|
150
|
+
else idleTicks++;
|
|
151
|
+
|
|
152
|
+
if (idleTicks >= cfg.maxIdleTicks) {
|
|
153
|
+
if (cfg.onIdleShutdown) try { await cfg.onIdleShutdown(); } catch { /* ignore */ }
|
|
154
|
+
return { stopped: true, reason: 'idle', ticks };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await sleep(cfg.intervalMs);
|
|
158
|
+
}
|
|
159
|
+
return { stopped: true, reason: 'requested', ticks };
|
|
160
|
+
} finally {
|
|
161
|
+
running = false;
|
|
162
|
+
releaseLock();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stop() { stopRequested = true; }
|
|
167
|
+
|
|
168
|
+
function status() {
|
|
169
|
+
return {
|
|
170
|
+
name: spec.name,
|
|
171
|
+
running,
|
|
172
|
+
ticks,
|
|
173
|
+
idleTicks,
|
|
174
|
+
uptimeMs: running ? Date.now() - startedAt : 0,
|
|
175
|
+
heartbeatPath: cfg.heartbeatPath,
|
|
176
|
+
lockPath: cfg.lockPath,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { run, stop, status };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Inspect a heartbeat file written by a DaemonRunner. Returns the heartbeat
|
|
185
|
+
* if fresh (within staleMs), null if stale or missing.
|
|
186
|
+
*/
|
|
187
|
+
export function readHeartbeat(heartbeatPath, { staleMs = 5 * 60 * 1000 } = {}) {
|
|
188
|
+
if (!heartbeatPath || !existsSync(heartbeatPath)) return null;
|
|
189
|
+
try {
|
|
190
|
+
const data = JSON.parse(readFileSync(heartbeatPath, 'utf8'));
|
|
191
|
+
const age = Date.now() - (data.ts || 0);
|
|
192
|
+
if (age > staleMs) return null;
|
|
193
|
+
return { ...data, ageMs: age };
|
|
194
|
+
} catch { return null; }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Helper for per-item TTL + retry + dead-letter routing inside a tick. Given
|
|
199
|
+
* a packet with `firstSeenAt` and optional `attempts`, returns a routing
|
|
200
|
+
* decision: 'process' (within budget), 'dead-letter' (TTL or retry budget
|
|
201
|
+
* exhausted), or 'escalate' (past TTL but not yet retried max).
|
|
202
|
+
*/
|
|
203
|
+
export function classifyPacket(packet, { maxAgeMs = 14 * 24 * 60 * 60 * 1000, maxAttempts = 3 } = {}) {
|
|
204
|
+
const firstSeenAt = packet?.firstSeenAt || packet?.createdAt || Date.now();
|
|
205
|
+
const attempts = packet?.attempts || 0;
|
|
206
|
+
const age = Date.now() - new Date(firstSeenAt).getTime();
|
|
207
|
+
if (age > maxAgeMs) return { route: 'dead-letter', reason: 'ttl-exceeded' };
|
|
208
|
+
if (attempts >= maxAttempts) return { route: 'dead-letter', reason: 'retry-budget-exhausted' };
|
|
209
|
+
return { route: 'process', attempts };
|
|
210
|
+
}
|
package/lib/docs-verify.mjs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Checks:
|
|
6
6
|
* 1. README.md exists and has basic sections
|
|
7
|
-
* 2. AGENTS.md exists and has required sections
|
|
7
|
+
* 2. AGENTS.md exists and has required sections
|
|
8
8
|
* 3. plan.md exists and is current (< 7 days)
|
|
9
|
-
* 4. .cx/context.md
|
|
9
|
+
* 4. .cx/context.md is current (< 7 days, critical when .cx/ exists). Verifier
|
|
10
|
+
* reconstitutes the file from the init template when .cx/ exists but the
|
|
11
|
+
* file does not — invariant enforcement is centralized here.
|
|
10
12
|
* 5. docs/README.md exists (if docs system initialized)
|
|
11
13
|
* 6. Required documentation lanes exist (if specified in init)
|
|
12
14
|
*
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
import fs from "node:fs";
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import { statSync, readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { hasIntakeReference } from "./intake/traceability.mjs";
|
|
24
|
+
import { ensureCxDir } from "./project-init-shared.mjs";
|
|
21
25
|
|
|
22
26
|
const args = process.argv.slice(2);
|
|
23
27
|
const quickMode = args.includes("--quick");
|
|
@@ -196,6 +200,37 @@ function checkContextHasProgress(contextPath) {
|
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
// Surfaces drift: artifacts under intake-fed locations that lack an
|
|
204
|
+
// intake_id / intake: none frontmatter reference. Advisory (warning) because
|
|
205
|
+
// not every artifact necessarily comes from an intake packet — but the absence
|
|
206
|
+
// is a flag for the operator to either stamp the provenance or declare it
|
|
207
|
+
// intake-independent.
|
|
208
|
+
function checkIntakeTraceability(cwd) {
|
|
209
|
+
const intakeFedDirs = [
|
|
210
|
+
path.join(cwd, 'docs', 'prd'),
|
|
211
|
+
path.join(cwd, 'docs', 'research'),
|
|
212
|
+
path.join(cwd, '.cx', 'knowledge', 'internal'),
|
|
213
|
+
];
|
|
214
|
+
let inspected = 0;
|
|
215
|
+
for (const dir of intakeFedDirs) {
|
|
216
|
+
if (!fs.existsSync(dir)) continue;
|
|
217
|
+
let entries;
|
|
218
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
221
|
+
if (entry.name.startsWith('_') || entry.name.toLowerCase() === 'readme.md') continue;
|
|
222
|
+
const filePath = path.join(dir, entry.name);
|
|
223
|
+
inspected += 1;
|
|
224
|
+
if (!hasIntakeReference(filePath)) {
|
|
225
|
+
warnings.push(
|
|
226
|
+
`Intake traceability: ${path.relative(cwd, filePath)} lacks intake_id or "intake: none" in frontmatter — stamp via \`construct intake done <id> --output\` or declare intake-independent.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return inspected;
|
|
232
|
+
}
|
|
233
|
+
|
|
199
234
|
function checkDocsSystem(docsPath) {
|
|
200
235
|
if (!fs.existsSync(docsPath)) {
|
|
201
236
|
// docs/ directory doesn't exist, which is fine if project doesn't use docs system
|
|
@@ -331,7 +366,25 @@ function main() {
|
|
|
331
366
|
// Critical checks (fail CI)
|
|
332
367
|
checkExists(path.join(cwd, "README.md"), "README.md", true);
|
|
333
368
|
checkExists(path.join(cwd, "AGENTS.md"), "AGENTS.md", true);
|
|
334
|
-
|
|
369
|
+
|
|
370
|
+
// .cx/context.md invariant. The verifier is the canonical enforcer: if .cx/
|
|
371
|
+
// exists but context.md does not, the file is reconstituted from the init
|
|
372
|
+
// template here. This is consistent with construct init (which creates both
|
|
373
|
+
// together) and absorbs the case where a subsystem creates a .cx/ subdirectory
|
|
374
|
+
// for operational reasons without going through ensureCxDir. CI starts with
|
|
375
|
+
// no .cx/ at all (gitignored) so the block is skipped entirely. Staleness
|
|
376
|
+
// remains critical — a context.md older than 7 days indicates sessions that
|
|
377
|
+
// are running without keeping the handoff state current, which is the real
|
|
378
|
+
// bug the gate exists to catch.
|
|
379
|
+
const cxDir = path.join(cwd, ".cx");
|
|
380
|
+
const contextMdPath = path.join(cxDir, "context.md");
|
|
381
|
+
const cxExists = fs.existsSync(cxDir);
|
|
382
|
+
if (cxExists) {
|
|
383
|
+
if (!fs.existsSync(contextMdPath)) {
|
|
384
|
+
ensureCxDir(cwd);
|
|
385
|
+
}
|
|
386
|
+
checkFileAge(contextMdPath, 7, ".cx/context.md", true);
|
|
387
|
+
}
|
|
335
388
|
|
|
336
389
|
// plan.md is a local working document — gitignored, not committed.
|
|
337
390
|
// Verify it's healthy when present, but don't fail when it's absent.
|
|
@@ -339,16 +392,16 @@ function main() {
|
|
|
339
392
|
|
|
340
393
|
// Age checks (fail CI if stale)
|
|
341
394
|
if (planExists) checkFileAge(path.join(cwd, "plan.md"), 7, "plan.md", true);
|
|
342
|
-
checkFileAge(path.join(cwd, ".cx", "context.md"), 7, ".cx/context.md", true);
|
|
343
395
|
|
|
344
396
|
if (!quickMode) {
|
|
345
397
|
// Detailed checks (warnings only)
|
|
346
398
|
checkReadmeBasicSections(path.join(cwd, "README.md"));
|
|
347
399
|
checkAgentsRequiredSections(path.join(cwd, "AGENTS.md"));
|
|
348
400
|
if (planExists) checkPlanLinkedToIssues(path.join(cwd, "plan.md"));
|
|
349
|
-
|
|
401
|
+
if (cxExists && fs.existsSync(contextMdPath)) checkContextHasProgress(contextMdPath);
|
|
350
402
|
checkDocsSystem(path.join(cwd, "docs"));
|
|
351
|
-
|
|
403
|
+
checkIntakeTraceability(cwd);
|
|
404
|
+
|
|
352
405
|
// Age warning for README.md (30 days)
|
|
353
406
|
checkFileAge(path.join(cwd, "README.md"), 30, "README.md", false);
|
|
354
407
|
}
|
package/lib/doctor/cli.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export async function runCli(args) {
|
|
|
85
85
|
import('./watchers/process-pressure.mjs'),
|
|
86
86
|
import('./watchers/service-health.mjs'),
|
|
87
87
|
import('./watchers/bd-watch.mjs'),
|
|
88
|
+
import('./watchers/consistency.mjs'),
|
|
88
89
|
]);
|
|
89
90
|
for (const w of watchers) {
|
|
90
91
|
const r = await w.tick();
|
|
@@ -93,7 +94,21 @@ export async function runCli(args) {
|
|
|
93
94
|
return 0;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
if (sub === 'consistency') {
|
|
98
|
+
const { runAllChecks } = await import('./watchers/consistency.mjs');
|
|
99
|
+
const result = await runAllChecks();
|
|
100
|
+
const blocking = result.findings.filter((f) => f.severity === 'blocking');
|
|
101
|
+
const warnings = result.findings.filter((f) => f.severity === 'warning');
|
|
102
|
+
|
|
103
|
+
for (const p of result.passed) console.log(` ✓ ${p.category.padEnd(18)} ${p.summary}`);
|
|
104
|
+
for (const w of warnings) console.log(` ⚠ ${w.category.padEnd(18)} ${w.summary}`);
|
|
105
|
+
for (const b of blocking) console.log(` ✗ ${b.category.padEnd(18)} ${b.summary}`);
|
|
106
|
+
|
|
107
|
+
console.log(`\n${result.passed.length} category(s) clean, ${warnings.length} warning(s), ${blocking.length} blocking finding(s)`);
|
|
108
|
+
return blocking.length > 0 ? 1 : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
console.error(`Unknown doctor subcommand: ${sub}`);
|
|
97
|
-
console.error('Usage: construct doctor [status|watch|stop|logs|tick]');
|
|
112
|
+
console.error('Usage: construct doctor [status|watch|stop|logs|tick|consistency]');
|
|
98
113
|
return 2;
|
|
99
114
|
}
|
package/lib/doctor/index.mjs
CHANGED
|
@@ -22,9 +22,11 @@ import * as processPressure from './watchers/process-pressure.mjs';
|
|
|
22
22
|
import * as serviceHealth from './watchers/service-health.mjs';
|
|
23
23
|
import * as bdWatch from './watchers/bd-watch.mjs';
|
|
24
24
|
import * as handoffs from './watchers/handoffs.mjs';
|
|
25
|
+
import * as consistency from './watchers/consistency.mjs';
|
|
26
|
+
import * as mcpProtocol from './watchers/mcp-protocol.mjs';
|
|
25
27
|
|
|
26
28
|
const STATE_PATH = join(homedir(), '.construct', 'doctor.json');
|
|
27
|
-
const WATCHERS = [disk, cost, processPressure, serviceHealth, bdWatch, handoffs];
|
|
29
|
+
const WATCHERS = [disk, cost, processPressure, serviceHealth, bdWatch, handoffs, consistency, mcpProtocol];
|
|
28
30
|
|
|
29
31
|
let running = false;
|
|
30
32
|
let timers = [];
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/doctor/watchers/consistency.mjs — cross-surface drift watcher.
|
|
3
|
+
*
|
|
4
|
+
* Five drift checks bundled at one cadence:
|
|
5
|
+
* 1. Hook manifest ↔ files: every entry in platforms/claude/settings.template.json
|
|
6
|
+
* points to a real .mjs file in lib/hooks/ that parses.
|
|
7
|
+
* 2. MCP registration ↔ impl: every `name === '<tool>'` branch in the MCP
|
|
8
|
+
* dispatch resolves to an imported function, and every exported tool
|
|
9
|
+
* function in lib/mcp/tools/*.mjs is wired into the dispatch.
|
|
10
|
+
* 3. Roles ↔ registry: every persona key in agents/role-manifests.json
|
|
11
|
+
* resolves to a persona or agent name in agents/registry.json.
|
|
12
|
+
* 4. Persona promptFiles: every registry.personas[].promptFile and
|
|
13
|
+
* registry.agents[].promptFile exists on disk.
|
|
14
|
+
* 5. Contracts ↔ schemas: agents/contracts.json passes validateContractsFile
|
|
15
|
+
* (cross-file refs, producer/consumer names, schema paths).
|
|
16
|
+
*
|
|
17
|
+
* Each violation produces an audit record. Blocking severity escalates to a
|
|
18
|
+
* role intake; advisory severity records only. Tick is cheap — reads JSON +
|
|
19
|
+
* directory listings, no network.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
+
import { join, dirname, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { record } from '../audit.mjs';
|
|
27
|
+
import { escalate } from '../escalate.mjs';
|
|
28
|
+
|
|
29
|
+
export const name = 'consistency';
|
|
30
|
+
export const intervalMs = 15 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
33
|
+
|
|
34
|
+
export async function tick() {
|
|
35
|
+
const results = await runAllChecks({ repoRoot: REPO_ROOT });
|
|
36
|
+
const actions = [];
|
|
37
|
+
const escalations = [];
|
|
38
|
+
|
|
39
|
+
for (const finding of results.findings) {
|
|
40
|
+
record({
|
|
41
|
+
kind: finding.severity === 'blocking' ? 'escalation' : 'action',
|
|
42
|
+
watcher: name,
|
|
43
|
+
action: finding.category,
|
|
44
|
+
summary: finding.summary,
|
|
45
|
+
context: { details: finding.details },
|
|
46
|
+
});
|
|
47
|
+
actions.push({ type: finding.category, target: finding.target || null });
|
|
48
|
+
if (finding.severity === 'blocking') {
|
|
49
|
+
escalate({
|
|
50
|
+
watcher: name,
|
|
51
|
+
event: `consistency.${finding.category}`,
|
|
52
|
+
severity: 'high',
|
|
53
|
+
summary: finding.summary,
|
|
54
|
+
context: { details: finding.details },
|
|
55
|
+
});
|
|
56
|
+
escalations.push({ event: `consistency.${finding.category}`, severity: 'high' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { actions, escalations };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run all five consistency checks. Returns { findings: [], passed: [] }.
|
|
65
|
+
* Pure function — no audit/escalate side effects. Surfaced separately so the
|
|
66
|
+
* `construct doctor consistency` CLI and the release-gate functional test can
|
|
67
|
+
* call it without spinning up the daemon.
|
|
68
|
+
*/
|
|
69
|
+
export async function runAllChecks({ repoRoot = REPO_ROOT } = {}) {
|
|
70
|
+
const findings = [];
|
|
71
|
+
const passed = [];
|
|
72
|
+
|
|
73
|
+
const hooks = checkHooksDrift({ repoRoot });
|
|
74
|
+
collect(hooks, findings, passed, 'hooks-drift');
|
|
75
|
+
|
|
76
|
+
const mcp = checkMcpDrift({ repoRoot });
|
|
77
|
+
collect(mcp, findings, passed, 'mcp-drift');
|
|
78
|
+
|
|
79
|
+
const roles = checkRolesDrift({ repoRoot });
|
|
80
|
+
collect(roles, findings, passed, 'roles-drift');
|
|
81
|
+
|
|
82
|
+
const prompts = checkPersonaPrompts({ repoRoot });
|
|
83
|
+
collect(prompts, findings, passed, 'prompt-files');
|
|
84
|
+
|
|
85
|
+
const contracts = await checkContractsDrift({ repoRoot });
|
|
86
|
+
collect(contracts, findings, passed, 'contracts-drift');
|
|
87
|
+
|
|
88
|
+
return { findings, passed };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collect(result, findings, passed, defaultCategory) {
|
|
92
|
+
if (result.violations.length === 0) {
|
|
93
|
+
passed.push({ category: defaultCategory, summary: result.summary });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const v of result.violations) {
|
|
97
|
+
findings.push({
|
|
98
|
+
category: v.category || defaultCategory,
|
|
99
|
+
severity: v.severity || 'warning',
|
|
100
|
+
summary: v.summary,
|
|
101
|
+
target: v.target || null,
|
|
102
|
+
details: v.details || null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Check 1: hook manifest ↔ files ─────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function checkHooksDrift({ repoRoot }) {
|
|
110
|
+
const violations = [];
|
|
111
|
+
const settingsPath = join(repoRoot, 'platforms', 'claude', 'settings.template.json');
|
|
112
|
+
const hooksDir = join(repoRoot, 'lib', 'hooks');
|
|
113
|
+
|
|
114
|
+
if (!existsSync(settingsPath)) {
|
|
115
|
+
return {
|
|
116
|
+
summary: 'settings template missing',
|
|
117
|
+
violations: [{ severity: 'blocking', summary: `settings template missing: ${settingsPath}` }],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let settings;
|
|
122
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
|
|
123
|
+
catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
summary: 'settings template parse error',
|
|
126
|
+
violations: [{ severity: 'blocking', summary: `settings template parse error: ${err.message}` }],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const referenced = new Set();
|
|
131
|
+
collectHookCommands(settings.hooks, referenced);
|
|
132
|
+
|
|
133
|
+
for (const hookPath of referenced) {
|
|
134
|
+
const resolvedPath = resolveHookPath(hookPath, hooksDir, repoRoot);
|
|
135
|
+
if (!resolvedPath || !existsSync(resolvedPath)) {
|
|
136
|
+
violations.push({
|
|
137
|
+
category: 'hooks-drift',
|
|
138
|
+
severity: 'blocking',
|
|
139
|
+
target: hookPath,
|
|
140
|
+
summary: `hook referenced by settings template does not exist on disk: ${hookPath}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { summary: `hooks: ${referenced.size} referenced, ${violations.length} missing`, violations };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function collectHookCommands(node, out) {
|
|
149
|
+
if (!node) return;
|
|
150
|
+
if (Array.isArray(node)) { node.forEach((n) => collectHookCommands(n, out)); return; }
|
|
151
|
+
if (typeof node !== 'object') return;
|
|
152
|
+
if (typeof node.command === 'string' && /\.mjs/.test(node.command)) {
|
|
153
|
+
const match = node.command.match(/([\w./-]+\.mjs)/);
|
|
154
|
+
if (match) out.add(match[1]);
|
|
155
|
+
}
|
|
156
|
+
for (const v of Object.values(node)) collectHookCommands(v, out);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveHookPath(reference, hooksDir, repoRoot) {
|
|
160
|
+
if (reference.startsWith('/')) return reference;
|
|
161
|
+
if (reference.startsWith('lib/hooks/')) return join(repoRoot, reference);
|
|
162
|
+
const bare = reference.split('/').pop();
|
|
163
|
+
return join(hooksDir, bare);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Check 2: MCP registration ↔ impl ───────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function checkMcpDrift({ repoRoot }) {
|
|
169
|
+
const violations = [];
|
|
170
|
+
const serverPath = join(repoRoot, 'lib', 'mcp', 'server.mjs');
|
|
171
|
+
const toolsDir = join(repoRoot, 'lib', 'mcp', 'tools');
|
|
172
|
+
|
|
173
|
+
if (!existsSync(serverPath) || !existsSync(toolsDir)) {
|
|
174
|
+
return { summary: 'mcp surface missing', violations: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const serverSource = readFileSync(serverPath, 'utf8');
|
|
178
|
+
const dispatchedTools = new Set();
|
|
179
|
+
for (const m of serverSource.matchAll(/name === ['"]([a-z][a-z0-9_]*)['"]/g)) {
|
|
180
|
+
dispatchedTools.add(m[1]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const exportedTools = new Set();
|
|
184
|
+
const entries = readdirSync(toolsDir, { withFileTypes: true });
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (!entry.isFile() || !entry.name.endsWith('.mjs')) continue;
|
|
187
|
+
const src = readFileSync(join(toolsDir, entry.name), 'utf8');
|
|
188
|
+
for (const m of src.matchAll(/export (?:async )?function (\w+)/g)) {
|
|
189
|
+
exportedTools.add(camelToSnake(m[1]));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const tool of exportedTools) {
|
|
194
|
+
if (KNOWN_NON_DISPATCH_TOOLS.has(tool)) continue;
|
|
195
|
+
if (!dispatchedTools.has(tool)) {
|
|
196
|
+
violations.push({
|
|
197
|
+
category: 'mcp-drift',
|
|
198
|
+
severity: 'warning',
|
|
199
|
+
target: tool,
|
|
200
|
+
summary: `MCP tool exported but not dispatched: ${tool}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { summary: `mcp: ${dispatchedTools.size} dispatched, ${exportedTools.size} exported, ${violations.length} drift`, violations };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const KNOWN_NON_DISPATCH_TOOLS = new Set([
|
|
209
|
+
// Internal helpers exported for tests but never wired through MCP.
|
|
210
|
+
'workflow_status_bound',
|
|
211
|
+
'create_needs_main_input_packet',
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
function camelToSnake(s) {
|
|
215
|
+
return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Check 3: roles ↔ registry ──────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function checkRolesDrift({ repoRoot }) {
|
|
221
|
+
const violations = [];
|
|
222
|
+
const manifestsPath = join(repoRoot, 'agents', 'role-manifests.json');
|
|
223
|
+
const registryPath = join(repoRoot, 'agents', 'registry.json');
|
|
224
|
+
|
|
225
|
+
if (!existsSync(manifestsPath) || !existsSync(registryPath)) {
|
|
226
|
+
return { summary: 'role manifests or registry missing', violations: [] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const manifests = JSON.parse(readFileSync(manifestsPath, 'utf8'));
|
|
230
|
+
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
231
|
+
|
|
232
|
+
const known = new Set();
|
|
233
|
+
for (const a of registry.agents || []) {
|
|
234
|
+
if (a?.name) { known.add(a.name); known.add(`cx-${a.name}`); }
|
|
235
|
+
}
|
|
236
|
+
for (const p of registry.personas || []) {
|
|
237
|
+
if (p?.name) { known.add(p.name); known.add(`cx-${p.name}`); }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const personaKeys = Object.keys(manifests.personas || manifests || {});
|
|
241
|
+
for (const key of personaKeys) {
|
|
242
|
+
if (key === 'version' || key === 'description' || key === 'schemaVersion') continue;
|
|
243
|
+
if (!known.has(key) && !known.has(`cx-${key}`)) {
|
|
244
|
+
violations.push({
|
|
245
|
+
category: 'roles-drift',
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
target: key,
|
|
248
|
+
summary: `role-manifests persona '${key}' does not resolve to a registry persona/agent`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { summary: `roles: ${personaKeys.length} manifest keys, ${violations.length} drift`, violations };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Check 4: persona promptFile existence ──────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function checkPersonaPrompts({ repoRoot }) {
|
|
259
|
+
const violations = [];
|
|
260
|
+
const registryPath = join(repoRoot, 'agents', 'registry.json');
|
|
261
|
+
if (!existsSync(registryPath)) return { summary: 'registry missing', violations: [] };
|
|
262
|
+
|
|
263
|
+
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
264
|
+
let checked = 0;
|
|
265
|
+
|
|
266
|
+
for (const list of [registry.personas || [], registry.agents || []]) {
|
|
267
|
+
for (const entry of list) {
|
|
268
|
+
const ref = entry?.promptFile;
|
|
269
|
+
if (!ref) continue;
|
|
270
|
+
checked++;
|
|
271
|
+
const promptPath = ref.startsWith('/') ? ref : join(repoRoot, ref);
|
|
272
|
+
if (!existsSync(promptPath)) {
|
|
273
|
+
violations.push({
|
|
274
|
+
category: 'prompt-files',
|
|
275
|
+
severity: 'blocking',
|
|
276
|
+
target: ref,
|
|
277
|
+
summary: `registry entry '${entry.name}' references missing promptFile: ${ref}`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { summary: `prompt-files: ${checked} referenced, ${violations.length} missing`, violations };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Check 5: contracts ↔ schemas ───────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
async function checkContractsDrift({ repoRoot }) {
|
|
289
|
+
const violations = [];
|
|
290
|
+
try {
|
|
291
|
+
const { validateContractsFile } = await import('../../contracts/validate.mjs');
|
|
292
|
+
const result = validateContractsFile({ repoRoot });
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
for (const err of result.errors) {
|
|
295
|
+
violations.push({
|
|
296
|
+
category: 'contracts-drift',
|
|
297
|
+
severity: 'warning',
|
|
298
|
+
summary: err,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch (err) {
|
|
303
|
+
violations.push({
|
|
304
|
+
category: 'contracts-drift',
|
|
305
|
+
severity: 'warning',
|
|
306
|
+
summary: `contract validator unavailable: ${err.message}`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return { summary: `contracts: ${violations.length} drift`, violations };
|
|
310
|
+
}
|