@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.
Files changed (60) hide show
  1. package/bin/construct +185 -3
  2. package/lib/agent-instructions/inject.mjs +25 -4
  3. package/lib/audit-rules.mjs +127 -0
  4. package/lib/audit-skills.mjs +43 -1
  5. package/lib/beads-client.mjs +9 -0
  6. package/lib/beads-optimistic.mjs +23 -71
  7. package/lib/bridges/copilot-proxy.mjs +116 -0
  8. package/lib/cli-commands.mjs +5 -1
  9. package/lib/comment-lint.mjs +1 -1
  10. package/lib/config/schema.mjs +1 -1
  11. package/lib/document-extract/docling-client.mjs +16 -6
  12. package/lib/document-extract/docling-sidecar.py +32 -2
  13. package/lib/document-extract.mjs +37 -10
  14. package/lib/document-ingest.mjs +90 -5
  15. package/lib/embed/roadmap.mjs +16 -1
  16. package/lib/engine/consolidate.mjs +160 -3
  17. package/lib/engine/contradiction-judge.mjs +71 -0
  18. package/lib/engine/contradiction.mjs +74 -0
  19. package/lib/host-capabilities.mjs +30 -0
  20. package/lib/ingest/docling-remote.mjs +90 -0
  21. package/lib/ingest/strategy.mjs +1 -1
  22. package/lib/init-unified.mjs +9 -13
  23. package/lib/logging/rotate.mjs +18 -0
  24. package/lib/mcp/server.mjs +124 -12
  25. package/lib/mcp/tool-budget.mjs +53 -0
  26. package/lib/mcp-catalog.json +1 -1
  27. package/lib/ollama/capability-store.mjs +78 -0
  28. package/lib/ollama/provision-context.mjs +344 -0
  29. package/lib/opencode-config.mjs +148 -0
  30. package/lib/opencode-telemetry.mjs +7 -0
  31. package/lib/orchestration-policy.mjs +41 -6
  32. package/lib/platforms/capabilities.mjs +100 -0
  33. package/lib/prompt-composer.js +12 -8
  34. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  35. package/lib/reflect/extractor.mjs +14 -1
  36. package/lib/reflect/salience.mjs +65 -0
  37. package/lib/rules-delivery.mjs +122 -0
  38. package/lib/runtime/uv-bootstrap.mjs +32 -17
  39. package/lib/service-manager.mjs +41 -3
  40. package/lib/setup.mjs +21 -2
  41. package/lib/specialists/prompt-schema.mjs +162 -0
  42. package/lib/specialists/scaffold.mjs +109 -0
  43. package/lib/storage/embeddings-engine.mjs +19 -5
  44. package/lib/telemetry/beads-fallback.mjs +40 -0
  45. package/lib/telemetry/hook-calls.mjs +138 -0
  46. package/package.json +1 -1
  47. package/personas/construct.md +1 -1
  48. package/platforms/capabilities.json +76 -0
  49. package/platforms/opencode/sync-config.mjs +121 -25
  50. package/rules/common/neurodivergent-output.md +1 -1
  51. package/rules/web/coding-style.md +8 -0
  52. package/rules/web/design-quality.md +8 -0
  53. package/rules/web/hooks.md +8 -0
  54. package/rules/web/patterns.md +8 -0
  55. package/rules/web/performance.md +8 -0
  56. package/rules/web/security.md +8 -0
  57. package/rules/web/testing.md +8 -0
  58. package/scripts/sync-specialists.mjs +139 -39
  59. package/specialists/prompts/cx-architect.md +20 -0
  60. 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 = spawnSync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | 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 = spawnSync(uv, ['venv', venvDir, '--python', '3.11'], {
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 = spawnSync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], {
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
  }
@@ -159,10 +159,15 @@ async function isMemoryRunning(port) {
159
159
  return probeRuntimePort(port);
160
160
  }
161
161
 
162
- async function isOpenCodeRunning(port) {
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 = isOpenCodeRunning,
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 = isOpenCodeRunning,
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) — fallback / test fixture only
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') return requested;
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] || ADAPTERS.hashing;
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] || ADAPTERS.hashing;
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] || ADAPTERS.hashing;
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
+ }