@geraldmaron/construct 1.0.24 → 1.1.0
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/bin/construct +185 -3
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- 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 +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.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 +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- 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/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/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- 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/service-manager.mjs +41 -3
- package/lib/setup.mjs +21 -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/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +1 -1
- package/personas/construct.md +1 -1
- 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 +139 -39
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
|
@@ -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/service-manager.mjs
CHANGED
|
@@ -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/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] [--no-launch-agent] [--reconfigure] [--with-docling]
|
|
44
44
|
|
|
45
45
|
Flags:
|
|
46
46
|
--scope=<s> project | user | both (default: project — see ADR-0029)
|
|
@@ -52,6 +52,9 @@ Flags:
|
|
|
52
52
|
--yes accept detected defaults without prompting
|
|
53
53
|
--no-launch-agent skip macOS LaunchAgent background service registration
|
|
54
54
|
--reconfigure re-prompt for service consent, ignoring cached answers
|
|
55
|
+
--with-docling eagerly provision the docling document-extraction venv now
|
|
56
|
+
(heavy, ~10 min via uv; otherwise provisioned lazily on
|
|
57
|
+
first document ingest)
|
|
55
58
|
|
|
56
59
|
What --scope=user does:
|
|
57
60
|
- creates ~/.construct/config.env
|
|
@@ -313,7 +316,8 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
313
316
|
return;
|
|
314
317
|
}
|
|
315
318
|
|
|
316
|
-
const KNOWN_FLAGS = new Set(['--yes', '--no-launch-agent', '--reconfigure', '--help', '-h']);
|
|
319
|
+
const KNOWN_FLAGS = new Set(['--yes', '--no-launch-agent', '--reconfigure', '--with-docling', '--help', '-h']);
|
|
320
|
+
const withDocling = argSet.has('--with-docling');
|
|
317
321
|
const unknownFlags = args.filter((a) => {
|
|
318
322
|
if (!a.startsWith('-')) return false;
|
|
319
323
|
if (a.startsWith('--scope=') || a === '--scope') return false;
|
|
@@ -392,6 +396,21 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
392
396
|
console.log(`Embeddings: warmup skipped (${err?.message || 'unknown error'}) — model will load on first use`);
|
|
393
397
|
}
|
|
394
398
|
|
|
399
|
+
// Docling (document/PDF extraction) provisions a pinned Python venv via uv,
|
|
400
|
+
// which is heavy (~10 min) and only needed for document ingest — so it stays
|
|
401
|
+
// lazy by default and is provisioned eagerly only when the user opts in with
|
|
402
|
+
// --with-docling. First document ingest still auto-provisions if skipped.
|
|
403
|
+
if (withDocling) {
|
|
404
|
+
try {
|
|
405
|
+
const { ensureDoclingVenv } = await import('./runtime/uv-bootstrap.mjs');
|
|
406
|
+
console.log('Docling: provisioning Python venv (uv) — this can take several minutes…');
|
|
407
|
+
const docling = await ensureDoclingVenv();
|
|
408
|
+
console.log(`Docling: ready (${docling.fresh ? 'provisioned' : 'already present'} at ${docling.venvDir})`);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.log(`Docling: provisioning skipped (${err?.message || 'unknown error'}) — will provision on first document ingest`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
395
414
|
const { isCheapestProviderEnabled, selectCheapestForAllTiers, setCheapestProviderPreference } =
|
|
396
415
|
await import('./model-cheapest-provider.mjs');
|
|
397
416
|
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
|
+
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* - local → ONNX Transformers (Xenova/all-MiniLM-L6-v2, 384d) — DEFAULT
|
|
9
9
|
* - openai → OpenAI API (text-embedding-3-small, 1536d)
|
|
10
10
|
* - ollama → Ollama server (nomic-embed-text, 768d)
|
|
11
|
-
* - hashing → Deterministic bag-of-words (256d) —
|
|
11
|
+
* - hashing → Deterministic bag-of-words (256d) — opt-in via the id only; never
|
|
12
|
+
* a silent fallback (256d has no semantics and mismatches the 384d
|
|
13
|
+
* vector schema, so an accidental fall-through degrades retrieval
|
|
14
|
+
* invisibly). An unknown CONSTRUCT_EMBEDDING_MODEL fails loud.
|
|
12
15
|
*
|
|
13
16
|
* OpenAI policy:
|
|
14
17
|
* - When CONSTRUCT_EMBEDDING_MODEL=openai and OPENAI_API_KEY is missing,
|
|
@@ -28,6 +31,8 @@ const ADAPTERS = {
|
|
|
28
31
|
hashing: () => import('./embeddings-legacy.mjs'),
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
const KNOWN_MODELS = new Set(Object.keys(ADAPTERS));
|
|
35
|
+
|
|
31
36
|
const DEFAULT_MODEL = 'local';
|
|
32
37
|
|
|
33
38
|
let openaiFallbackWarned = false;
|
|
@@ -39,7 +44,16 @@ let openaiFallbackWarned = false;
|
|
|
39
44
|
*/
|
|
40
45
|
function resolveModelId(env) {
|
|
41
46
|
const requested = (env.CONSTRUCT_EMBEDDING_MODEL || DEFAULT_MODEL).toLowerCase();
|
|
42
|
-
if (requested !== 'openai')
|
|
47
|
+
if (requested !== 'openai') {
|
|
48
|
+
if (!KNOWN_MODELS.has(requested)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`CONSTRUCT_EMBEDDING_MODEL='${requested}' is not a known embedding model ` +
|
|
51
|
+
`(${[...KNOWN_MODELS].join('|')}). Fix the value — falling through to the 256d ` +
|
|
52
|
+
`hashing adapter would silently degrade retrieval and mismatch the vector schema.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return requested;
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
if (env.OPENAI_API_KEY) return 'openai';
|
|
45
59
|
|
|
@@ -69,7 +83,7 @@ function resolveModelId(env) {
|
|
|
69
83
|
*/
|
|
70
84
|
export async function embedText(text, { env = process.env } = {}) {
|
|
71
85
|
const modelId = resolveModelId(env);
|
|
72
|
-
const loader = ADAPTERS[modelId]
|
|
86
|
+
const loader = ADAPTERS[modelId];
|
|
73
87
|
const { embed } = await loader();
|
|
74
88
|
return embed(text, { env });
|
|
75
89
|
}
|
|
@@ -82,7 +96,7 @@ export async function embedText(text, { env = process.env } = {}) {
|
|
|
82
96
|
*/
|
|
83
97
|
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
84
98
|
const modelId = resolveModelId(env);
|
|
85
|
-
const loader = ADAPTERS[modelId]
|
|
99
|
+
const loader = ADAPTERS[modelId];
|
|
86
100
|
const { embedBatch } = await loader();
|
|
87
101
|
return embedBatch(texts, { env });
|
|
88
102
|
}
|
|
@@ -94,7 +108,7 @@ export async function embedBatch(texts, { env = process.env } = {}) {
|
|
|
94
108
|
*/
|
|
95
109
|
export async function getEmbeddingModelInfo({ env = process.env } = {}) {
|
|
96
110
|
const modelId = resolveModelId(env);
|
|
97
|
-
const loader = ADAPTERS[modelId]
|
|
111
|
+
const loader = ADAPTERS[modelId];
|
|
98
112
|
const { getModelInfo } = await loader();
|
|
99
113
|
return getModelInfo({ env });
|
|
100
114
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/beads-fallback.mjs — count legacy-lock fallback firings.
|
|
3
|
+
*
|
|
4
|
+
* The beads write path is optimistic by default; the exclusive file-lock runs only
|
|
5
|
+
* as the PATH-3 fallback after optimistic retries exhaust. Whether that fallback
|
|
6
|
+
* ever fires in practice is the evidence that decides if the lock + wait-queue can
|
|
7
|
+
* be retired (bead construct-nhn5): one JSON line per firing to
|
|
8
|
+
* ~/.cx/beads-fallback.jsonl. Zero entries over a representative window = safe to
|
|
9
|
+
* remove; entries name the bd commands that actually contend.
|
|
10
|
+
*
|
|
11
|
+
* Errors here are non-fatal — telemetry must never add a failure mode to the
|
|
12
|
+
* fallback it observes.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { appendBounded } from '../logging/rotate.mjs';
|
|
17
|
+
import { cxDir } from '../paths.mjs';
|
|
18
|
+
|
|
19
|
+
export function defaultLogPath() {
|
|
20
|
+
return path.join(cxDir(), 'beads-fallback.jsonl');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function logBeadsFallback(event, opts = {}) {
|
|
24
|
+
const logPath = opts.logPath || defaultLogPath();
|
|
25
|
+
const entry = {
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
command: event?.command || 'unknown',
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
31
|
+
appendBounded('beads-fallback', logPath, JSON.stringify(entry) + '\n');
|
|
32
|
+
} catch { /* best-effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBeadsFallbacks({ logPath = defaultLogPath() } = {}) {
|
|
36
|
+
if (!fs.existsSync(logPath)) return [];
|
|
37
|
+
return fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean).map((l) => {
|
|
38
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
39
|
+
}).filter(Boolean);
|
|
40
|
+
}
|