@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.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/bin/construct +266 -13
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/audit-trail.mjs +77 -10
  7. package/lib/beads-client.mjs +9 -0
  8. package/lib/beads-optimistic.mjs +23 -71
  9. package/lib/bridges/copilot-proxy.mjs +116 -0
  10. package/lib/cli-commands.mjs +112 -1
  11. package/lib/comment-lint.mjs +1 -1
  12. package/lib/config/schema.mjs +1 -1
  13. package/lib/demo.mjs +245 -0
  14. package/lib/diagram.mjs +300 -0
  15. package/lib/doctor/index.mjs +1 -1
  16. package/lib/doctor/watchers/process-pressure.mjs +1 -1
  17. package/lib/doctor/watchers/service-health.mjs +1 -1
  18. package/lib/document-extract/docling-client.mjs +16 -6
  19. package/lib/document-extract/docling-sidecar.py +32 -2
  20. package/lib/document-extract.mjs +85 -10
  21. package/lib/document-ingest.mjs +97 -7
  22. package/lib/embed/roadmap.mjs +16 -1
  23. package/lib/embed/semantic.mjs +5 -2
  24. package/lib/engine/consolidate.mjs +160 -3
  25. package/lib/engine/contradiction-judge.mjs +71 -0
  26. package/lib/engine/contradiction.mjs +74 -0
  27. package/lib/handoffs/cleanup.mjs +1 -1
  28. package/lib/hooks/audit-trail.mjs +14 -28
  29. package/lib/host-capabilities.mjs +30 -0
  30. package/lib/ingest/docling-remote.mjs +90 -0
  31. package/lib/ingest/strategy.mjs +1 -1
  32. package/lib/init-unified.mjs +9 -13
  33. package/lib/logging/rotate.mjs +18 -0
  34. package/lib/mcp/server.mjs +124 -12
  35. package/lib/mcp/tool-budget.mjs +53 -0
  36. package/lib/mcp-catalog.json +1 -1
  37. package/lib/model-router.mjs +52 -1
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration-policy.mjs +41 -6
  43. package/lib/persona-sections.mjs +66 -0
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +14 -9
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reflect/extractor.mjs +14 -1
  48. package/lib/reflect/salience.mjs +65 -0
  49. package/lib/rules-delivery.mjs +122 -0
  50. package/lib/runtime/uv-bootstrap.mjs +32 -17
  51. package/lib/server/index.mjs +1 -1
  52. package/lib/server/langfuse-login.mjs +3 -3
  53. package/lib/service-manager.mjs +42 -4
  54. package/lib/session-store.mjs +1 -1
  55. package/lib/setup.mjs +58 -2
  56. package/lib/specialists/prompt-schema.mjs +162 -0
  57. package/lib/specialists/scaffold.mjs +109 -0
  58. package/lib/storage/embeddings-engine.mjs +19 -5
  59. package/lib/storage/embeddings-local.mjs +6 -3
  60. package/lib/storage/file-lock.mjs +18 -11
  61. package/lib/telemetry/beads-fallback.mjs +40 -0
  62. package/lib/telemetry/hook-calls.mjs +138 -0
  63. package/package.json +4 -2
  64. package/personas/construct.md +12 -12
  65. package/platforms/capabilities.json +76 -0
  66. package/platforms/opencode/sync-config.mjs +121 -25
  67. package/rules/common/neurodivergent-output.md +1 -1
  68. package/rules/web/coding-style.md +8 -0
  69. package/rules/web/design-quality.md +8 -0
  70. package/rules/web/hooks.md +8 -0
  71. package/rules/web/patterns.md +8 -0
  72. package/rules/web/performance.md +8 -0
  73. package/rules/web/security.md +8 -0
  74. package/rules/web/testing.md +8 -0
  75. package/scripts/sync-specialists.mjs +277 -63
  76. package/skills/docs/init-project.md +1 -1
  77. package/specialists/prompts/cx-architect.md +20 -0
  78. package/specialists/prompts/cx-test-automation.md +12 -0
  79. package/templates/docs/construct_guide.md +2 -2
  80. package/lib/ingest/chunker.mjs +0 -94
  81. package/lib/ingest/pipeline.mjs +0 -53
  82. package/lib/ingest/store.mjs +0 -82
  83. package/lib/mode-commands.mjs +0 -122
  84. package/lib/policy/unified-gates.mjs +0 -96
  85. package/lib/profiles/validate-custom.mjs +0 -114
  86. package/lib/services/telemetry-backend.mjs +0 -177
  87. 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 = 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
  }
@@ -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 up` rewrites managed values in
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 up` seeds Langfuse with deterministic admin creds via the
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 up\` first.</pre>`);
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 up\` first.</pre>`);
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
  }
@@ -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 up` —
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 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
  }
@@ -350,7 +350,7 @@ export function buildResumeContext(session) {
350
350
  }
351
351
 
352
352
  /**
353
- * Close all active sessions (called on construct down).
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
+ }