@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,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
+ };
@@ -40,6 +40,7 @@ import {
40
40
  import {
41
41
  workflowInit, workflowAddTask, workflowUpdateTask,
42
42
  workflowNeedsMainInput, workflowValidate, workflowImportPlan,
43
+ workflowContractValidate,
43
44
  } from './tools/workflow.mjs';
44
45
  import {
45
46
  cxTrace, cxTraceUpdate, cxScore, sessionUsage, efficiencySnapshot,
@@ -934,6 +935,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
934
935
  else if (name === 'workflow_update_task') result = workflowUpdateTask(args);
935
936
  else if (name === 'workflow_needs_main_input') result = workflowNeedsMainInput(args);
936
937
  else if (name === 'workflow_validate') result = workflowValidate(args);
938
+ else if (name === 'workflow_contract_validate') result = await workflowContractValidate(args);
937
939
  else if (name === 'workflow_import_plan') result = workflowImportPlan(args);
938
940
  else if (name === 'list_teams') result = listTeams(opts);
939
941
  else if (name === 'get_team') result = getTeam(args, opts);
@@ -96,6 +96,31 @@ export function workflowValidate(args) {
96
96
  return { cwd, ...result };
97
97
  }
98
98
 
99
+ /**
100
+ * Validate a producer→consumer handoff against agents/contracts.json.
101
+ *
102
+ * Args:
103
+ * producer — agent or persona name producing the artifact
104
+ * consumer — agent or persona name receiving it
105
+ * id — optional contract id (overrides producer/consumer lookup)
106
+ * artifact — the handoff payload to validate
107
+ * enforcement — 'warn' (default) or 'block'; 'block' returns ok:false on
108
+ * violation so the workflow can refuse to advance
109
+ *
110
+ * Returns { ok, status?, errors?, warnings?, contract } where status is set
111
+ * to 'BLOCKED_CONTRACT' on enforced violations.
112
+ */
113
+ export async function workflowContractValidate(args) {
114
+ const { validateHandoff } = await import('../../contracts/validate.mjs');
115
+ return validateHandoff({
116
+ producer: args.producer,
117
+ consumer: args.consumer,
118
+ id: args.id,
119
+ artifact: args.artifact,
120
+ enforcement: args.enforcement || process.env.CONSTRUCT_CONTRACT_ENFORCEMENT || 'warn',
121
+ });
122
+ }
123
+
99
124
  export function workflowImportPlan(args) {
100
125
  const cwd = args.cwd ? resolve(args.cwd) : process.cwd();
101
126
  const markdown = args.markdown ?? '';
@@ -55,21 +55,25 @@
55
55
  "id": "memory",
56
56
  "name": "Memory",
57
57
  "category": "optional",
58
- "description": "cass-memory over HTTP. Persistent cross-session and cross-agent memory for decisions, findings, and handoffs.",
59
- "type": "url",
60
- "url": "http://127.0.0.1:8765/",
61
- "env": {},
58
+ "description": "cass-memory persistent cross-session and cross-agent memory, fronted by the Construct stdio MCP bridge so editors get a full MCP handshake.",
59
+ "command": "node",
60
+ "args": [
61
+ "__CX_TOOLKIT_DIR__/lib/mcp/memory-bridge.mjs"
62
+ ],
63
+ "env": {
64
+ "CONSTRUCT_MEMORY_BRIDGE_URL": "http://127.0.0.1:__MEMORY_PORT__/"
65
+ },
62
66
  "requiredEnv": [],
63
67
  "setupModes": [
64
68
  "auto",
65
69
  "manual"
66
70
  ],
67
71
  "hostSupport": {
68
- "claude": { "mode": "unsupported", "label": "construct-mcp (project .mcp.json)" },
69
- "opencode": { "mode": "unsupported", "label": "construct-mcp (stdio)" },
70
- "codex": { "mode": "unsupported", "label": "construct-mcp / local observation tools" }
72
+ "claude": { "mode": "managed" },
73
+ "opencode": { "mode": "managed" },
74
+ "codex": { "mode": "managed" }
71
75
  },
72
- "setupNote": "Claude Code and Codex reject the cass-memory HTTP endpoint because `cm serve` skips the MCP `initialize` handshake. On Claude, memory tools (memory_search, memory_add_observations, memory_recent, memory_create_entities) are exposed through the project-local construct-mcp stdio server in .mcp.json. OpenCode does not support the cass-memory HTTP endpoint (SSE transport fails with 405) — use construct-mcp stdio server instead, which wraps memory tools correctly.",
76
+ "setupNote": "Memory is wired as a stdio MCP bridge (lib/mcp/memory-bridge.mjs). The bridge answers the MCP handshake locally (initialize, ping, notifications/initialized) and forwards tools/list, tools/call, resources/list, resources/read to cm at CONSTRUCT_MEMORY_BRIDGE_URL. cm itself never sees the handshake calls that v0.2.x cannot answer.",
73
77
  "usedBy": [
74
78
  "construct",
75
79
  "cx-docs-keeper",
@@ -48,13 +48,21 @@ function buildRemoteHeaders(mcpDef, resolvedValues) {
48
48
  return stripUnresolvedValues(resolveTemplateObject(mcpDef.headers ?? {}, resolvedValues));
49
49
  }
50
50
 
51
+ function withMemoryDefaults(id, values) {
52
+ if (id !== "memory") return values;
53
+ if (values.MEMORY_PORT) return values;
54
+ return { ...values, MEMORY_PORT: "8765" };
55
+ }
56
+
51
57
  export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}) {
58
+ const values = withMemoryDefaults(id, { CX_TOOLKIT_DIR: ROOT_DIR, ...resolvedValues });
59
+
52
60
  if (mcpDef.type === "url") {
53
- const headers = buildRemoteHeaders(mcpDef, resolvedValues);
61
+ const headers = buildRemoteHeaders(mcpDef, values);
54
62
  const url =
55
- id === "memory" && resolvedValues.MEMORY_PORT
56
- ? `http://127.0.0.1:${resolvedValues.MEMORY_PORT}/`
57
- : resolveTemplateString(mcpDef.url, resolvedValues);
63
+ id === "memory" && values.MEMORY_PORT
64
+ ? `http://127.0.0.1:${values.MEMORY_PORT}/`
65
+ : resolveTemplateString(mcpDef.url, values);
58
66
  return {
59
67
  type: "http",
60
68
  url,
@@ -62,19 +70,19 @@ export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}) {
62
70
  };
63
71
  }
64
72
 
65
- const env = buildLocalEnvironment(mcpDef, resolvedValues);
73
+ const env = buildLocalEnvironment(mcpDef, values);
66
74
  return {
67
75
  command: mcpDef.command,
68
- args: resolveArgs(mcpDef.args, resolvedValues),
76
+ args: resolveArgs(mcpDef.args, values),
69
77
  ...(Object.keys(env).length > 0 ? { env } : {}),
70
78
  };
71
79
  }
72
80
 
73
81
  export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}) {
74
- const runtimeValues = {
82
+ const runtimeValues = withMemoryDefaults(id, {
75
83
  CX_TOOLKIT_DIR: ROOT_DIR,
76
84
  ...resolvedValues,
77
- };
85
+ });
78
86
  const openCodeId = getOpenCodeMcpId(id);
79
87
 
80
88
  if (mcpDef.type === "url") {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * lib/migrations/index.mjs — Schema migration registry and runner.
3
+ *
4
+ * Construct's machine-readable artifacts (agents/registry.json, contracts.json,
5
+ * role-manifests.json, .cx/config.json, profile JSON) carry an integer
6
+ * `version` field. When the installed binary's expected schema version is
7
+ * higher than the on-disk version, migrations apply in order. When the on-disk
8
+ * version is higher, the runner refuses to load and instructs the operator to
9
+ * upgrade.
10
+ *
11
+ * Each migration is a module under lib/migrations/ that exports:
12
+ * - from: integer
13
+ * - to: integer
14
+ * - apply(artifactPath, options): Promise<{ changed, summary }>
15
+ *
16
+ * v1 is the baseline — no migration needed. The registry below is the contract
17
+ * between the installed binary and migration authors.
18
+ */
19
+
20
+ import v1Baseline from './v1-baseline.mjs';
21
+
22
+ const REGISTRY = [
23
+ v1Baseline,
24
+ ];
25
+
26
+ export const CURRENT_SCHEMA_VERSION = Math.max(...REGISTRY.map((m) => m.to));
27
+
28
+ /**
29
+ * Compute the migration path from `fromVersion` to CURRENT_SCHEMA_VERSION.
30
+ * Returns an array of migrations to apply in order, or null if no path exists.
31
+ */
32
+ export function planMigrations(fromVersion, toVersion = CURRENT_SCHEMA_VERSION) {
33
+ if (fromVersion === toVersion) return [];
34
+ if (fromVersion > toVersion) return null;
35
+ const steps = [];
36
+ let cursor = fromVersion;
37
+ while (cursor < toVersion) {
38
+ const step = REGISTRY.find((m) => m.from === cursor);
39
+ if (!step) return null;
40
+ steps.push(step);
41
+ cursor = step.to;
42
+ }
43
+ return steps;
44
+ }
45
+
46
+ /**
47
+ * Apply a planned migration sequence. Each step receives the artifact path
48
+ * and any options. Returns a summary including every step's outcome.
49
+ *
50
+ * When dryRun is true, steps must not write to disk; they only report what
51
+ * they would change.
52
+ */
53
+ export async function runMigrations({ artifactPath, fromVersion, toVersion = CURRENT_SCHEMA_VERSION, dryRun = false }) {
54
+ const plan = planMigrations(fromVersion, toVersion);
55
+ if (plan === null) {
56
+ return {
57
+ ok: false,
58
+ error: `no migration path from version ${fromVersion} to ${toVersion}`,
59
+ applied: [],
60
+ };
61
+ }
62
+ if (plan.length === 0) {
63
+ return { ok: true, applied: [], summary: 'no migrations needed' };
64
+ }
65
+
66
+ const applied = [];
67
+ for (const step of plan) {
68
+ try {
69
+ const result = await step.apply(artifactPath, { dryRun });
70
+ applied.push({ from: step.from, to: step.to, ...result });
71
+ } catch (err) {
72
+ return {
73
+ ok: false,
74
+ error: `migration ${step.from}→${step.to} failed: ${err.message}`,
75
+ applied,
76
+ };
77
+ }
78
+ }
79
+ return { ok: true, applied, summary: `applied ${applied.length} migration(s)` };
80
+ }
81
+
82
+ /**
83
+ * Check artifact compatibility. Returns:
84
+ * { compatible: true } — versions match
85
+ * { compatible: false, needsMigration: true, ... } — on-disk is older
86
+ * { compatible: false, needsUpgrade: true, ... } — on-disk is newer
87
+ */
88
+ export function checkCompatibility(artifactVersion, expected = CURRENT_SCHEMA_VERSION) {
89
+ if (artifactVersion === expected) return { compatible: true };
90
+ if (artifactVersion < expected) {
91
+ return {
92
+ compatible: false,
93
+ needsMigration: true,
94
+ fromVersion: artifactVersion,
95
+ toVersion: expected,
96
+ message: `artifact schema version ${artifactVersion} is older than expected ${expected}; run 'construct migrate'`,
97
+ };
98
+ }
99
+ return {
100
+ compatible: false,
101
+ needsUpgrade: true,
102
+ fromVersion: artifactVersion,
103
+ toVersion: expected,
104
+ message: `artifact schema version ${artifactVersion} is newer than this binary supports (${expected}); run 'construct upgrade'`,
105
+ };
106
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * lib/migrations/v1-baseline.mjs — No-op baseline migration.
3
+ *
4
+ * Marks v0 (pre-versioned artifacts) as compatible with v1 by stamping the
5
+ * artifact with `version: 1` when absent. Schema bumps after v1 will add new
6
+ * migrations rather than modifying this one.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+
11
+ export default {
12
+ from: 0,
13
+ to: 1,
14
+ description: 'Stamp pre-versioned artifacts with version: 1 (no shape changes)',
15
+ async apply(artifactPath, { dryRun = false } = {}) {
16
+ if (!existsSync(artifactPath)) {
17
+ return { changed: false, summary: `${artifactPath}: not present (skip)` };
18
+ }
19
+ let data;
20
+ try { data = JSON.parse(readFileSync(artifactPath, 'utf8')); }
21
+ catch (err) { throw new Error(`failed to parse ${artifactPath}: ${err.message}`); }
22
+
23
+ if (data && typeof data === 'object' && data.version === 1) {
24
+ return { changed: false, summary: `${artifactPath}: already at v1` };
25
+ }
26
+
27
+ const next = { ...data, version: 1 };
28
+ if (!dryRun) {
29
+ writeFileSync(artifactPath, JSON.stringify(next, null, 2) + '\n');
30
+ }
31
+ return { changed: true, summary: `${artifactPath}: stamped version: 1` };
32
+ },
33
+ };
@@ -19,6 +19,7 @@ import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
19
19
  import { embedText as embedTextEngine } from './storage/embeddings-engine.mjs';
20
20
  import { VectorClient } from './storage/vector-client.mjs';
21
21
  import { withFileLockSync } from './storage/file-lock.mjs';
22
+ import { ensureCxDir } from './project-init-shared.mjs';
22
23
 
23
24
  const OBS_DIR = '.cx/observations';
24
25
  const INDEX_FILE = 'index.json';
@@ -36,7 +37,10 @@ const VALID_CATEGORIES = new Set([
36
37
  'pattern', 'anti-pattern', 'dependency', 'decision', 'insight', 'session-summary',
37
38
  ]);
38
39
 
39
- function ensureDir(dir) {
40
+ function ensureDir(dir, rootDir = null) {
41
+ // Preserves the construct context invariant: any code that creates a .cx/
42
+ // subdirectory also initializes context.md from the project template.
43
+ if (rootDir) ensureCxDir(rootDir);
40
44
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
41
45
  }
42
46
 
@@ -91,6 +95,7 @@ function logCapDrop(rootDir, kind, dropped, total) {
91
95
  if (dropped <= 0) return;
92
96
  try {
93
97
  const logPath = path.join(rootDir, '.cx', 'observation-cap-warnings.jsonl');
98
+ ensureCxDir(rootDir);
94
99
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
95
100
  const entry = JSON.stringify({
96
101
  ts: new Date().toISOString(),
@@ -105,7 +110,7 @@ function logCapDrop(rootDir, kind, dropped, total) {
105
110
  }
106
111
 
107
112
  function writeIndex(rootDir, entries) {
108
- ensureDir(obsDir(rootDir));
113
+ ensureDir(obsDir(rootDir), rootDir);
109
114
  const trimmed = entries.slice(0, MAX_INDEX);
110
115
  if (entries.length > MAX_INDEX) {
111
116
  logCapDrop(rootDir, 'observation-index', entries.length - MAX_INDEX, entries.length);
@@ -124,7 +129,7 @@ function readVectors(rootDir) {
124
129
  }
125
130
 
126
131
  function writeVectors(rootDir, records) {
127
- ensureDir(obsDir(rootDir));
132
+ ensureDir(obsDir(rootDir), rootDir);
128
133
  if (records.length > MAX_INDEX) {
129
134
  logCapDrop(rootDir, 'observation-vectors', records.length - MAX_INDEX, records.length);
130
135
  }
@@ -177,7 +182,7 @@ export async function addObservation(rootDir, {
177
182
  // Lock the per-store index so concurrent writers (CLI + hooks) cannot
178
183
  // corrupt the index/vectors JSON pair. The observation JSON file is
179
184
  // unique-per-id and doesn't need the lock; the shared structures do.
180
- ensureDir(obsDir(rootDir));
185
+ ensureDir(obsDir(rootDir), rootDir);
181
186
  fs.writeFileSync(
182
187
  path.join(obsDir(rootDir), `${id}.json`),
183
188
  JSON.stringify(record, null, 2) + '\n',
@@ -16,6 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { withFileLockSync } from '../storage/file-lock.mjs';
19
+ import { ensureCxDir } from '../project-init-shared.mjs';
19
20
 
20
21
  const ROTATE_AT = 10_000;
21
22
  const MAX_NOTES = 500;
@@ -65,6 +66,7 @@ function countLines(file) {
65
66
  export function recordOutcome(cwd, payload) {
66
67
  if (!cwd || !payload || typeof payload.success !== 'boolean') return null;
67
68
  try {
69
+ ensureCxDir(cwd);
68
70
  fs.mkdirSync(outcomesDir(cwd), { recursive: true });
69
71
  const file = activeFile(cwd, payload.role);
70
72
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * lib/profiles/rebrand.mjs — Profile-aware language helper.
3
+ *
4
+ * Each curated profile carries a `rebrand` block: how the active profile
5
+ * wants the intake queue and individual signal items named in user-facing
6
+ * surfaces (CLI output, daemon logs, session-start prelude). The helper
7
+ * centralises that lookup so every consumer goes through one path with
8
+ * safe defaults instead of hardcoding "intake queue" / "signal" / "intake".
9
+ *
10
+ * Defaults match the legacy strings so behaviour is unchanged for the rnd
11
+ * profile and for any environment where profile resolution fails.
12
+ */
13
+
14
+ import { resolveActiveProfile } from './loader.mjs';
15
+
16
+ export const DEFAULT_REBRAND = Object.freeze({
17
+ intakeQueueLabel: 'Intake queue',
18
+ signalNoun: 'signal',
19
+ });
20
+
21
+ /**
22
+ * Return the active profile's rebrand labels.
23
+ *
24
+ * @param {string} [rootDir] - project root where profile.json / construct.config.json live.
25
+ * @returns {{ intakeQueueLabel: string, signalNoun: string }}
26
+ *
27
+ * Returns defaults when `rootDir` is missing, when the profile cannot be
28
+ * loaded, or when the profile omits its `rebrand` block. Never throws.
29
+ */
30
+ export function getRebrand(rootDir) {
31
+ if (!rootDir || typeof rootDir !== 'string') return { ...DEFAULT_REBRAND };
32
+ try {
33
+ const profile = resolveActiveProfile(rootDir);
34
+ const rb = profile?.rebrand;
35
+ if (!rb || typeof rb !== 'object') return { ...DEFAULT_REBRAND };
36
+ const intakeQueueLabel = typeof rb.intakeQueueLabel === 'string' && rb.intakeQueueLabel.trim()
37
+ ? rb.intakeQueueLabel.trim()
38
+ : DEFAULT_REBRAND.intakeQueueLabel;
39
+ const signalNoun = typeof rb.signalNoun === 'string' && rb.signalNoun.trim()
40
+ ? rb.signalNoun.trim()
41
+ : DEFAULT_REBRAND.signalNoun;
42
+ return { intakeQueueLabel, signalNoun };
43
+ } catch {
44
+ return { ...DEFAULT_REBRAND };
45
+ }
46
+ }