@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,121 @@
1
+ /**
2
+ * lib/intake/daemon.mjs — Continuous intake daemon with safeguards.
3
+ *
4
+ * Polls `.cx/inbox/` for new files, classifies them via the existing intake
5
+ * classifier, and writes packets to `.cx/intake/pending/`. Packets past their
6
+ * TTL move to `.cx/intake/dead-letter/`; failed classification retries up to
7
+ * the budget then dead-letters with the failure reason.
8
+ *
9
+ * Built on lib/daemons/contract.mjs — every safeguard (bounded lifetime,
10
+ * idle shutdown, heartbeat, killswitch, single-writer lock) applies.
11
+ */
12
+
13
+ import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { join, basename } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ import { createDaemon, classifyPacket } from '../daemons/contract.mjs';
18
+
19
+ const KILLSWITCH_ENV = 'CONSTRUCT_INTAKE_DAEMON';
20
+
21
+ function inboxDir(cwd) { return join(cwd, '.cx', 'inbox'); }
22
+ function pendingDir(cwd) { return join(cwd, '.cx', 'intake', 'pending'); }
23
+ function deadLetterDir(cwd) { return join(cwd, '.cx', 'intake', 'dead-letter'); }
24
+ function heartbeatPath() { return join(homedir(), '.construct', 'intake-daemon.heartbeat'); }
25
+
26
+ function ensureDir(dir) {
27
+ try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
28
+ }
29
+
30
+ function listInboxFiles(cwd) {
31
+ const dir = inboxDir(cwd);
32
+ if (!existsSync(dir)) return [];
33
+ let entries;
34
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
35
+ return entries
36
+ .filter((e) => e.isFile() && !e.name.startsWith('.'))
37
+ .map((e) => join(dir, e.name));
38
+ }
39
+
40
+ /**
41
+ * Process one inbox file: classify, persist packet to pending, remove the
42
+ * inbox source on success, or move to dead-letter on persistent failure.
43
+ *
44
+ * Returns one of:
45
+ * { didWork: true, route: 'pending'|'dead-letter', packetId }
46
+ * { didWork: false, reason }
47
+ */
48
+ export async function processInboxFile(filePath, { cwd, classify, now = () => new Date() }) {
49
+ ensureDir(pendingDir(cwd));
50
+ ensureDir(deadLetterDir(cwd));
51
+
52
+ let body;
53
+ try { body = readFileSync(filePath, 'utf8'); }
54
+ catch (err) { return { didWork: false, reason: `read failed: ${err.message}` }; }
55
+
56
+ const id = `intake-${now().getTime()}-${Math.random().toString(36).slice(2, 8)}`;
57
+ const packet = {
58
+ id,
59
+ firstSeenAt: new Date(statSync(filePath).mtimeMs).toISOString(),
60
+ sourcePath: filePath,
61
+ excerpt: body.slice(0, 500),
62
+ attempts: 0,
63
+ };
64
+
65
+ const decision = classifyPacket(packet);
66
+ if (decision.route === 'dead-letter') {
67
+ return persistDeadLetter(filePath, packet, decision.reason, cwd);
68
+ }
69
+
70
+ try {
71
+ packet.triage = await classify({ sourcePath: filePath, extractedText: body });
72
+ } catch (err) {
73
+ packet.attempts++;
74
+ packet.lastError = err.message;
75
+ if (packet.attempts >= 3) {
76
+ return persistDeadLetter(filePath, packet, 'retry-budget-exhausted', cwd);
77
+ }
78
+ return { didWork: false, reason: `classify failed, will retry: ${err.message}` };
79
+ }
80
+
81
+ const packetPath = join(pendingDir(cwd), `${id}.json`);
82
+ writeFileSync(packetPath, JSON.stringify(packet, null, 2));
83
+ try {
84
+ const consumedPath = join(pendingDir(cwd), `.${basename(filePath)}.consumed`);
85
+ renameSync(filePath, consumedPath);
86
+ } catch { /* ignore consume failure */ }
87
+ return { didWork: true, route: 'pending', packetId: id };
88
+ }
89
+
90
+ function persistDeadLetter(filePath, packet, reason, cwd) {
91
+ const dlPath = join(deadLetterDir(cwd), `${packet.id}.json`);
92
+ writeFileSync(dlPath, JSON.stringify({ ...packet, deadLetterReason: reason, deadLetteredAt: new Date().toISOString() }, null, 2));
93
+ try { renameSync(filePath, join(deadLetterDir(cwd), `.source.${basename(filePath)}`)); } catch { /* ignore */ }
94
+ return { didWork: true, route: 'dead-letter', packetId: packet.id, reason };
95
+ }
96
+
97
+ /**
98
+ * Build a DaemonRunner for the intake daemon. Caller is responsible for
99
+ * calling .run() — typically construct intake daemon start.
100
+ */
101
+ export function buildIntakeDaemon({ cwd = process.cwd(), intervalMs = 60_000, classify } = {}) {
102
+ const classifyFn = classify || (async () => ({ intakeType: 'unknown', rdStage: 'triage', primaryOwner: 'product-manager', recommendedAction: 'review' }));
103
+ return createDaemon({
104
+ name: 'intake',
105
+ intervalMs,
106
+ killswitchEnv: KILLSWITCH_ENV,
107
+ heartbeatPath: heartbeatPath(),
108
+ maxRuntimeMs: 24 * 60 * 60 * 1000,
109
+ maxIdleTicks: 6,
110
+ async tick() {
111
+ const files = listInboxFiles(cwd);
112
+ if (files.length === 0) return { didWork: false };
113
+ let workedAny = false;
114
+ for (const file of files) {
115
+ const result = await processInboxFile(file, { cwd, classify: classifyFn });
116
+ if (result.didWork) workedAny = true;
117
+ }
118
+ return { didWork: workedAny };
119
+ },
120
+ });
121
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { ensureCxDir } from '../project-init-shared.mjs';
13
14
 
14
15
  const QUEUE_SUBDIR = '.cx/intake';
15
16
 
@@ -55,6 +56,7 @@ export class FilesystemIntakeQueue {
55
56
  enqueue(entry) {
56
57
  if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
57
58
  const dir = pendingDir(this.rootDir);
59
+ ensureCxDir(this.rootDir);
58
60
  mkdirSync(dir, { recursive: true });
59
61
 
60
62
  const ts = timestamp();
@@ -112,6 +114,7 @@ export class FilesystemIntakeQueue {
112
114
  if (notes) data.notes = notes;
113
115
 
114
116
  const dst = path.join(processedDir(this.rootDir), `${id}.json`);
117
+ ensureCxDir(this.rootDir);
115
118
  mkdirSync(path.dirname(dst), { recursive: true });
116
119
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
117
120
  rmSync(src);
@@ -128,6 +131,7 @@ export class FilesystemIntakeQueue {
128
131
  if (reason) data.reason = reason;
129
132
 
130
133
  const dst = path.join(skippedDir(this.rootDir), `${id}.json`);
134
+ ensureCxDir(this.rootDir);
131
135
  mkdirSync(path.dirname(dst), { recursive: true });
132
136
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
133
137
  rmSync(src);
@@ -148,7 +152,8 @@ export class FilesystemIntakeQueue {
148
152
  delete data.reason;
149
153
 
150
154
  const dst = path.join(pendingDir(this.rootDir), `${id}.json`);
151
- mkdirSync(path.dirname(dst), { recursive: true });
155
+ ensureCxDir(this.rootDir);
156
+ mkdirSync(path.dirname(dst), { recursive: true });
152
157
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
153
158
  rmSync(src);
154
159
  return { id, filePath: dst, from: path.basename(dir) };
@@ -24,6 +24,7 @@
24
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
25
  import { isAbsolute, join, resolve } from 'node:path';
26
26
  import { shouldCreateCx } from '../project-detection.mjs';
27
+ import { ensureCxDir } from '../project-init-shared.mjs';
27
28
 
28
29
  export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';
29
30
  export const INTAKE_DEFAULT_MAX_DEPTH = 4;
@@ -115,7 +116,7 @@ export function saveIntakeConfig(rootDir, patch = {}) {
115
116
  };
116
117
 
117
118
  const file = intakeConfigPath(rootDir);
118
- mkdirSync(join(rootDir, '.cx'), { recursive: true });
119
+ ensureCxDir(rootDir);
119
120
  writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
120
121
  return next;
121
122
  }
@@ -77,7 +77,6 @@ export async function prepareIntakeForIngestedFile({
77
77
  extractedText: extracted,
78
78
  related,
79
79
  profile: activeProfile?.id,
80
- cwd: rootDir,
81
80
  });
82
81
 
83
82
  // Detect customer mentions and update profiles
@@ -13,6 +13,7 @@
13
13
  import { createIntakeQueue } from './queue.mjs';
14
14
  import { formatTriageLine } from './classify.mjs';
15
15
  import { isBrokered } from '../mcp/broker.mjs';
16
+ import { getRebrand } from '../profiles/rebrand.mjs';
16
17
 
17
18
  export function buildIntakePrelude({ cwd, env = process.env } = {}) {
18
19
  if (!cwd) return '';
@@ -20,11 +21,16 @@ export function buildIntakePrelude({ cwd, env = process.env } = {}) {
20
21
  const queue = createIntakeQueue(cwd, env);
21
22
  const pending = queue.listPending();
22
23
  if (!pending.length) return '';
24
+ const { intakeQueueLabel, signalNoun } = getRebrand(cwd);
23
25
  const recent = pending.slice(-3).map((p) => {
24
26
  const src = p.intake?.sourcePath || p.id;
25
27
  return `- ${formatTriageLine(src, p.triage)}`;
26
28
  });
27
- return `\n## Pending R&D intake (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new signal, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
29
+ // Strip a trailing " queue" / " intake" so the heading reads
30
+ // "## Pending R&D intake (N)" rather than the redundant
31
+ // "## Pending R&D intake queue queue (N)".
32
+ const heading = intakeQueueLabel.replace(/\s+queue$/i, '');
33
+ return `\n## Pending ${heading} (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new ${signalNoun}, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
28
34
  } catch {
29
35
  return '';
30
36
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lib/intake/traceability.mjs — stamps intake provenance into artifact frontmatter.
3
+ *
4
+ * Backs `construct intake done <id> --output=<path>`: reads the intake packet,
5
+ * extracts (id, confidence, rationale), and writes them into the artifact's
6
+ * YAML frontmatter. Refuses to overwrite a different intake_id (an artifact
7
+ * can be linked to exactly one intake source). Idempotent on re-stamping.
8
+ *
9
+ * Read by docs:verify checkIntakeTraceability to surface artifacts in
10
+ * intake-fed locations that lack a reference.
11
+ *
12
+ * Field schema (in artifact YAML frontmatter):
13
+ * intake_id: construct-xxx
14
+ * intake_confidence: 0.7
15
+ * intake_rationale: "Matched 3 keywords: dashboard, latency, p95"
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
19
+
20
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
21
+
22
+ export function parseArtifactFrontmatter(content) {
23
+ const m = content.match(FRONTMATTER_RE);
24
+ if (!m) return { frontmatter: {}, body: content, hasBlock: false };
25
+ const out = {};
26
+ for (const line of m[1].split('\n')) {
27
+ if (!line.trim() || line.trim().startsWith('#')) continue;
28
+ const idx = line.indexOf(':');
29
+ if (idx === -1) continue;
30
+ const key = line.slice(0, idx).trim();
31
+ const rawValue = line.slice(idx + 1).trim();
32
+ if (!key) continue;
33
+ const unquoted = rawValue.replace(/^["']|["']$/g, '');
34
+ if (/^-?\d+(\.\d+)?$/.test(unquoted)) out[key] = Number(unquoted);
35
+ else out[key] = unquoted;
36
+ }
37
+ return { frontmatter: out, body: content.slice(m[0].length), hasBlock: true };
38
+ }
39
+
40
+ function serializeFrontmatter(fm) {
41
+ const lines = ['---'];
42
+ for (const [k, v] of Object.entries(fm)) {
43
+ if (v === null || v === undefined) continue;
44
+ if (typeof v === 'number' || typeof v === 'boolean') {
45
+ lines.push(`${k}: ${v}`);
46
+ } else {
47
+ const s = String(v);
48
+ const needsQuoting = /[:#"'\n]|^\s|\s$/.test(s);
49
+ lines.push(`${k}: ${needsQuoting ? JSON.stringify(s) : s}`);
50
+ }
51
+ }
52
+ lines.push('---');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ export function stampIntakeProvenance(artifactPath, { intakeId, confidence, rationale }) {
57
+ if (!existsSync(artifactPath)) {
58
+ throw new Error(`Output artifact not found: ${artifactPath}`);
59
+ }
60
+ if (!intakeId) throw new Error('intakeId required for stamping');
61
+ const content = readFileSync(artifactPath, 'utf8');
62
+ const { frontmatter, body, hasBlock } = parseArtifactFrontmatter(content);
63
+
64
+ const existing = frontmatter.intake_id;
65
+ if (existing && existing !== intakeId) {
66
+ throw new Error(
67
+ `Refusing to overwrite intake_id: artifact already references ${existing}, not ${intakeId}. Resolve manually or supersede.`,
68
+ );
69
+ }
70
+
71
+ const next = {
72
+ ...frontmatter,
73
+ intake_id: intakeId,
74
+ ...(confidence !== undefined && confidence !== null ? { intake_confidence: confidence } : {}),
75
+ ...(rationale ? { intake_rationale: rationale } : {}),
76
+ };
77
+
78
+ const fmText = serializeFrontmatter(next);
79
+ const out = hasBlock ? `${fmText}\n${body}` : `${fmText}\n\n${content}`;
80
+ writeFileSync(artifactPath, out, 'utf8');
81
+ return { intake_id: intakeId, intake_confidence: next.intake_confidence, intake_rationale: next.intake_rationale };
82
+ }
83
+
84
+ export function hasIntakeReference(artifactPath) {
85
+ if (!existsSync(artifactPath)) return false;
86
+ const { frontmatter } = parseArtifactFrontmatter(readFileSync(artifactPath, 'utf8'));
87
+ if (frontmatter.intake_id) return true;
88
+ if (frontmatter.intake === 'none') return true;
89
+ return false;
90
+ }
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { syncFileStateToSql } from '../storage/sync.mjs';
21
21
  import { resolveActiveProfile } from '../profiles/loader.mjs';
22
+ import { ensureCxDir } from '../project-init-shared.mjs';
22
23
 
23
24
  const ROOT = '.cx/knowledge/external/research';
24
25
  const MAX_BYTES = 50 * 1024;
@@ -68,6 +69,7 @@ export async function addResearchFinding({
68
69
  const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
69
70
 
70
71
  const outDir = join(cwd, ROOT);
72
+ ensureCxDir(cwd);
71
73
  mkdirSync(outDir, { recursive: true });
72
74
  const outPath = join(outDir, `${slug}.md`);
73
75
 
@@ -0,0 +1,315 @@
1
+ /**
2
+ * lib/maintenance/cleanup.mjs — Construct's self-maintenance primitives.
3
+ *
4
+ * Every artifact Construct writes has a lifecycle. This module collects the
5
+ * primitives that enforce that lifecycle: rotate oversized logs, prune
6
+ * rotated segments, age-out cache directories. Each primitive is pure and
7
+ * returns a structured summary; runFullCleanup orchestrates them with sane
8
+ * defaults.
9
+ *
10
+ * Triggered from two entry points:
11
+ * - `construct cleanup` — manual sweep, optionally --dry-run
12
+ * - bin/construct startup check — automatic when the installed version
13
+ * differs from ~/.construct/.cleanup-stamp,
14
+ * so an upgrade reclaims disk without
15
+ * requiring a manual command.
16
+ *
17
+ * Scope is deliberately narrow: only artifacts whose retention is owned by
18
+ * the runtime (logs, caches, rotated segments). Knowledge stores
19
+ * (.cx/observations/, .cx/handoffs/, .cx/intake/) require explicit retention
20
+ * policy and are left for a follow-up after policy is agreed.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ const HARD_BUDGET_MS = 5000;
28
+
29
+ // Size caps in bytes. Each entry: [filename relative to ~/.cx/, maxBytes].
30
+ // Files that exceed maxBytes are truncated (head-preserving rotation is not
31
+ // useful for append-only event logs — the most-recent events are what matter,
32
+ // not the oldest; tail-preserving truncation keeps the last N% by recopying).
33
+ const JSONL_LOG_CAPS_BYTES = {
34
+ 'audit.jsonl': 25 * 1024 * 1024,
35
+ 'contract-violations.jsonl': 25 * 1024 * 1024,
36
+ 'doctor-log.jsonl': 25 * 1024 * 1024,
37
+ 'role-pending.jsonl': 5 * 1024 * 1024,
38
+ 'role-events.jsonl': 25 * 1024 * 1024,
39
+ };
40
+
41
+ const EMBED_LOG_DEFAULT_MAX_MB = 50;
42
+ const EMBED_LOG_DEFAULT_KEEP = 3;
43
+ const EMBED_LOG_HARD_CAP_MAX_MB = 500;
44
+ const EMBED_LOG_HARD_CAP_KEEP = 20;
45
+
46
+ function fileSizeOrZero(p) {
47
+ try { return fs.statSync(p).size; } catch { return 0; }
48
+ }
49
+
50
+ function rmSafe(p, dryRun) {
51
+ if (dryRun) return;
52
+ try { fs.rmSync(p, { force: true }); } catch (err) {
53
+ process.stderr.write(`[cleanup] failed to remove ${p}: ${err.message}\n`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Tail-preserving truncate: keeps the last `keepBytes` of `file`, drops the
59
+ * rest. Used for append-only event logs where recent events are valuable but
60
+ * historical bloat is not.
61
+ */
62
+ function tailTruncate(file, keepBytes, dryRun) {
63
+ if (dryRun) return;
64
+ let stat;
65
+ try { stat = fs.statSync(file); } catch { return; }
66
+ if (stat.size <= keepBytes) return;
67
+ const start = stat.size - keepBytes;
68
+ const buf = Buffer.alloc(keepBytes);
69
+ const fd = fs.openSync(file, 'r');
70
+ try { fs.readSync(fd, buf, 0, keepBytes, start); } finally { fs.closeSync(fd); }
71
+ // Find the first newline so we don't start mid-record.
72
+ const nl = buf.indexOf(0x0a);
73
+ const payload = nl >= 0 && nl < buf.length - 1 ? buf.subarray(nl + 1) : buf;
74
+ fs.writeFileSync(file, payload);
75
+ }
76
+
77
+ function rotationConfig(env = process.env) {
78
+ const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
79
+ const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
80
+ const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, EMBED_LOG_HARD_CAP_MAX_MB) : EMBED_LOG_DEFAULT_MAX_MB;
81
+ const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, EMBED_LOG_HARD_CAP_KEEP) : EMBED_LOG_DEFAULT_KEEP;
82
+ return { maxBytes: maxMb * 1024 * 1024, keep };
83
+ }
84
+
85
+ /**
86
+ * Walk the embed log + every rotated segment (.1, .2, ...) and drop the ones
87
+ * that exceed the per-segment cap OR sit beyond the keep count.
88
+ *
89
+ * Unlike rotateEmbedLogIfNeeded in lib/embed/cli.mjs (which is called at
90
+ * daemon spawn time and only rotates the live log), this is the post-upgrade
91
+ * cleanup: it handles the case where an existing install has segments that
92
+ * were oversized before the cap existed.
93
+ */
94
+ export function cleanupEmbedLog({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
95
+ const dir = path.join(homeDir, '.cx', 'runtime');
96
+ const baseLog = path.join(dir, 'embed-daemon.log');
97
+ const { maxBytes, keep } = rotationConfig(env);
98
+
99
+ const summary = { removed: [], truncated: [], freedBytes: 0, baseLog };
100
+
101
+ // Every existing segment (.log, .log.1, .log.2, ...)
102
+ if (!fs.existsSync(dir)) return summary;
103
+ const entries = fs.readdirSync(dir).filter(n => n === 'embed-daemon.log' || n.startsWith('embed-daemon.log.'));
104
+
105
+ for (const name of entries) {
106
+ const full = path.join(dir, name);
107
+ const size = fileSizeOrZero(full);
108
+
109
+ // Drop segments beyond the keep horizon entirely.
110
+ const dotMatch = name.match(/^embed-daemon\.log\.(\d+)$/);
111
+ if (dotMatch && Number.parseInt(dotMatch[1], 10) > keep) {
112
+ summary.removed.push({ path: full, size });
113
+ summary.freedBytes += size;
114
+ rmSafe(full, dryRun);
115
+ continue;
116
+ }
117
+
118
+ // The live log: if oversized, truncate to 0 (it will rotate cleanly on next
119
+ // daemon spawn via rotateEmbedLogIfNeeded). Truncating in place keeps any
120
+ // open daemon FDs valid (writes resume at byte 0 on next O_APPEND write).
121
+ if (name === 'embed-daemon.log' && size > maxBytes) {
122
+ summary.truncated.push({ path: full, sizeWas: size });
123
+ summary.freedBytes += size;
124
+ if (!dryRun) {
125
+ try {
126
+ fs.truncateSync(full, 0);
127
+ } catch (err) {
128
+ process.stderr.write(`[cleanup] failed to truncate ${full}: ${err.message}\n`);
129
+ }
130
+ }
131
+ continue;
132
+ }
133
+
134
+ // Rotated segments that are individually oversized: drop them. They are
135
+ // archived noise; keeping them around defeats the cap.
136
+ if (dotMatch && size > maxBytes) {
137
+ summary.removed.push({ path: full, size });
138
+ summary.freedBytes += size;
139
+ rmSafe(full, dryRun);
140
+ }
141
+ }
142
+
143
+ return summary;
144
+ }
145
+
146
+ /**
147
+ * Apply per-file size caps to the user-level JSONL event logs at ~/.cx/.
148
+ * Truncates oldest events rather than removing the whole file so the most
149
+ * recent records survive.
150
+ */
151
+ export function cleanupJsonlLogs({ homeDir = os.homedir(), dryRun = false } = {}) {
152
+ const dir = path.join(homeDir, '.cx');
153
+ const summary = { truncated: [], freedBytes: 0 };
154
+ if (!fs.existsSync(dir)) return summary;
155
+
156
+ for (const [name, capBytes] of Object.entries(JSONL_LOG_CAPS_BYTES)) {
157
+ const file = path.join(dir, name);
158
+ const size = fileSizeOrZero(file);
159
+ if (size <= capBytes) continue;
160
+ // Retain the most recent half-cap so headroom exists before next truncate.
161
+ const keepBytes = Math.floor(capBytes / 2);
162
+ summary.truncated.push({ path: file, sizeWas: size, sizeNow: keepBytes });
163
+ summary.freedBytes += size - keepBytes;
164
+ tailTruncate(file, keepBytes, dryRun);
165
+ }
166
+
167
+ return summary;
168
+ }
169
+
170
+ /**
171
+ * Age out ~/.construct/cache/ entries older than `maxAgeDays`. Cache hits are
172
+ * idempotent and re-fetched on demand, so pruning is safe.
173
+ */
174
+ export function cleanupCacheDir({ homeDir = os.homedir(), maxAgeDays = 30, dryRun = false } = {}) {
175
+ const dir = path.join(homeDir, '.construct', 'cache');
176
+ const summary = { removed: [], freedBytes: 0 };
177
+ if (!fs.existsSync(dir)) return summary;
178
+
179
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
180
+ const walk = (current) => {
181
+ let entries;
182
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
183
+ for (const entry of entries) {
184
+ const full = path.join(current, entry.name);
185
+ if (entry.isDirectory()) { walk(full); continue; }
186
+ let stat;
187
+ try { stat = fs.statSync(full); } catch { continue; }
188
+ if (stat.mtimeMs < cutoff) {
189
+ summary.removed.push({ path: full, size: stat.size });
190
+ summary.freedBytes += stat.size;
191
+ rmSafe(full, dryRun);
192
+ }
193
+ }
194
+ };
195
+ walk(dir);
196
+ return summary;
197
+ }
198
+
199
+ /**
200
+ * Orchestrator. Runs each primitive under a hard time budget and returns an
201
+ * aggregated summary. Individual failures are non-fatal: a primitive that
202
+ * throws is recorded as an error in the summary but does not block the rest.
203
+ */
204
+ export function runFullCleanup({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
205
+ const startedAt = Date.now();
206
+ const summary = {
207
+ dryRun,
208
+ startedAt: new Date(startedAt).toISOString(),
209
+ durationMs: 0,
210
+ freedBytes: 0,
211
+ embedLog: null,
212
+ jsonlLogs: null,
213
+ cacheDir: null,
214
+ errors: [],
215
+ };
216
+
217
+ const safelyRun = (label, fn) => {
218
+ if (Date.now() - startedAt > HARD_BUDGET_MS) {
219
+ summary.errors.push({ step: label, error: 'time budget exceeded; skipped' });
220
+ return null;
221
+ }
222
+ try { return fn(); }
223
+ catch (err) { summary.errors.push({ step: label, error: err.message }); return null; }
224
+ };
225
+
226
+ summary.embedLog = safelyRun('embedLog', () => cleanupEmbedLog({ homeDir, env, dryRun }));
227
+ summary.jsonlLogs = safelyRun('jsonlLogs', () => cleanupJsonlLogs({ homeDir, dryRun }));
228
+ summary.cacheDir = safelyRun('cacheDir', () => cleanupCacheDir({ homeDir, dryRun }));
229
+
230
+ for (const part of [summary.embedLog, summary.jsonlLogs, summary.cacheDir]) {
231
+ if (part?.freedBytes) summary.freedBytes += part.freedBytes;
232
+ }
233
+ summary.durationMs = Date.now() - startedAt;
234
+ return summary;
235
+ }
236
+
237
+ /**
238
+ * Format bytes for one-line operator output. 1234567 → "1.2 MB".
239
+ */
240
+ export function formatBytes(bytes) {
241
+ if (!bytes || bytes < 0) return '0 B';
242
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
243
+ let n = bytes;
244
+ let unit = 0;
245
+ while (n >= 1024 && unit < units.length - 1) { n /= 1024; unit += 1; }
246
+ return `${n.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Version stamp — drives automatic cleanup on upgrade.
251
+ //
252
+ // ~/.construct/.cleanup-stamp records the last version that ran cleanup.
253
+ // On every CLI invocation, bin/construct compares the current package
254
+ // version to the stamp. A mismatch triggers a single cleanup pass and
255
+ // writes the new version into the stamp. The first ever invocation also
256
+ // triggers cleanup (no stamp present) — this is what unblocks existing
257
+ // installs that already have 34 GB logs sitting around.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ const STAMP_RELATIVE = path.join('.construct', '.cleanup-stamp');
261
+
262
+ export function stampPath(homeDir = os.homedir()) {
263
+ return path.join(homeDir, STAMP_RELATIVE);
264
+ }
265
+
266
+ export function readVersionStamp({ homeDir = os.homedir() } = {}) {
267
+ const p = stampPath(homeDir);
268
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
269
+ catch { return null; }
270
+ }
271
+
272
+ export function writeVersionStamp({ homeDir = os.homedir(), version, summary } = {}) {
273
+ const p = stampPath(homeDir);
274
+ fs.mkdirSync(path.dirname(p), { recursive: true });
275
+ const payload = {
276
+ version,
277
+ ranAt: new Date().toISOString(),
278
+ freedBytes: summary?.freedBytes ?? 0,
279
+ durationMs: summary?.durationMs ?? 0,
280
+ };
281
+ fs.writeFileSync(p, JSON.stringify(payload, null, 2));
282
+ }
283
+
284
+ /**
285
+ * Run cleanup if and only if the installed version differs from the stamp.
286
+ * Returns { ran: bool, reason, summary?, previousVersion? }.
287
+ *
288
+ * Designed to be called at CLI startup. Never throws — cleanup failures are
289
+ * surfaced via the returned summary and never block the actual command.
290
+ */
291
+ export function maybeRunCleanupOnUpgrade({
292
+ homeDir = os.homedir(),
293
+ env = process.env,
294
+ currentVersion,
295
+ force = false,
296
+ } = {}) {
297
+ if (env.CONSTRUCT_DISABLE_AUTO_CLEANUP === '1') {
298
+ return { ran: false, reason: 'disabled via CONSTRUCT_DISABLE_AUTO_CLEANUP' };
299
+ }
300
+ const stamp = readVersionStamp({ homeDir });
301
+ if (!force && stamp && stamp.version === currentVersion) {
302
+ return { ran: false, reason: 'version matches stamp' };
303
+ }
304
+ let summary;
305
+ try { summary = runFullCleanup({ homeDir, env, dryRun: false }); }
306
+ catch (err) { return { ran: false, reason: `cleanup threw: ${err.message}` }; }
307
+ try { writeVersionStamp({ homeDir, version: currentVersion, summary }); }
308
+ catch (err) { /* stamp write failure is non-fatal */ void err; }
309
+ return {
310
+ ran: true,
311
+ reason: stamp ? 'version changed' : 'first run',
312
+ previousVersion: stamp?.version ?? null,
313
+ summary,
314
+ };
315
+ }