@geraldmaron/construct 1.0.6 → 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.
Files changed (123) hide show
  1. package/README.md +19 -17
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +2 -0
  5. package/agents/prompts/cx-ai-engineer.md +2 -0
  6. package/agents/prompts/cx-architect.md +2 -0
  7. package/agents/prompts/cx-business-strategist.md +2 -0
  8. package/agents/prompts/cx-data-analyst.md +2 -0
  9. package/agents/prompts/cx-data-engineer.md +2 -0
  10. package/agents/prompts/cx-debugger.md +2 -0
  11. package/agents/prompts/cx-designer.md +2 -0
  12. package/agents/prompts/cx-devil-advocate.md +2 -0
  13. package/agents/prompts/cx-docs-keeper.md +2 -0
  14. package/agents/prompts/cx-engineer.md +2 -0
  15. package/agents/prompts/cx-evaluator.md +2 -0
  16. package/agents/prompts/cx-explorer.md +2 -0
  17. package/agents/prompts/cx-legal-compliance.md +2 -0
  18. package/agents/prompts/cx-operations.md +2 -0
  19. package/agents/prompts/cx-orchestrator.md +2 -0
  20. package/agents/prompts/cx-platform-engineer.md +2 -0
  21. package/agents/prompts/cx-product-manager.md +2 -0
  22. package/agents/prompts/cx-qa.md +2 -0
  23. package/agents/prompts/cx-rd-lead.md +2 -0
  24. package/agents/prompts/cx-release-manager.md +2 -0
  25. package/agents/prompts/cx-researcher.md +2 -0
  26. package/agents/prompts/cx-reviewer.md +2 -0
  27. package/agents/prompts/cx-security.md +2 -0
  28. package/agents/prompts/cx-sre.md +2 -0
  29. package/agents/prompts/cx-test-automation.md +2 -0
  30. package/agents/prompts/cx-trace-reviewer.md +2 -0
  31. package/agents/prompts/cx-ux-researcher.md +2 -0
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +209 -32
  34. package/commands/build/feature.md +1 -1
  35. package/commands/build/fix.md +1 -1
  36. package/commands/design/access.md +1 -1
  37. package/commands/design/flow.md +1 -1
  38. package/commands/design/ui.md +1 -1
  39. package/commands/measure/experiment.md +1 -1
  40. package/commands/measure/metrics.md +1 -1
  41. package/commands/measure/results.md +1 -1
  42. package/commands/plan/api.md +1 -1
  43. package/commands/plan/challenge.md +1 -1
  44. package/commands/plan/decide.md +1 -1
  45. package/commands/plan/feature.md +1 -1
  46. package/commands/plan/requirements.md +1 -1
  47. package/commands/remember/context.md +1 -1
  48. package/commands/remember/handoff.md +1 -1
  49. package/commands/remember/runbook.md +1 -1
  50. package/commands/review/code.md +1 -1
  51. package/commands/review/quality.md +1 -1
  52. package/commands/review/security.md +1 -1
  53. package/commands/ship/ready.md +1 -1
  54. package/commands/ship/release.md +1 -1
  55. package/commands/ship/status.md +1 -1
  56. package/commands/understand/docs.md +1 -1
  57. package/commands/understand/this.md +1 -1
  58. package/commands/understand/why.md +1 -1
  59. package/commands/work/clean.md +1 -1
  60. package/commands/work/drive.md +1 -1
  61. package/commands/work/optimize-prompts.md +1 -1
  62. package/commands/work/parallel-review.md +1 -1
  63. package/lib/auto-docs.mjs +7 -7
  64. package/lib/boundary.mjs +126 -0
  65. package/lib/cache-strategy-google.js +26 -31
  66. package/lib/comment-lint.mjs +134 -0
  67. package/lib/contracts/validate.mjs +323 -0
  68. package/lib/daemons/contract.mjs +210 -0
  69. package/lib/docs-verify.mjs +59 -6
  70. package/lib/doctor/cli.mjs +16 -1
  71. package/lib/doctor/index.mjs +3 -1
  72. package/lib/doctor/watchers/consistency.mjs +310 -0
  73. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  74. package/lib/document-extract.mjs +211 -1
  75. package/lib/embed/cli.mjs +124 -3
  76. package/lib/embed/daemon.mjs +43 -4
  77. package/lib/embed/docs-lifecycle.mjs +1 -1
  78. package/lib/embed/inbox.mjs +2 -0
  79. package/lib/embed/scheduler.mjs +33 -5
  80. package/lib/evaluator-optimizer.mjs +2 -3
  81. package/lib/hooks/comment-lint.mjs +16 -0
  82. package/lib/hooks/mcp-audit.mjs +2 -1
  83. package/lib/hooks/proactive-activation.mjs +0 -14
  84. package/lib/hooks/rule-verifier.mjs +217 -0
  85. package/lib/hooks/session-optimize.mjs +2 -1
  86. package/lib/init-unified.mjs +55 -65
  87. package/lib/intake/classify.mjs +108 -24
  88. package/lib/intake/daemon.mjs +121 -0
  89. package/lib/intake/filesystem-queue.mjs +6 -1
  90. package/lib/intake/intake-config.mjs +2 -1
  91. package/lib/intake/prepare.mjs +0 -1
  92. package/lib/intake/session-prelude.mjs +7 -1
  93. package/lib/intake/traceability.mjs +90 -0
  94. package/lib/knowledge/research-store.mjs +2 -0
  95. package/lib/maintenance/cleanup.mjs +315 -0
  96. package/lib/mcp/memory-bridge.mjs +276 -0
  97. package/lib/mcp/server.mjs +2 -0
  98. package/lib/mcp/tools/workflow.mjs +25 -0
  99. package/lib/mcp-catalog.json +12 -8
  100. package/lib/mcp-platform-config.mjs +16 -8
  101. package/lib/migrations/index.mjs +106 -0
  102. package/lib/migrations/v1-baseline.mjs +33 -0
  103. package/lib/observation-store.mjs +9 -4
  104. package/lib/outcomes/record.mjs +2 -0
  105. package/lib/profiles/rebrand.mjs +46 -0
  106. package/lib/project-init-shared.mjs +12 -0
  107. package/lib/provider-capabilities.js +20 -7
  108. package/lib/providers/auth-manager.mjs +58 -17
  109. package/lib/reflect.mjs +49 -12
  110. package/lib/server/index.mjs +22 -28
  111. package/lib/session-store.mjs +6 -4
  112. package/lib/setup.mjs +14 -3
  113. package/lib/telemetry/client.mjs +5 -1
  114. package/lib/version.mjs +51 -0
  115. package/lib/worker/trace.mjs +5 -1
  116. package/package.json +4 -1
  117. package/personas/construct.md +3 -1
  118. package/rules/common/development-workflow.md +2 -1
  119. package/rules/common/no-fabrication.md +69 -0
  120. package/rules/common/review-before-change.md +58 -0
  121. package/scripts/sync-agents.mjs +45 -14
  122. package/templates/docs/construct_guide.md +6 -6
  123. package/templates/docs/skill-artifact.md +1 -1
@@ -0,0 +1,323 @@
1
+ /**
2
+ * lib/contracts/validate.mjs — Validate agents/contracts.json at sync time + at handoff.
3
+ *
4
+ * Three validation tiers:
5
+ * 1. Schema shape: contracts.json conforms to contracts.schema.json (minimal
6
+ * validator — top-level required fields, contract entry required fields).
7
+ * 2. Cross-file refs: every output.schema points to a real file in lib/schemas/,
8
+ * every producer/consumer name resolves to an agent or persona in
9
+ * agents/registry.json, every well-known event/intake string is reachable.
10
+ * 3. Runtime handoff: a single artifact validated against the schema referenced
11
+ * by a producer→consumer contract, with mustContain post-conditions.
12
+ *
13
+ * Surfaces:
14
+ * - scripts/sync-agents.mjs invokes validateContractsFile at sync time.
15
+ * - bin/construct lint:contracts invokes the same path in CI.
16
+ * - workflowContractValidate (runtime) invokes validateHandoff per handoff.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join, dirname, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
24
+ const CONTRACTS_PATH = join(REPO_ROOT, 'agents', 'contracts.json');
25
+ const CONTRACTS_SCHEMA_PATH = join(REPO_ROOT, 'agents', 'contracts.schema.json');
26
+ const REGISTRY_PATH = join(REPO_ROOT, 'agents', 'registry.json');
27
+
28
+ const WELL_KNOWN_PRODUCERS = new Set(['user', 'oncall', 'incident-system', '*']);
29
+ const WELL_KNOWN_CONSUMERS = new Set(['user', 'construct']);
30
+
31
+ function readJson(path) {
32
+ return JSON.parse(readFileSync(path, 'utf8'));
33
+ }
34
+
35
+ /**
36
+ * Validate agents/contracts.json shape and cross-file references.
37
+ * Returns { ok, errors[] } — errors is empty on success.
38
+ */
39
+ export function validateContractsFile({ contractsPath, registryPath, repoRoot = REPO_ROOT } = {}) {
40
+ const errors = [];
41
+
42
+ // When repoRoot is supplied but explicit paths are not, derive both from it
43
+ // so test fixtures and the consistency watcher can validate slices in a
44
+ // tmpdir without pointing at the live repo.
45
+ const cPath = contractsPath || join(repoRoot, 'agents', 'contracts.json');
46
+ const rPath = registryPath || join(repoRoot, 'agents', 'registry.json');
47
+ const schemaPath = join(repoRoot, 'agents', 'contracts.schema.json');
48
+
49
+ if (!existsSync(cPath)) {
50
+ return { ok: false, errors: [`contracts file not found: ${cPath}`] };
51
+ }
52
+ if (!existsSync(schemaPath)) {
53
+ errors.push(`contracts schema missing: ${schemaPath}`);
54
+ }
55
+
56
+ let contracts;
57
+ try { contracts = readJson(cPath); }
58
+ catch (err) { return { ok: false, errors: [`contracts.json parse error: ${err.message}`] }; }
59
+
60
+ for (const key of ['version', 'terminalStates', 'severities', 'contracts']) {
61
+ if (!(key in contracts)) errors.push(`contracts.json missing top-level field: ${key}`);
62
+ }
63
+
64
+ const knownNames = collectAgentNames(rPath);
65
+
66
+ if (Array.isArray(contracts.contracts)) {
67
+ const ids = new Set();
68
+ contracts.contracts.forEach((c, idx) => {
69
+ const where = `contracts[${idx}]${c.id ? ` (${c.id})` : ''}`;
70
+ if (!c.id) errors.push(`${where}: missing id`);
71
+ else if (!/^[a-z0-9][a-z0-9-]*$/.test(c.id)) errors.push(`${where}: id must be kebab-case`);
72
+ else if (ids.has(c.id)) errors.push(`${where}: duplicate id`);
73
+ else ids.add(c.id);
74
+
75
+ if (!c.producer) errors.push(`${where}: missing producer`);
76
+ if (!c.consumer) errors.push(`${where}: missing consumer`);
77
+ if (!c.input) errors.push(`${where}: missing input`);
78
+
79
+ if (c.producer && !nameResolves(c.producer, knownNames, 'producer')) {
80
+ errors.push(`${where}: producer '${c.producer}' is not an agent/persona in registry.json and is not a well-known producer`);
81
+ }
82
+ if (c.consumer && !nameResolves(c.consumer, knownNames, 'consumer')) {
83
+ errors.push(`${where}: consumer '${c.consumer}' is not an agent/persona in registry.json and is not a well-known consumer`);
84
+ }
85
+
86
+ const schemaRef = c.output?.schema;
87
+ if (schemaRef) {
88
+ const outputSchemaPath = join(repoRoot, schemaRef);
89
+ if (!existsSync(outputSchemaPath)) {
90
+ errors.push(`${where}: output.schema '${schemaRef}' does not exist on disk`);
91
+ } else {
92
+ try { readJson(outputSchemaPath); }
93
+ catch (err) { errors.push(`${where}: output.schema '${schemaRef}' is not valid JSON: ${err.message}`); }
94
+ }
95
+ }
96
+ });
97
+ } else {
98
+ errors.push('contracts.json: contracts must be an array');
99
+ }
100
+
101
+ return { ok: errors.length === 0, errors };
102
+ }
103
+
104
+ function nameResolves(name, knownNames, role) {
105
+ if (WELL_KNOWN_PRODUCERS.has(name) && role === 'producer') return true;
106
+ if (WELL_KNOWN_CONSUMERS.has(name) && role === 'consumer') return true;
107
+ if (knownNames.has(name)) return true;
108
+ // Persona registry stores names without the cx- prefix; contracts.json
109
+ // conventionally uses the cx-prefixed form. Normalize both directions.
110
+ const stripped = name.startsWith('cx-') ? name.slice(3) : `cx-${name}`;
111
+ return knownNames.has(stripped);
112
+ }
113
+
114
+ function collectAgentNames(registryPath) {
115
+ const names = new Set();
116
+ if (!existsSync(registryPath)) return names;
117
+ try {
118
+ const registry = readJson(registryPath);
119
+ for (const a of registry.agents || []) {
120
+ if (a?.name) names.add(a.name);
121
+ if (a?.displayName) names.add(a.displayName);
122
+ }
123
+ for (const p of registry.personas || []) {
124
+ if (p?.name) names.add(p.name);
125
+ if (p?.displayName) names.add(p.displayName);
126
+ }
127
+ } catch { /* fall through with whatever names we collected */ }
128
+ return names;
129
+ }
130
+
131
+ /**
132
+ * Look up a contract by producer/consumer pair (and optional id).
133
+ */
134
+ export function findContract({ producer, consumer, id, contractsPath = CONTRACTS_PATH }) {
135
+ if (!existsSync(contractsPath)) return null;
136
+ let contracts;
137
+ try { contracts = readJson(contractsPath); } catch { return null; }
138
+ const list = contracts.contracts || [];
139
+ if (id) return list.find((c) => c.id === id) || null;
140
+ return list.find((c) => c.producer === producer && c.consumer === consumer) || null;
141
+ }
142
+
143
+ /**
144
+ * Validate a single artifact against its contract at handoff time.
145
+ *
146
+ * Returns one of:
147
+ * { ok: true, contract }
148
+ * { ok: false, status: 'BLOCKED_CONTRACT', errors[], contract }
149
+ *
150
+ * Enforcement defaults to the value of process.env.CONSTRUCT_CONTRACT_ENFORCEMENT
151
+ * ('warn' or 'block'), and may be overridden by the caller. In warn mode the
152
+ * result includes errors but ok stays true so a session is not blocked.
153
+ */
154
+ export function validateHandoff({
155
+ producer,
156
+ consumer,
157
+ id,
158
+ artifact,
159
+ contractsPath = CONTRACTS_PATH,
160
+ repoRoot = REPO_ROOT,
161
+ enforcement = process.env.CONSTRUCT_CONTRACT_ENFORCEMENT || 'warn',
162
+ }) {
163
+ const contract = findContract({ producer, consumer, id, contractsPath });
164
+ if (!contract) {
165
+ return enforcement === 'block'
166
+ ? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
167
+ : { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
168
+ }
169
+
170
+ const errors = [];
171
+
172
+ // A handoff carries a producer's output, which is the consumer's input. The
173
+ // input contract is what the consumer expects to receive; check mustContain
174
+ // against that first. Output.schema validation only applies when the artifact
175
+ // declares the matching `type` (e.g. the consumer is forwarding its own
176
+ // produced artifact downstream).
177
+ const inputMustContain = contract.input?.mustContain || [];
178
+ for (const field of inputMustContain) {
179
+ if (!hasField(artifact, field)) {
180
+ errors.push(`artifact missing required field: ${field}`);
181
+ }
182
+ }
183
+
184
+ const outputType = contract.output?.type;
185
+ const schemaRef = contract.output?.schema;
186
+ if (schemaRef && outputType && artifact && artifact.type === outputType) {
187
+ const schemaPath = join(repoRoot, schemaRef);
188
+ if (!existsSync(schemaPath)) {
189
+ errors.push(`contract output.schema '${schemaRef}' does not exist on disk`);
190
+ } else {
191
+ const schema = readJson(schemaPath);
192
+ for (const field of schema.required || []) {
193
+ if (!hasField(artifact, field)) errors.push(`artifact missing schema-required field: ${field}`);
194
+ }
195
+ }
196
+ }
197
+
198
+ // Postcondition checks run against the artifact file on disk when the
199
+ // handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
200
+ const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
201
+ if (declaredArtifactPath) {
202
+ const absPath = isAbsolutePath(declaredArtifactPath)
203
+ ? declaredArtifactPath
204
+ : join(repoRoot, declaredArtifactPath);
205
+ const pcErrors = validatePostconditions({ contract, artifactPath: absPath });
206
+ for (const e of pcErrors) errors.push(e);
207
+ }
208
+
209
+ if (errors.length === 0) return { ok: true, contract };
210
+ if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
211
+ return { ok: true, warnings: errors, contract };
212
+ }
213
+
214
+ function isAbsolutePath(p) {
215
+ return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
216
+ }
217
+
218
+ function hasField(obj, field) {
219
+ if (obj == null) return false;
220
+ if (typeof obj !== 'object') return false;
221
+ return Object.prototype.hasOwnProperty.call(obj, field) && obj[field] != null && obj[field] !== '';
222
+ }
223
+
224
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
225
+
226
+ function parseArtifactFrontmatter(text) {
227
+ const m = text.match(FRONTMATTER_RE);
228
+ if (!m) return { frontmatter: {}, body: text };
229
+ const fm = {};
230
+ for (const line of m[1].split('\n')) {
231
+ if (!line.trim() || line.trim().startsWith('#')) continue;
232
+ const idx = line.indexOf(':');
233
+ if (idx === -1) continue;
234
+ const key = line.slice(0, idx).trim();
235
+ const rawValue = line.slice(idx + 1).trim();
236
+ if (key) fm[key] = rawValue.replace(/^["']|["']$/g, '');
237
+ }
238
+ return { frontmatter: fm, body: text.slice(m[0].length) };
239
+ }
240
+
241
+ function hasSection(body, sectionTitle) {
242
+ const target = sectionTitle.trim().toLowerCase();
243
+ const headingRe = /^#{1,6}\s+(.+)$/gm;
244
+ let m;
245
+ while ((m = headingRe.exec(body))) {
246
+ if (m[1].trim().toLowerCase() === target) return true;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ function claimsHaveCitations(body) {
252
+ const lines = body.split('\n');
253
+ const offenders = [];
254
+ let inFence = false;
255
+ let inTable = false;
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const line = lines[i];
258
+ if (/^```/.test(line)) { inFence = !inFence; continue; }
259
+ if (inFence) continue;
260
+ inTable = /^\s*\|.*\|\s*$/.test(line);
261
+ if (inTable) continue;
262
+ if (/\b\d{1,3}\s*%/.test(line) || /\b\d{2,}x\b/i.test(line)) {
263
+ const window = [lines[i - 1] || '', line, lines[i + 1] || ''].join('\n');
264
+ if (!/\[source:|\bhttps?:\/\/|\[\^\d+\]/i.test(window)) {
265
+ offenders.push({ line: i + 1, snippet: line.trim().slice(0, 80) });
266
+ }
267
+ }
268
+ }
269
+ return offenders;
270
+ }
271
+
272
+ /**
273
+ * Run structured postcondition checks against a handoff's output artifact on
274
+ * disk. Postconditions can be strings (descriptive, ignored here) or objects
275
+ * with { id, check, field?, section? }. Returns an array of error strings.
276
+ */
277
+ export function validatePostconditions({ contract, artifactPath }) {
278
+ if (!contract?.postconditions?.length) return [];
279
+ if (!artifactPath || !existsSync(artifactPath)) {
280
+ const structured = contract.postconditions.filter((p) => typeof p === 'object');
281
+ if (structured.length === 0) return [];
282
+ return [`postcondition checks declared but artifact not found on disk: ${artifactPath}`];
283
+ }
284
+ const text = readFileSync(artifactPath, 'utf8');
285
+ const { frontmatter, body } = parseArtifactFrontmatter(text);
286
+ const errors = [];
287
+ for (const pc of contract.postconditions) {
288
+ if (typeof pc !== 'object') continue;
289
+ const tag = pc.id || pc.check;
290
+ switch (pc.check) {
291
+ case 'artifact-has-frontmatter-field': {
292
+ if (!pc.field) {
293
+ errors.push(`[${tag}] postcondition missing 'field' for artifact-has-frontmatter-field`);
294
+ break;
295
+ }
296
+ if (!frontmatter[pc.field]) {
297
+ errors.push(`[${tag}] artifact missing frontmatter field: ${pc.field}`);
298
+ }
299
+ break;
300
+ }
301
+ case 'artifact-has-section': {
302
+ if (!pc.section) {
303
+ errors.push(`[${tag}] postcondition missing 'section' for artifact-has-section`);
304
+ break;
305
+ }
306
+ if (!hasSection(body, pc.section)) {
307
+ errors.push(`[${tag}] artifact missing required section: "${pc.section}"`);
308
+ }
309
+ break;
310
+ }
311
+ case 'artifact-claims-cited': {
312
+ const offenders = claimsHaveCitations(body);
313
+ for (const o of offenders) {
314
+ errors.push(`[${tag}] uncited numeric claim at line ${o.line}: ${o.snippet}`);
315
+ }
316
+ break;
317
+ }
318
+ default:
319
+ errors.push(`[${tag}] unknown postcondition check: ${pc.check}`);
320
+ }
321
+ }
322
+ return errors;
323
+ }
@@ -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
+ }
@@ -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 exists and is current (< 7 days)
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
- checkExists(path.join(cwd, ".cx", "context.md"), ".cx/context.md", true);
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
- checkContextHasProgress(path.join(cwd, ".cx", "context.md"));
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
  }
@@ -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
  }
@@ -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 = [];