@geraldmaron/construct 1.0.6 → 1.0.8

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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -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
+ }
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/mcp/memory-bridge.mjs — stdio MCP bridge for cass-memory (cm).
4
+ *
5
+ * Editor MCP clients (OpenCode, Claude Code) expect a host to satisfy the MCP
6
+ * HTTP+SSE handshake — GET with `Accept: text/event-stream`, POST `initialize`,
7
+ * `notifications/initialized`, `ping`. cass-memory v0.2.x ships an HTTP server
8
+ * that answers `tools/list`, `tools/call`, `resources/list`, `resources/read`
9
+ * over POST but rejects the handshake calls (405 on GET, "Unsupported method"
10
+ * on POST initialize/ping). That single gap surfaces in editors as
11
+ * "memory SSE error: Non-200 status code (405)" and the memory tools never
12
+ * register.
13
+ *
14
+ * Sits between editor and cm. Speaks the canonical 2026 MCP stdio transport
15
+ * (newline-delimited JSON-RPC on stdin/stdout, logs on stderr), answers the
16
+ * handshake natively, and forwards real work (tools/resources) to cm over its
17
+ * existing HTTP API. Stays up across cm restarts; surfaces backend outages as
18
+ * JSON-RPC `-32603` errors rather than crashing, so the editor can retry
19
+ * without losing the channel.
20
+ *
21
+ * Env:
22
+ * CONSTRUCT_MEMORY_BRIDGE_URL cm base URL (default http://127.0.0.1:8765/)
23
+ * CONSTRUCT_MEMORY_BRIDGE_AUTOSTART set to 1 to spawn `cm serve` if unreachable
24
+ * CONSTRUCT_MEMORY_BRIDGE_TIMEOUT_MS per-request timeout (default 5000)
25
+ *
26
+ * Stdout is reserved for protocol frames. All diagnostic output goes to stderr.
27
+ */
28
+
29
+ import { createInterface } from 'node:readline';
30
+ import { spawn } from 'node:child_process';
31
+ import { readFileSync } from 'node:fs';
32
+ import { dirname, join } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ const PROTOCOL_VERSION = '2024-11-05';
36
+ const SERVER_NAME = 'construct-memory-bridge';
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = dirname(__filename);
40
+
41
+ function readPackageVersion() {
42
+ try {
43
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
44
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
45
+ } catch {
46
+ return '0.0.0';
47
+ }
48
+ }
49
+
50
+ const SERVER_VERSION = readPackageVersion();
51
+
52
+ function normalizeBackendUrl(raw) {
53
+ const value = (raw && String(raw).trim()) || 'http://127.0.0.1:8765/';
54
+ return value.endsWith('/') ? value : `${value}/`;
55
+ }
56
+
57
+ const BACKEND_URL = normalizeBackendUrl(process.env.CONSTRUCT_MEMORY_BRIDGE_URL);
58
+ const REQUEST_TIMEOUT_MS = (() => {
59
+ const raw = Number(process.env.CONSTRUCT_MEMORY_BRIDGE_TIMEOUT_MS);
60
+ return Number.isFinite(raw) && raw > 0 ? raw : 5000;
61
+ })();
62
+ const AUTOSTART = process.env.CONSTRUCT_MEMORY_BRIDGE_AUTOSTART === '1';
63
+
64
+ const FORWARDED_METHODS = new Set([
65
+ 'tools/list',
66
+ 'tools/call',
67
+ 'resources/list',
68
+ 'resources/read',
69
+ 'prompts/list',
70
+ 'prompts/get',
71
+ ]);
72
+
73
+ function log(...parts) {
74
+ process.stderr.write(`[memory-bridge] ${parts.join(' ')}\n`);
75
+ }
76
+
77
+ function writeFrame(frame) {
78
+ process.stdout.write(`${JSON.stringify(frame)}\n`);
79
+ }
80
+
81
+ function jsonRpcResult(id, result) {
82
+ return { jsonrpc: '2.0', id, result };
83
+ }
84
+
85
+ function jsonRpcError(id, code, message, data) {
86
+ const err = { code, message };
87
+ if (data !== undefined) err.data = data;
88
+ return { jsonrpc: '2.0', id, error: err };
89
+ }
90
+
91
+ // cm forwarding uses fetch with an AbortController so a stuck backend never
92
+ // strands the editor. Failed call returns a JSON-RPC -32603 error frame, not a
93
+ // thrown exception, so the bridge process stays available for retries.
94
+
95
+ async function forwardToBackend(method, params, id) {
96
+ const controller = new AbortController();
97
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
98
+ try {
99
+ const body = { jsonrpc: '2.0', id: id ?? 0, method, params: params ?? {} };
100
+ const res = await fetch(BACKEND_URL, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
103
+ body: JSON.stringify(body),
104
+ signal: controller.signal,
105
+ });
106
+ if (!res.ok) {
107
+ return jsonRpcError(
108
+ id,
109
+ -32603,
110
+ `memory backend returned HTTP ${res.status} for ${method}`,
111
+ { backend: BACKEND_URL, status: res.status },
112
+ );
113
+ }
114
+ const text = await res.text();
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(text);
118
+ } catch {
119
+ return jsonRpcError(id, -32603, `memory backend returned non-JSON for ${method}`, { backend: BACKEND_URL });
120
+ }
121
+ if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'error')) {
122
+ return { jsonrpc: '2.0', id, error: parsed.error };
123
+ }
124
+ return jsonRpcResult(id, parsed && parsed.result !== undefined ? parsed.result : parsed);
125
+ } catch (err) {
126
+ const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || String(err));
127
+ return jsonRpcError(id, -32603, `memory backend unreachable at ${BACKEND_URL} (${reason})`, { backend: BACKEND_URL });
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+
133
+ // Autostart only fires when CONSTRUCT_MEMORY_BRIDGE_AUTOSTART=1. Detached
134
+ // spawn keeps cm alive after the editor disconnects.
135
+
136
+ let autostartAttempted = false;
137
+
138
+ function maybeAutostartCm() {
139
+ if (!AUTOSTART || autostartAttempted) return;
140
+ autostartAttempted = true;
141
+ try {
142
+ const port = (() => {
143
+ try {
144
+ const u = new URL(BACKEND_URL);
145
+ return u.port || '8765';
146
+ } catch {
147
+ return '8765';
148
+ }
149
+ })();
150
+ const child = spawn('cm', ['serve', '--port', String(port)], {
151
+ detached: true,
152
+ stdio: 'ignore',
153
+ });
154
+ child.unref();
155
+ log(`autostart: spawned cm serve --port ${port}`);
156
+ } catch (err) {
157
+ log(`autostart failed: ${err?.message || err}`);
158
+ }
159
+ }
160
+
161
+ // Handshake methods resolve natively. cm cannot answer them; forwarding would
162
+ // return the same "Unsupported method" surface that triggered this bug.
163
+
164
+ function handleInitialize(id) {
165
+ return jsonRpcResult(id, {
166
+ protocolVersion: PROTOCOL_VERSION,
167
+ capabilities: { tools: {}, resources: {} },
168
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
169
+ });
170
+ }
171
+
172
+ function handlePing(id) {
173
+ return jsonRpcResult(id, {});
174
+ }
175
+
176
+ async function handleShutdown(id) {
177
+ try { process.stderr.write(''); } catch { /* stderr already closed */ }
178
+ if (id !== undefined && id !== null) writeFrame(jsonRpcResult(id, null));
179
+ setImmediate(() => process.exit(0));
180
+ }
181
+
182
+ async function dispatch(message) {
183
+ if (!message || typeof message !== 'object') return null;
184
+ if (message.jsonrpc !== '2.0') return null;
185
+
186
+ const { id, method, params } = message;
187
+ const isNotification = id === undefined || id === null;
188
+
189
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') {
190
+ return null;
191
+ }
192
+ if (method === 'initialize') return handleInitialize(id);
193
+ if (method === 'ping') return handlePing(id);
194
+ if (method === 'shutdown') return handleShutdown(id);
195
+
196
+ if (FORWARDED_METHODS.has(method)) {
197
+ maybeAutostartCm();
198
+ return forwardToBackend(method, params, id);
199
+ }
200
+
201
+ if (isNotification) return null;
202
+ return jsonRpcError(id, -32601, `Method not found: ${method}`);
203
+ }
204
+
205
+ async function handleLine(line) {
206
+ const trimmed = line.trim();
207
+ if (!trimmed) return;
208
+ let message;
209
+ try {
210
+ message = JSON.parse(trimmed);
211
+ } catch (err) {
212
+ writeFrame(jsonRpcError(null, -32700, `Parse error: ${err?.message || err}`));
213
+ return;
214
+ }
215
+ try {
216
+ const response = await dispatch(message);
217
+ if (response) writeFrame(response);
218
+ } catch (err) {
219
+ if (message && message.id !== undefined && message.id !== null) {
220
+ writeFrame(jsonRpcError(message.id, -32603, `Internal bridge error: ${err?.message || err}`));
221
+ } else {
222
+ log(`unhandled error on notification: ${err?.message || err}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ export function startBridge({ input = process.stdin, output = process.stdout } = {}) {
228
+ if (output !== process.stdout) {
229
+ const writer = (frame) => output.write(`${JSON.stringify(frame)}\n`);
230
+ return runLoop(input, writer);
231
+ }
232
+ return runLoop(input, (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`));
233
+ }
234
+
235
+ function runLoop(input, writer) {
236
+ const rl = createInterface({ input, crlfDelay: Infinity });
237
+ rl.on('line', async (line) => {
238
+ const trimmed = line.trim();
239
+ if (!trimmed) return;
240
+ let message;
241
+ try {
242
+ message = JSON.parse(trimmed);
243
+ } catch (err) {
244
+ writer(jsonRpcError(null, -32700, `Parse error: ${err?.message || err}`));
245
+ return;
246
+ }
247
+ try {
248
+ const response = await dispatch(message);
249
+ if (response) writer(response);
250
+ } catch (err) {
251
+ if (message && message.id !== undefined && message.id !== null) {
252
+ writer(jsonRpcError(message.id, -32603, `Internal bridge error: ${err?.message || err}`));
253
+ } else {
254
+ log(`unhandled error on notification: ${err?.message || err}`);
255
+ }
256
+ }
257
+ });
258
+ rl.on('close', () => process.exit(0));
259
+ return rl;
260
+ }
261
+
262
+ const isMain = (() => {
263
+ try { return import.meta.url === `file://${process.argv[1]}`; } catch { return false; }
264
+ })();
265
+
266
+ if (isMain) {
267
+ log(`starting (backend=${BACKEND_URL}, autostart=${AUTOSTART ? 'on' : 'off'})`);
268
+ startBridge();
269
+ }
270
+
271
+ export const _testing = {
272
+ dispatch,
273
+ handleLine,
274
+ BACKEND_URL,
275
+ SERVER_VERSION,
276
+ };
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { realpathSync } from 'node:fs';
15
15
  import { loadToolkitEnv } from '../toolkit-env.mjs';
16
16
  import { loadConstructEnv } from '../env-config.mjs';
17
+ import { withGenAiSpan, GenAiAttrs, extractTraceContext, injectTraceContext } from '../telemetry/otel-tracer.mjs';
17
18
 
18
19
  // Apply config.env values to process.env, letting config.env win over shell env
19
20
  // so telemetry/OpenRouter credentials are always correct regardless of host env.
@@ -40,6 +41,7 @@ import {
40
41
  import {
41
42
  workflowInit, workflowAddTask, workflowUpdateTask,
42
43
  workflowNeedsMainInput, workflowValidate, workflowImportPlan,
44
+ workflowContractValidate,
43
45
  } from './tools/workflow.mjs';
44
46
  import {
45
47
  cxTrace, cxTraceUpdate, cxScore, sessionUsage, efficiencySnapshot,
@@ -414,11 +416,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
414
416
  },
415
417
  {
416
418
  name: 'broker_check',
417
- description: 'Queries the MCP broker policy gate for a pending action without executing it. Agents call this BEFORE attempting a high-risk action so the response (allowed / approvalRequired / reason / source) can be surfaced to the user in the agent\'s voice rather than triggering a denial after the fact. In solo mode the broker is off by default — returns `brokerActive: false` with `allowed: true` so the call is cheap and agents don\'t waste tokens on an inactive gate. Always emits a `tool.called` trace event for audit-trail parity. Reads agents/role-manifests.json fence rules in team / enterprise mode.',
419
+ description: 'Queries the MCP broker policy gate for a pending action without executing it. Agents call this BEFORE attempting a high-risk action so the response (allowed / approvalRequired / reason / source) can be surfaced to the user in the agent\'s voice rather than triggering a denial after the fact. In solo mode the broker is off by default — returns `brokerActive: false` with `allowed: true` so the call is cheap and agents don\'t waste tokens on an inactive gate. Always emits a `tool.called` trace event for audit-trail parity. Reads specialists/role-manifests.json fence rules in team / enterprise mode.',
418
420
  inputSchema: {
419
421
  type: 'object',
420
422
  properties: {
421
- role: { type: 'string', description: 'Persona name (e.g. `engineer`, `security`). Must match a key in agents/role-manifests.json for team / enterprise mode.' },
423
+ role: { type: 'string', description: 'Persona name (e.g. `engineer`, `security`). Must match a key in specialists/role-manifests.json for team / enterprise mode.' },
422
424
  project: { type: 'string', description: 'Optional project scope for the decision.' },
423
425
  tool: { type: 'string', description: 'The tool the agent wants to invoke (e.g. `github`, `fs`).' },
424
426
  action: { type: 'string', description: 'The action on that tool (e.g. `create_pr`, `edit:lib/foo.mjs`).' },
@@ -430,7 +432,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
430
432
  },
431
433
  {
432
434
  name: 'agent_contract',
433
- description: 'Looks up explicit agent-to-agent service contracts (from agents/contracts.json). Specialists should call this at the start of a handoff to see the expected input shape, preconditions, and what postconditions they must satisfy. Use without args to get all contracts; pass producer/consumer to narrow; pass id for a specific contract.',
435
+ description: 'Looks up explicit agent-to-agent service contracts (from specialists/contracts.json). Specialists should call this at the start of a handoff to see the expected input shape, preconditions, and what postconditions they must satisfy. Use without args to get all contracts; pass producer/consumer to narrow; pass id for a specific contract.',
434
436
  inputSchema: {
435
437
  type: 'object',
436
438
  properties: {
@@ -874,6 +876,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
874
876
  cwd: { type: 'string', description: 'Project root (default: server cwd).' },
875
877
  top_k: { type: 'number', description: 'Max communities to return (default 5).' },
876
878
  min_size: { type: 'number', description: 'Skip communities smaller than this (default 2).' },
879
+ tags: { type: 'array', items: { type: 'string' }, description: 'Restrict entity extraction to observations carrying these tags.' },
880
+ tag_match: { type: 'string', enum: ['any', 'all'], description: 'Tag match mode: any (default) or all.' },
877
881
  },
878
882
  },
879
883
  },
@@ -905,6 +909,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
905
909
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
906
910
  const { name, arguments: args = {} } = request.params;
907
911
 
912
+ // Extract W3C traceparent from params._meta (SEP-414 propagation).
913
+ const parentCtx = await extractTraceContext(request.params?._meta || {});
914
+
908
915
  let result;
909
916
  try {
910
917
  if (name === 'agent_health') result = agentHealth(args);
@@ -934,6 +941,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
934
941
  else if (name === 'workflow_update_task') result = workflowUpdateTask(args);
935
942
  else if (name === 'workflow_needs_main_input') result = workflowNeedsMainInput(args);
936
943
  else if (name === 'workflow_validate') result = workflowValidate(args);
944
+ else if (name === 'workflow_contract_validate') result = await workflowContractValidate(args);
937
945
  else if (name === 'workflow_import_plan') result = workflowImportPlan(args);
938
946
  else if (name === 'list_teams') result = listTeams(opts);
939
947
  else if (name === 'get_team') result = getTeam(args, opts);