@geraldmaron/construct 1.0.24 → 1.1.1
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 +2 -0
- package/bin/construct +266 -13
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/audit-trail.mjs +77 -10
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +112 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +85 -10
- package/lib/document-ingest.mjs +97 -7
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/embed/semantic.mjs +5 -2
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp-catalog.json +1 -1
- package/lib/model-router.mjs +52 -1
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/persona-sections.mjs +66 -0
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +14 -9
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +42 -4
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +58 -2
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +4 -2
- package/personas/construct.md +12 -12
- package/platforms/capabilities.json +76 -0
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +277 -63
- package/skills/docs/init-project.md +1 -1
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* sidecar without relying on PATH state.
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
|
|
@@ -37,6 +37,31 @@ function which(bin) {
|
|
|
37
37
|
return result.stdout.trim().split('\n')[0] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Provisioning runs on the request path (the first docling ingest), including
|
|
41
|
+
// inside the long-lived MCP server. spawnSync would block the event loop for the
|
|
42
|
+
// whole multi-minute install — freezing every other request and defeating the
|
|
43
|
+
// per-tool dispatch timeout. Run each step via async spawn instead, shaped to
|
|
44
|
+
// mirror spawnSync's result so describeStepFailure reads it unchanged.
|
|
45
|
+
|
|
46
|
+
function runAsync(cmd, args, { timeoutMs, env } = {}) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const child = spawn(cmd, args, { env });
|
|
49
|
+
let stdout = '';
|
|
50
|
+
let stderr = '';
|
|
51
|
+
let timedOut = false;
|
|
52
|
+
child.stdout?.setEncoding('utf8');
|
|
53
|
+
child.stderr?.setEncoding('utf8');
|
|
54
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
55
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
56
|
+
const timer = timeoutMs ? setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeoutMs) : null;
|
|
57
|
+
child.on('error', (error) => { if (timer) clearTimeout(timer); resolve({ status: null, stdout, stderr, error, signal: null }); });
|
|
58
|
+
child.on('close', (code, signal) => {
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
resolve({ status: code, stdout, stderr, signal: timedOut ? 'SIGTERM' : signal, error: timedOut ? { code: 'ETIMEDOUT' } : null });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
// First-run docling provisioning blocks for minutes (uv install, venv, then a
|
|
41
66
|
// large `uv pip install docling` that pulls ML deps). Without a heartbeat it
|
|
42
67
|
// reads as a hang. Progress goes to stderr only, so JSON consumers on stdout are
|
|
@@ -56,7 +81,7 @@ function describeStepFailure(label, result, timeoutMs) {
|
|
|
56
81
|
return `${label} failed (${result.status}): ${result.stderr || result.stdout || result.error?.message || 'unknown error'}`;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
function ensureUv(installDir) {
|
|
84
|
+
async function ensureUv(installDir) {
|
|
60
85
|
const fromPath = which('uv');
|
|
61
86
|
if (fromPath) return fromPath;
|
|
62
87
|
const cachedUv = path.join(installDir, 'bin', 'uv');
|
|
@@ -64,11 +89,7 @@ function ensureUv(installDir) {
|
|
|
64
89
|
mkdirSync(installDir, { recursive: true });
|
|
65
90
|
progress('Installing uv (Python toolchain) — first run only…');
|
|
66
91
|
const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
|
|
67
|
-
const sh =
|
|
68
|
-
env,
|
|
69
|
-
timeout: UV_TIMEOUT_MS,
|
|
70
|
-
encoding: 'utf8',
|
|
71
|
-
});
|
|
92
|
+
const sh = await runAsync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], { env, timeoutMs: UV_TIMEOUT_MS });
|
|
72
93
|
if (sh.status !== 0) {
|
|
73
94
|
throw new Error(describeStepFailure('uv install', sh, UV_TIMEOUT_MS));
|
|
74
95
|
}
|
|
@@ -95,7 +116,7 @@ function writeMarker(markerPath, payload) {
|
|
|
95
116
|
writeFileSync(markerPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = false } = {}) {
|
|
119
|
+
export async function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = false } = {}) {
|
|
99
120
|
mkdirSync(runtimeDir, { recursive: true });
|
|
100
121
|
const venvDir = path.join(runtimeDir, '.venv');
|
|
101
122
|
const markerPath = path.join(runtimeDir, '.install-marker.json');
|
|
@@ -107,22 +128,16 @@ export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = fa
|
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
progress('Provisioning the docling document extractor (first run). This downloads a Python runtime and ML dependencies and can take several minutes; later runs are instant.');
|
|
110
|
-
const uv = ensureUv(runtimeDir);
|
|
131
|
+
const uv = await ensureUv(runtimeDir);
|
|
111
132
|
|
|
112
133
|
progress('Creating Python 3.11 virtualenv…');
|
|
113
|
-
const venvResult =
|
|
114
|
-
timeout: VENV_TIMEOUT_MS,
|
|
115
|
-
encoding: 'utf8',
|
|
116
|
-
});
|
|
134
|
+
const venvResult = await runAsync(uv, ['venv', venvDir, '--python', '3.11'], { timeoutMs: VENV_TIMEOUT_MS });
|
|
117
135
|
if (venvResult.status !== 0) {
|
|
118
136
|
throw new Error(describeStepFailure('uv venv', venvResult, VENV_TIMEOUT_MS));
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
progress(`Installing docling ${DOCLING_PIN} and its dependencies — the slow step (large download)…`);
|
|
122
|
-
const installResult =
|
|
123
|
-
timeout: INSTALL_TIMEOUT_MS,
|
|
124
|
-
encoding: 'utf8',
|
|
125
|
-
});
|
|
140
|
+
const installResult = await runAsync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], { timeoutMs: INSTALL_TIMEOUT_MS });
|
|
126
141
|
if (installResult.status !== 0) {
|
|
127
142
|
throw new Error(describeStepFailure('docling install', installResult, INSTALL_TIMEOUT_MS));
|
|
128
143
|
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -343,7 +343,7 @@ async function buildStatus() {
|
|
|
343
343
|
rootDir: ROOT_DIR,
|
|
344
344
|
cwd: process.cwd(),
|
|
345
345
|
homeDir: HOME,
|
|
346
|
-
// The dashboard is long-lived and `construct
|
|
346
|
+
// The dashboard is long-lived and `construct dev` rewrites managed values in
|
|
347
347
|
// ~/.construct/config.env. Read fresh config on every request instead of
|
|
348
348
|
// letting inherited process env shadow updated ports/credentials.
|
|
349
349
|
env: {},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/server/langfuse-login.mjs — Magic-link bridge for local Langfuse.
|
|
3
3
|
*
|
|
4
|
-
* `construct
|
|
4
|
+
* `construct dev` seeds Langfuse with deterministic admin creds via the
|
|
5
5
|
* LANGFUSE_INIT_* env vars in langfuse/docker-compose.yml. This bridge turns
|
|
6
6
|
* the "Open Langfuse" link in the dashboard into a one-click sign-in: the
|
|
7
7
|
* server fetches a CSRF token from local Langfuse, then returns an HTML
|
|
@@ -28,7 +28,7 @@ export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.b
|
|
|
28
28
|
const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`, { headers: { Accept: 'application/json' } });
|
|
29
29
|
if (!csrfResponse.ok) {
|
|
30
30
|
res.writeHead(502, HTML_HEADERS);
|
|
31
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct
|
|
31
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct dev\` first.</pre>`);
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
const { csrfToken } = await csrfResponse.json();
|
|
@@ -53,6 +53,6 @@ export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.b
|
|
|
53
53
|
</body></html>`);
|
|
54
54
|
} catch (err) {
|
|
55
55
|
res.writeHead(502, HTML_HEADERS);
|
|
56
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct
|
|
56
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct dev\` first.</pre>`);
|
|
57
57
|
}
|
|
58
58
|
}
|
package/lib/service-manager.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/service-manager.mjs — start, stop, and describe Construct runtime services.
|
|
3
3
|
*
|
|
4
4
|
* Manages the dashboard process, memory server (cm), and OpenCode.
|
|
5
|
-
* startServices() is the single entry point for `construct
|
|
5
|
+
* startServices() is the single entry point for `construct dev` —
|
|
6
6
|
* spawns whatever is available and returns a result per service.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -159,10 +159,15 @@ async function isMemoryRunning(port) {
|
|
|
159
159
|
return probeRuntimePort(port);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
async function
|
|
162
|
+
async function isBridgeRunning(port) {
|
|
163
163
|
return probeRuntimeHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 1000 });
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
async function isCopilotBridgeRunning(port) {
|
|
167
|
+
// Check if the port is reachable.
|
|
168
|
+
return probeRuntimePort(port);
|
|
169
|
+
}
|
|
170
|
+
|
|
166
171
|
function spawnDetached(command, args, homeDir, logFile, options = {}) {
|
|
167
172
|
const logPath = path.join(runtimeStateDir(homeDir), logFile);
|
|
168
173
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
@@ -290,7 +295,7 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
|
|
|
290
295
|
export async function getRuntimePorts(homeDir = os.homedir(), {
|
|
291
296
|
dashboardProbeFn = isDashboardRunning,
|
|
292
297
|
memoryProbeFn = isMemoryRunning,
|
|
293
|
-
openCodeProbeFn =
|
|
298
|
+
openCodeProbeFn = isBridgeRunning,
|
|
294
299
|
findAvailablePortFn = findAvailablePort,
|
|
295
300
|
} = {}) {
|
|
296
301
|
const dashboard = readDashboardState(homeDir);
|
|
@@ -308,6 +313,7 @@ export async function getRuntimePorts(homeDir = os.homedir(), {
|
|
|
308
313
|
dashboard: dashboard?.port ?? await resolvePort('DASHBOARD_PORT', 4242, dashboardProbeFn),
|
|
309
314
|
memory: await resolvePort('MEMORY_PORT', derivedMemoryPort(), memoryProbeFn),
|
|
310
315
|
bridge: await resolvePort('BRIDGE_PORT', 5173, openCodeProbeFn),
|
|
316
|
+
copilotBridge: await resolvePort('COPILOT_BRIDGE_PORT', 5174, probeRuntimePort),
|
|
311
317
|
};
|
|
312
318
|
}
|
|
313
319
|
|
|
@@ -324,6 +330,7 @@ export async function describeRuntimeSupport() {
|
|
|
324
330
|
tmux: commandExists('tmux'),
|
|
325
331
|
cm: commandExists('cm'),
|
|
326
332
|
opencode: commandExists('opencode'),
|
|
333
|
+
gh: commandExists('gh'),
|
|
327
334
|
};
|
|
328
335
|
}
|
|
329
336
|
|
|
@@ -332,6 +339,7 @@ export const SELECTABLE_SERVICES = Object.freeze([
|
|
|
332
339
|
{ key: 'telemetry', label: 'Telemetry', description: 'Trace export / local JSONL traces.' },
|
|
333
340
|
{ key: 'memory', label: 'Memory (cm)', description: 'Persistent memory service (cm).' },
|
|
334
341
|
{ key: 'opencode', label: 'OpenCode', description: 'OpenCode bridge server.' },
|
|
342
|
+
{ key: 'copilot-bridge', label: 'Copilot Bridge', description: 'Host-native Copilot bridge proxy (requires gh auth).' },
|
|
335
343
|
]);
|
|
336
344
|
|
|
337
345
|
export async function startServices({
|
|
@@ -344,7 +352,7 @@ export async function startServices({
|
|
|
344
352
|
loadConstructEnvFn = loadConstructEnv,
|
|
345
353
|
spawnDetachedFn = spawnDetached,
|
|
346
354
|
memoryProbeFn = isMemoryRunning,
|
|
347
|
-
openCodeProbeFn =
|
|
355
|
+
openCodeProbeFn = isBridgeRunning,
|
|
348
356
|
runPressureReleaseFn = runPressureRelease,
|
|
349
357
|
} = {}) {
|
|
350
358
|
const support = await describeRuntimeSupportFn();
|
|
@@ -357,10 +365,15 @@ export async function startServices({
|
|
|
357
365
|
DASHBOARD_PORT: String(ports.dashboard),
|
|
358
366
|
MEMORY_PORT: String(ports.memory),
|
|
359
367
|
BRIDGE_PORT: String(ports.bridge),
|
|
368
|
+
COPILOT_BRIDGE_PORT: String(ports.copilotBridge),
|
|
360
369
|
});
|
|
361
370
|
|
|
362
371
|
const liveEnv = loadConstructEnvFn({ rootDir, homeDir });
|
|
363
372
|
const results = [];
|
|
373
|
+
|
|
374
|
+
// Set Ollama persistence for local inference performance
|
|
375
|
+
liveEnv.OLLAMA_KEEP_ALIVE = "-1";
|
|
376
|
+
|
|
364
377
|
const pressureReport = runPressureReleaseFn({ env: { ...liveEnv, HOME: homeDir } });
|
|
365
378
|
if (pressureReport?.killed?.length) {
|
|
366
379
|
results.push({
|
|
@@ -438,6 +451,27 @@ export async function startServices({
|
|
|
438
451
|
}
|
|
439
452
|
}
|
|
440
453
|
|
|
454
|
+
// Copilot Bridge
|
|
455
|
+
if (wants('copilot-bridge') && support.gh) {
|
|
456
|
+
const { detectActiveSessions } = await import('./host-capabilities.mjs');
|
|
457
|
+
const sessions = detectActiveSessions();
|
|
458
|
+
if (sessions.includes('github-copilot')) {
|
|
459
|
+
if (await probeRuntimePort(ports.copilotBridge)) {
|
|
460
|
+
results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'reused' });
|
|
461
|
+
} else {
|
|
462
|
+
const proxyPath = path.join(INSTALL_ROOT, 'lib', 'bridges', 'copilot-proxy.mjs');
|
|
463
|
+
if (fs.existsSync(proxyPath)) {
|
|
464
|
+
spawnDetachedFn('node', [proxyPath, '--port', String(ports.copilotBridge)], homeDir, 'copilot-bridge.log');
|
|
465
|
+
results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'started' });
|
|
466
|
+
} else {
|
|
467
|
+
results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'copilot-proxy binary missing in lib/bridges/' });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'gh auth status shows no active GitHub session' });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
441
475
|
const doctor = startDoctor({ rootDir, homeDir });
|
|
442
476
|
if (doctor.started) {
|
|
443
477
|
results.push({ name: 'Doctor', status: 'started', note: `L0 daemon · logs: ${doctor.logPath}` });
|
|
@@ -521,6 +555,10 @@ export async function stopServices({
|
|
|
521
555
|
const openCodeKilled = killPortOwners(bridgePort, spawnSyncFn);
|
|
522
556
|
results.push({ name: 'OpenCode', status: openCodeKilled ? 'stopped' : 'not-running' });
|
|
523
557
|
|
|
558
|
+
const copilotBridgePort = Number(envValues.COPILOT_BRIDGE_PORT) || 5174;
|
|
559
|
+
const copilotBridgeKilled = killPortOwners(copilotBridgePort, spawnSyncFn);
|
|
560
|
+
results.push({ name: 'Copilot Bridge', status: copilotBridgeKilled ? 'stopped' : 'not-running' });
|
|
561
|
+
|
|
524
562
|
const stopped = results.filter((r) => r.status === 'stopped' || r.status === 'cleaned').map((r) => r.name);
|
|
525
563
|
return { stopped, results };
|
|
526
564
|
}
|
package/lib/session-store.mjs
CHANGED
|
@@ -350,7 +350,7 @@ export function buildResumeContext(session) {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
/**
|
|
353
|
-
* Close all active sessions (called on construct
|
|
353
|
+
* Close all active sessions (called on construct stop).
|
|
354
354
|
*/
|
|
355
355
|
export function closeAllSessions(rootDir) {
|
|
356
356
|
const index = readIndex(rootDir);
|
package/lib/setup.mjs
CHANGED
|
@@ -40,7 +40,7 @@ function printHelp() {
|
|
|
40
40
|
console.log(`Construct install — machine setup (once per machine)
|
|
41
41
|
|
|
42
42
|
Usage:
|
|
43
|
-
construct install [--scope=project|user|both] [--yes] [--no-launch-agent] [--reconfigure]
|
|
43
|
+
construct install [--scope=project|user|both] [--yes] [--dry-run] [--no-launch-agent] [--reconfigure] [--with-docling]
|
|
44
44
|
|
|
45
45
|
Flags:
|
|
46
46
|
--scope=<s> project | user | both (default: project — see ADR-0029)
|
|
@@ -50,8 +50,12 @@ Flags:
|
|
|
50
50
|
~/.claude/* via consent-gated sync).
|
|
51
51
|
both: print project guidance, then run user-scope install.
|
|
52
52
|
--yes accept detected defaults without prompting
|
|
53
|
+
--dry-run print the install plan and exit without writing anything
|
|
53
54
|
--no-launch-agent skip macOS LaunchAgent background service registration
|
|
54
55
|
--reconfigure re-prompt for service consent, ignoring cached answers
|
|
56
|
+
--with-docling eagerly provision the docling document-extraction venv now
|
|
57
|
+
(heavy, ~10 min via uv; otherwise provisioned lazily on
|
|
58
|
+
first document ingest)
|
|
55
59
|
|
|
56
60
|
What --scope=user does:
|
|
57
61
|
- creates ~/.construct/config.env
|
|
@@ -303,6 +307,36 @@ function printProjectScopeGuidance() {
|
|
|
303
307
|
console.log(' construct install --scope=both');
|
|
304
308
|
}
|
|
305
309
|
|
|
310
|
+
// Dry-run renders the user-scope plan as intent only — every line names a write,
|
|
311
|
+
// network call, or service registration that the real run would perform — and
|
|
312
|
+
// returns before touching disk so the preview is provably side-effect-free.
|
|
313
|
+
|
|
314
|
+
function printInstallPlan({ scope, homeDir, withDocling, noLaunchAgent }) {
|
|
315
|
+
console.log('Construct install — dry-run (no changes written)');
|
|
316
|
+
console.log('────────────────────────────────────────────────');
|
|
317
|
+
console.log(`Scope: ${scope}`);
|
|
318
|
+
if (scope === 'both') {
|
|
319
|
+
console.log(' · would print project-scope guidance, then run the user-scope plan below');
|
|
320
|
+
}
|
|
321
|
+
console.log('\nWould write:');
|
|
322
|
+
console.log(` · ${getUserEnvPath(homeDir)} (user config, managed defaults)`);
|
|
323
|
+
console.log(` · ${getCanonicalOpenCodeConfigPath(homeDir)} (OpenCode config)`);
|
|
324
|
+
console.log(` · ${path.join(homeDir, '.construct', 'lib')} → ${path.join(ROOT_DIR, 'lib')} (hook lib symlink)`);
|
|
325
|
+
console.log(` · ${path.join(homeDir, '.construct', 'workspace')} (workspace docs scaffold)`);
|
|
326
|
+
console.log(` · ${path.dirname(defaultVectorIndexPath(homeDir))} (LanceDB vector store dir)`);
|
|
327
|
+
console.log('\nWould check / install runtime tooling:');
|
|
328
|
+
console.log(' · cm (memory CLI) and cass (session search) when available on PATH');
|
|
329
|
+
console.log('\nWould reach the network / external services:');
|
|
330
|
+
console.log(' · embedding model warmup (loads from local cache; offline-safe, no download)');
|
|
331
|
+
if (withDocling) console.log(' · docling Python venv provisioning via uv (~10 min)');
|
|
332
|
+
console.log(' · construct sync --quiet, construct sync --global, construct doctor');
|
|
333
|
+
if (process.platform === 'darwin' && !noLaunchAgent) {
|
|
334
|
+
console.log('\nWould register macOS LaunchAgent:');
|
|
335
|
+
console.log(` · ${path.join(homeDir, 'Library', 'LaunchAgents', 'dev.construct.pressure-release.plist')} (consent-gated)`);
|
|
336
|
+
}
|
|
337
|
+
console.log('\nNo files were written. Re-run without --dry-run to apply.');
|
|
338
|
+
}
|
|
339
|
+
|
|
306
340
|
export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME } = {}) {
|
|
307
341
|
const argSet = new Set(args);
|
|
308
342
|
const isYes = argSet.has('--yes');
|
|
@@ -313,7 +347,9 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
313
347
|
return;
|
|
314
348
|
}
|
|
315
349
|
|
|
316
|
-
const KNOWN_FLAGS = new Set(['--yes', '--no-launch-agent', '--reconfigure', '--help', '-h']);
|
|
350
|
+
const KNOWN_FLAGS = new Set(['--yes', '--dry-run', '--no-launch-agent', '--reconfigure', '--with-docling', '--help', '-h']);
|
|
351
|
+
const dryRun = argSet.has('--dry-run');
|
|
352
|
+
const withDocling = argSet.has('--with-docling');
|
|
317
353
|
const unknownFlags = args.filter((a) => {
|
|
318
354
|
if (!a.startsWith('-')) return false;
|
|
319
355
|
if (a.startsWith('--scope=') || a === '--scope') return false;
|
|
@@ -338,6 +374,11 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
338
374
|
return;
|
|
339
375
|
}
|
|
340
376
|
|
|
377
|
+
if (dryRun) {
|
|
378
|
+
printInstallPlan({ scope, homeDir, withDocling, noLaunchAgent: argSet.has('--no-launch-agent') });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
341
382
|
if (scope === 'both') {
|
|
342
383
|
printProjectScopeGuidance();
|
|
343
384
|
console.log('');
|
|
@@ -392,6 +433,21 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
392
433
|
console.log(`Embeddings: warmup skipped (${err?.message || 'unknown error'}) — model will load on first use`);
|
|
393
434
|
}
|
|
394
435
|
|
|
436
|
+
// Docling (document/PDF extraction) provisions a pinned Python venv via uv,
|
|
437
|
+
// which is heavy (~10 min) and only needed for document ingest — so it stays
|
|
438
|
+
// lazy by default and is provisioned eagerly only when the user opts in with
|
|
439
|
+
// --with-docling. First document ingest still auto-provisions if skipped.
|
|
440
|
+
if (withDocling) {
|
|
441
|
+
try {
|
|
442
|
+
const { ensureDoclingVenv } = await import('./runtime/uv-bootstrap.mjs');
|
|
443
|
+
console.log('Docling: provisioning Python venv (uv) — this can take several minutes…');
|
|
444
|
+
const docling = await ensureDoclingVenv();
|
|
445
|
+
console.log(`Docling: ready (${docling.fresh ? 'provisioned' : 'already present'} at ${docling.venvDir})`);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
console.log(`Docling: provisioning skipped (${err?.message || 'unknown error'}) — will provision on first document ingest`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
395
451
|
const { isCheapestProviderEnabled, selectCheapestForAllTiers, setCheapestProviderPreference } =
|
|
396
452
|
await import('./model-cheapest-provider.mjs');
|
|
397
453
|
const cheapestAlreadyEnabled = isCheapestProviderEnabled(envPath, { env: process.env });
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/specialists/prompt-schema.mjs — validation for hybrid specialist prompt files.
|
|
3
|
+
*
|
|
4
|
+
* Specialist prompts are migrating from opaque free-form markdown to a hybrid
|
|
5
|
+
* shape: YAML frontmatter (structured metadata) + markdown body (the authored
|
|
6
|
+
* prose), mirroring skills/roles/*.md. This validates the frontmatter and the
|
|
7
|
+
* canonical-section contract, and gates the highest-value invariant — the
|
|
8
|
+
* frontmatter `perspective{}` must equal the registry's, since the two
|
|
9
|
+
* currently duplicate each other and drift silently (ADR-0037).
|
|
10
|
+
*
|
|
11
|
+
* A file with no frontmatter is treated as not-yet-converted: reported, never an
|
|
12
|
+
* error, so the linter can run across a half-migrated corpus. Frontmatter is the
|
|
13
|
+
* source of truth; section checks are warnings until the body-normalization
|
|
14
|
+
* phase, so adding frontmatter alone never fails the gate.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import yaml from 'js-yaml';
|
|
20
|
+
|
|
21
|
+
export const REQUIRED_FRONTMATTER = ['name', 'role', 'version', 'perspective'];
|
|
22
|
+
export const PERSPECTIVE_FIELDS = ['bias', 'tension', 'openingQuestion', 'failureMode'];
|
|
23
|
+
export const OPTIONAL_FRONTMATTER = ['roleGuidance', 'roleOverlays', 'templates', 'preloadRoleGuidance'];
|
|
24
|
+
export const REQUIRED_SECTIONS = ['Anti-fabrication contract', 'Output format'];
|
|
25
|
+
export const CANONICAL_SECTIONS = [
|
|
26
|
+
'Orientation', 'Anti-fabrication contract', 'Productive tension',
|
|
27
|
+
'Opening question', 'Failure mode', 'Domain overlays', 'Output format',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split a prompt file into { frontmatter, body }. frontmatter is null when the
|
|
34
|
+
* file has none (unconverted), or undefined when the YAML failed to parse.
|
|
35
|
+
*/
|
|
36
|
+
export function splitFrontmatter(text) {
|
|
37
|
+
const raw = String(text ?? '');
|
|
38
|
+
const m = raw.match(FRONTMATTER_RE);
|
|
39
|
+
if (!m) return { frontmatter: null, body: raw };
|
|
40
|
+
try {
|
|
41
|
+
const fm = yaml.load(m[1]);
|
|
42
|
+
return { frontmatter: (fm && typeof fm === 'object') ? fm : {}, body: raw.slice(m[0].length) };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { frontmatter: undefined, body: raw.slice(m[0].length), error: err.message };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasHeading(body, title) {
|
|
49
|
+
const target = String(title).trim().toLowerCase();
|
|
50
|
+
const re = /^#{1,6}\s+(.+)$/gm;
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = re.exec(body))) {
|
|
53
|
+
if (m[1].trim().toLowerCase() === target) return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function samePerspective(a, b) {
|
|
59
|
+
if (!a || !b) return false;
|
|
60
|
+
return PERSPECTIVE_FIELDS.every((f) => String(a[f] ?? '').trim() === String(b[f] ?? '').trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate one prompt file's content against the hybrid format.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {string} opts.content - raw file content
|
|
68
|
+
* @param {string} [opts.id] - display id (cx-<role>) for messages
|
|
69
|
+
* @param {object} [opts.registryEntry] - the matching registry specialist (for drift checks)
|
|
70
|
+
* @returns {{ converted: boolean, errors: string[], warnings: string[] }}
|
|
71
|
+
*/
|
|
72
|
+
export function validatePromptContent({ content, id = '(prompt)', registryEntry } = {}) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
const warnings = [];
|
|
75
|
+
const { frontmatter, body, error } = splitFrontmatter(content);
|
|
76
|
+
|
|
77
|
+
if (frontmatter === null) {
|
|
78
|
+
return { converted: false, errors, warnings: [`${id}: no frontmatter — not yet converted to the hybrid format`] };
|
|
79
|
+
}
|
|
80
|
+
if (frontmatter === undefined) {
|
|
81
|
+
errors.push(`${id}: frontmatter is not valid YAML — ${error}`);
|
|
82
|
+
return { converted: true, errors, warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const field of REQUIRED_FRONTMATTER) {
|
|
86
|
+
if (frontmatter[field] == null || frontmatter[field] === '') {
|
|
87
|
+
errors.push(`${id}: missing required frontmatter field "${field}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (frontmatter.name && registryEntry?.name) {
|
|
92
|
+
const expected = `cx-${registryEntry.name}`;
|
|
93
|
+
if (frontmatter.name !== expected) {
|
|
94
|
+
errors.push(`${id}: frontmatter name "${frontmatter.name}" must equal "${expected}" (registry name)`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (frontmatter.version != null && !Number.isInteger(frontmatter.version)) {
|
|
98
|
+
errors.push(`${id}: frontmatter version must be an integer (got ${JSON.stringify(frontmatter.version)})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const persp = frontmatter.perspective;
|
|
102
|
+
if (persp && typeof persp === 'object') {
|
|
103
|
+
for (const f of PERSPECTIVE_FIELDS) {
|
|
104
|
+
if (!persp[f] || !String(persp[f]).trim()) errors.push(`${id}: perspective.${f} is required and must be non-empty`);
|
|
105
|
+
}
|
|
106
|
+
if (registryEntry?.perspective && !samePerspective(persp, registryEntry.perspective)) {
|
|
107
|
+
errors.push(`${id}: perspective{} drifts from registry.json — they must match while both exist (frontmatter is the source of truth; update the registry)`);
|
|
108
|
+
}
|
|
109
|
+
} else if (frontmatter.perspective != null) {
|
|
110
|
+
errors.push(`${id}: perspective must be an object with ${PERSPECTIVE_FIELDS.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const known = new Set([...REQUIRED_FRONTMATTER, ...OPTIONAL_FRONTMATTER]);
|
|
114
|
+
for (const key of Object.keys(frontmatter)) {
|
|
115
|
+
if (!known.has(key)) warnings.push(`${id}: unknown frontmatter key "${key}"`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
119
|
+
if (!hasHeading(body, section)) warnings.push(`${id}: missing canonical section "## ${section}" (warn-only until body normalization)`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { converted: true, errors, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate every specialist prompt file referenced by the registry.
|
|
127
|
+
* @returns {{ errors: string[], warnings: string[], total: number, converted: number }}
|
|
128
|
+
*/
|
|
129
|
+
export function validatePromptFiles({ rootDir = process.cwd(), registry } = {}) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const warnings = [];
|
|
132
|
+
let total = 0;
|
|
133
|
+
let converted = 0;
|
|
134
|
+
|
|
135
|
+
const reg = registry ?? loadRegistry(rootDir);
|
|
136
|
+
const specialists = Array.isArray(reg?.specialists) ? reg.specialists : [];
|
|
137
|
+
const byName = new Map(specialists.map((s) => [s.name, s]));
|
|
138
|
+
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const entry of specialists) {
|
|
141
|
+
if (!entry?.promptFile || !entry.promptFile.startsWith('specialists/prompts/')) continue;
|
|
142
|
+
if (seen.has(entry.promptFile)) continue;
|
|
143
|
+
seen.add(entry.promptFile);
|
|
144
|
+
const filePath = path.join(rootDir, entry.promptFile);
|
|
145
|
+
if (!fs.existsSync(filePath)) { errors.push(`${entry.name}: promptFile ${entry.promptFile} does not exist`); continue; }
|
|
146
|
+
total += 1;
|
|
147
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
148
|
+
const result = validatePromptContent({ content, id: `cx-${entry.name}`, registryEntry: byName.get(entry.name) });
|
|
149
|
+
if (result.converted) converted += 1;
|
|
150
|
+
errors.push(...result.errors);
|
|
151
|
+
warnings.push(...result.warnings);
|
|
152
|
+
}
|
|
153
|
+
return { errors, warnings, total, converted };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function loadRegistry(rootDir) {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(fs.readFileSync(path.join(rootDir, 'specialists', 'registry.json'), 'utf8'));
|
|
159
|
+
} catch {
|
|
160
|
+
return { specialists: [] };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/specialists/scaffold.mjs — scaffold and field-edit hybrid specialist prompts.
|
|
3
|
+
*
|
|
4
|
+
* The CLI harness side of ADR-0037: `construct specialist create` emits a
|
|
5
|
+
* canonical skeleton (frontmatter + required sections) a human then fills in,
|
|
6
|
+
* and `construct specialist edit` mutates frontmatter fields only (never the
|
|
7
|
+
* prose body). Both validate against lib/specialists/prompt-schema.mjs before
|
|
8
|
+
* declaring success, so a scaffolded file passes `lint:prompts` immediately.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import { splitFrontmatter, validatePromptContent, PERSPECTIVE_FIELDS } from './prompt-schema.mjs';
|
|
15
|
+
|
|
16
|
+
function promptRelPath(role) {
|
|
17
|
+
return path.join('specialists', 'prompts', `cx-${role}.md`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a canonical skeleton for a new specialist. Body sections are stubbed
|
|
22
|
+
* with a one-line placeholder so the file is valid-shaped from creation.
|
|
23
|
+
*/
|
|
24
|
+
export function renderSkeleton({ role, perspective = {}, roleGuidance } = {}) {
|
|
25
|
+
const fm = {
|
|
26
|
+
name: `cx-${role}`,
|
|
27
|
+
role,
|
|
28
|
+
version: 1,
|
|
29
|
+
perspective: {
|
|
30
|
+
bias: perspective.bias || 'TODO: what this role is instinctively suspicious of',
|
|
31
|
+
tension: perspective.tension || 'cx-TODO',
|
|
32
|
+
openingQuestion: perspective.openingQuestion || 'TODO: the first diagnostic question',
|
|
33
|
+
failureMode: perspective.failureMode || 'TODO: how this role fails when absent',
|
|
34
|
+
},
|
|
35
|
+
...(roleGuidance ? { roleGuidance } : {}),
|
|
36
|
+
};
|
|
37
|
+
const body = [
|
|
38
|
+
'## Orientation',
|
|
39
|
+
'',
|
|
40
|
+
'TODO: the role voice — the hard-won instinct that makes this specialist worth consulting.',
|
|
41
|
+
'',
|
|
42
|
+
'## Anti-fabrication contract',
|
|
43
|
+
'',
|
|
44
|
+
'Every load-bearing claim cites a source the reader can re-verify. When a fact is not in the source, write `unknown` or `[unverified]`. See `rules/common/no-fabrication.md`.',
|
|
45
|
+
'',
|
|
46
|
+
'## Output format',
|
|
47
|
+
'',
|
|
48
|
+
'TODO: the artifact this role produces, or the `get_template(...)` it delegates to.',
|
|
49
|
+
'',
|
|
50
|
+
].join('\n');
|
|
51
|
+
return `---\n${yaml.dump(fm).trimEnd()}\n---\n\n${body}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a specialist prompt draft on disk. Refuses to overwrite. Validates
|
|
56
|
+
* the rendered skeleton before writing.
|
|
57
|
+
* @returns {{ path: string, relPath: string }}
|
|
58
|
+
*/
|
|
59
|
+
export function createSpecialistDraft({ rootDir = process.cwd(), role, perspective, roleGuidance } = {}) {
|
|
60
|
+
if (!role || !/^[a-z][a-z0-9-]*$/.test(role)) {
|
|
61
|
+
throw new Error(`invalid role id "${role}" — use lowercase kebab-case (e.g. performance-auditor)`);
|
|
62
|
+
}
|
|
63
|
+
const relPath = promptRelPath(role);
|
|
64
|
+
const filePath = path.join(rootDir, relPath);
|
|
65
|
+
if (fs.existsSync(filePath)) throw new Error(`${relPath} already exists — refusing to overwrite`);
|
|
66
|
+
|
|
67
|
+
const content = renderSkeleton({ role, perspective, roleGuidance });
|
|
68
|
+
const { errors } = validatePromptContent({ content, id: `cx-${role}` });
|
|
69
|
+
if (errors.length) throw new Error(`scaffold failed validation:\n ${errors.join('\n ')}`);
|
|
70
|
+
|
|
71
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
72
|
+
fs.writeFileSync(filePath, content.endsWith('\n') ? content : `${content}\n`);
|
|
73
|
+
return { path: filePath, relPath };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Edit a specialist's frontmatter only (never the prose body). Supported edits:
|
|
78
|
+
* set a perspective field, add a role overlay, or bump the version.
|
|
79
|
+
* @returns {{ path: string, frontmatter: object }}
|
|
80
|
+
*/
|
|
81
|
+
export function editSpecialistFrontmatter({ rootDir = process.cwd(), role, setPerspective = {}, addOverlay, bumpVersion = false } = {}) {
|
|
82
|
+
const relPath = promptRelPath(role);
|
|
83
|
+
const filePath = path.join(rootDir, relPath);
|
|
84
|
+
if (!fs.existsSync(filePath)) throw new Error(`${relPath} does not exist`);
|
|
85
|
+
|
|
86
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
87
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
88
|
+
if (!frontmatter) throw new Error(`${relPath} has no frontmatter — convert it to the hybrid format first`);
|
|
89
|
+
|
|
90
|
+
for (const [field, value] of Object.entries(setPerspective)) {
|
|
91
|
+
if (!PERSPECTIVE_FIELDS.includes(field)) throw new Error(`unknown perspective field "${field}"`);
|
|
92
|
+
frontmatter.perspective = { ...(frontmatter.perspective || {}), [field]: value };
|
|
93
|
+
}
|
|
94
|
+
if (addOverlay) {
|
|
95
|
+
const overlays = new Set(Array.isArray(frontmatter.roleOverlays) ? frontmatter.roleOverlays : []);
|
|
96
|
+
overlays.add(addOverlay);
|
|
97
|
+
frontmatter.roleOverlays = [...overlays];
|
|
98
|
+
}
|
|
99
|
+
if (bumpVersion) {
|
|
100
|
+
frontmatter.version = (Number.isInteger(frontmatter.version) ? frontmatter.version : 0) + 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const next = `---\n${yaml.dump(frontmatter).trimEnd()}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
|
|
104
|
+
const { errors } = validatePromptContent({ content: next, id: `cx-${role}` });
|
|
105
|
+
if (errors.length) throw new Error(`edit would break validation:\n ${errors.join('\n ')}`);
|
|
106
|
+
|
|
107
|
+
fs.writeFileSync(filePath, next.endsWith('\n') ? next : `${next}\n`);
|
|
108
|
+
return { path: filePath, frontmatter };
|
|
109
|
+
}
|