@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.
- package/README.md +19 -17
- package/agents/contracts.json +617 -2
- package/agents/contracts.schema.json +83 -0
- package/agents/prompts/cx-accessibility.md +2 -0
- package/agents/prompts/cx-ai-engineer.md +2 -0
- package/agents/prompts/cx-architect.md +2 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +2 -0
- package/agents/prompts/cx-data-engineer.md +2 -0
- package/agents/prompts/cx-debugger.md +2 -0
- package/agents/prompts/cx-designer.md +2 -0
- package/agents/prompts/cx-devil-advocate.md +2 -0
- package/agents/prompts/cx-docs-keeper.md +2 -0
- package/agents/prompts/cx-engineer.md +2 -0
- package/agents/prompts/cx-evaluator.md +2 -0
- package/agents/prompts/cx-explorer.md +2 -0
- package/agents/prompts/cx-legal-compliance.md +2 -0
- package/agents/prompts/cx-operations.md +2 -0
- package/agents/prompts/cx-orchestrator.md +2 -0
- package/agents/prompts/cx-platform-engineer.md +2 -0
- package/agents/prompts/cx-product-manager.md +2 -0
- package/agents/prompts/cx-qa.md +2 -0
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-release-manager.md +2 -0
- package/agents/prompts/cx-researcher.md +2 -0
- package/agents/prompts/cx-reviewer.md +2 -0
- package/agents/prompts/cx-security.md +2 -0
- package/agents/prompts/cx-sre.md +2 -0
- package/agents/prompts/cx-test-automation.md +2 -0
- package/agents/prompts/cx-trace-reviewer.md +2 -0
- package/agents/prompts/cx-ux-researcher.md +2 -0
- package/agents/registry.json +8 -3
- package/bin/construct +209 -32
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +1 -1
- package/commands/work/parallel-review.md +1 -1
- package/lib/auto-docs.mjs +7 -7
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/comment-lint.mjs +134 -0
- package/lib/contracts/validate.mjs +323 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +310 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +211 -1
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +2 -0
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/mcp-audit.mjs +2 -1
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/init-unified.mjs +55 -65
- package/lib/intake/classify.mjs +108 -24
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +6 -1
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/prepare.mjs +0 -1
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +2 -0
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/observation-store.mjs +9 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/reflect.mjs +49 -12
- package/lib/server/index.mjs +22 -28
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/telemetry/client.mjs +5 -1
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/package.json +4 -1
- package/personas/construct.md +3 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/sync-agents.mjs +45 -14
- package/templates/docs/construct_guide.md +6 -6
- 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
|
+
};
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -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 ?? '';
|
package/lib/mcp-catalog.json
CHANGED
|
@@ -55,21 +55,25 @@
|
|
|
55
55
|
"id": "memory",
|
|
56
56
|
"name": "Memory",
|
|
57
57
|
"category": "optional",
|
|
58
|
-
"description": "cass-memory
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
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": "
|
|
69
|
-
"opencode": { "mode": "
|
|
70
|
-
"codex": { "mode": "
|
|
72
|
+
"claude": { "mode": "managed" },
|
|
73
|
+
"opencode": { "mode": "managed" },
|
|
74
|
+
"codex": { "mode": "managed" }
|
|
71
75
|
},
|
|
72
|
-
"setupNote": "
|
|
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,
|
|
61
|
+
const headers = buildRemoteHeaders(mcpDef, values);
|
|
54
62
|
const url =
|
|
55
|
-
id === "memory" &&
|
|
56
|
-
? `http://127.0.0.1:${
|
|
57
|
-
: resolveTemplateString(mcpDef.url,
|
|
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,
|
|
73
|
+
const env = buildLocalEnvironment(mcpDef, values);
|
|
66
74
|
return {
|
|
67
75
|
command: mcpDef.command,
|
|
68
|
-
args: resolveArgs(mcpDef.args,
|
|
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',
|
package/lib/outcomes/record.mjs
CHANGED
|
@@ -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
|
+
}
|