@geraldmaron/construct 1.0.6 → 1.0.8

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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
package/bin/construct CHANGED
@@ -19,7 +19,7 @@ import { readCurrentModels, readOpenRouterApiKeyFromOpenCodeConfig, applyToEnv,
19
19
  import { pollFreeModels, topForTier, selectForTier } from '../lib/model-free-selector.mjs';
20
20
  import { cmdMcpList, cmdMcpAdd, cmdMcpRemove, cmdMcpInfo } from '../lib/mcp-manager.mjs';
21
21
  import { cmdOllama } from '../lib/ollama-manager.mjs';
22
- import { printHostCapabilities } from '../lib/host-capabilities.mjs';
22
+ import { printHostCapabilities, formatHostCapabilitiesJson } from '../lib/host-capabilities.mjs';
23
23
  import { getPluginById, initPluginManifest, loadPluginRegistry } from '../lib/plugin-registry.mjs';
24
24
  import { runSetup } from '../lib/setup.mjs';
25
25
  import { runUpdate } from '../lib/update.mjs';
@@ -249,7 +249,7 @@ async function cmdShow() {
249
249
 
250
250
  async function cmdSync(args) {
251
251
  generateCompletions();
252
- runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-agents.mjs'), args);
252
+ runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs'), args);
253
253
  if (args.includes('--no-docs')) return;
254
254
  const { regenerateDocs } = await import('../lib/auto-docs.mjs');
255
255
  const { changed } = await regenerateDocs({ rootDir: ROOT_DIR });
@@ -277,7 +277,7 @@ async function cmdCompletions(args) {
277
277
  }
278
278
 
279
279
  async function cmdList() {
280
- const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
280
+ const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
281
281
  println('Construct Agents\n');
282
282
  println('Personas:');
283
283
  for (const persona of registry.personas ?? []) {
@@ -292,7 +292,7 @@ async function cmdList() {
292
292
  async function cmdDoctor() {
293
293
  const args = process.argv.slice(3);
294
294
 
295
- const DAEMON_SUBS = new Set(['status', 'watch', 'stop', 'logs', 'tick', 'report']);
295
+ const DAEMON_SUBS = new Set(['status', 'watch', 'stop', 'logs', 'tick', 'report', 'consistency']);
296
296
  if (args.length > 0 && DAEMON_SUBS.has(args[0])) {
297
297
  const { runCli } = await import('../lib/doctor/cli.mjs');
298
298
  const code = await runCli(args);
@@ -418,8 +418,8 @@ async function cmdDoctor() {
418
418
 
419
419
  const checks = [];
420
420
  const add = (label, pass, optional = false) => checks.push({ label, pass, optional });
421
- add('registry.json exists', fs.existsSync(path.join(ROOT_DIR, 'agents', 'registry.json')));
422
- add('sync-agents.mjs exists', fs.existsSync(path.join(ROOT_DIR, 'scripts', 'sync-agents.mjs')));
421
+ add('registry.json exists', fs.existsSync(path.join(ROOT_DIR, 'specialists', 'registry.json')));
422
+ add('sync-specialists.mjs exists', fs.existsSync(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs')));
423
423
 
424
424
  // Tier model selection. Construct ships with no default — at least
425
425
  // one tier must be configured (registry.json primary OR CX_MODEL_*
@@ -456,7 +456,14 @@ async function cmdDoctor() {
456
456
  add('Claude Code agents dir', fs.existsSync(path.join(HOME, '.claude', 'agents')), true);
457
457
  add('Codex agents dir', fs.existsSync(path.join(HOME, '.codex', 'agents')), true);
458
458
  add('Copilot prompts dir', fs.existsSync(path.join(HOME, '.github', 'prompts')), true);
459
- add('Cursor MCP config', fs.existsSync(path.join(HOME, '.cursor', 'mcp.json')), true);
459
+ // Only check Cursor MCP config when Cursor is actually installed (its home
460
+ // directory exists). Absent Cursor, no warning — the adapter parity check
461
+ // already surfaces 'cursor: not installed' for informational purposes.
462
+ const cursorHomeDir = path.join(HOME, '.cursor');
463
+ if (fs.existsSync(cursorHomeDir)) {
464
+ add('Cursor MCP config', fs.existsSync(path.join(cursorHomeDir, 'mcp.json')), true);
465
+ }
466
+
460
467
  const vscodeSettingsPaths = process.platform === 'darwin'
461
468
  ? [
462
469
  path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
@@ -471,11 +478,35 @@ async function cmdDoctor() {
471
478
  path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code', 'User', 'settings.json'),
472
479
  path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code - Insiders', 'User', 'settings.json'),
473
480
  ];
474
- add('VS Code settings file', vscodeSettingsPaths.some((candidate) => fs.existsSync(candidate)), true);
481
+ // Only check VS Code settings when VS Code is actually installed. On macOS,
482
+ // Code's Application Support directory is the installation signal.
483
+ const vscodeAppSupportRoots = process.platform === 'darwin'
484
+ ? [
485
+ path.join(HOME, 'Library', 'Application Support', 'Code'),
486
+ path.join(HOME, 'Library', 'Application Support', 'Code - Insiders'),
487
+ ]
488
+ : process.platform === 'linux'
489
+ ? [path.join(HOME, '.config', 'Code'), path.join(HOME, '.config', 'Code - Insiders')]
490
+ : [
491
+ path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code'),
492
+ path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code - Insiders'),
493
+ ];
494
+ if (vscodeAppSupportRoots.some((r) => fs.existsSync(r))) {
495
+ // VS Code is installed but has no settings.json — the user needs to create
496
+ // one manually or run `construct sync` after VS Code sync support lands.
497
+ const vscodeSettingsOk = vscodeSettingsPaths.some((candidate) => fs.existsSync(candidate));
498
+ add(
499
+ vscodeSettingsOk
500
+ ? 'VS Code settings file'
501
+ : 'VS Code settings missing — create settings.json or wait for `construct sync` VS Code support',
502
+ vscodeSettingsOk,
503
+ true,
504
+ );
505
+ }
475
506
  add('User config ready', fs.existsSync(getUserEnvPath(HOME)) || fs.existsSync(path.join(HOME, '.construct')), true);
476
507
  add('skills/ directory', fs.existsSync(path.join(ROOT_DIR, 'skills')));
477
508
  add('rules/common/', fs.existsSync(path.join(ROOT_DIR, 'rules', 'common')));
478
- add('agents/prompts/ directory', fs.existsSync(path.join(ROOT_DIR, 'agents', 'prompts')));
509
+ add('specialists/prompts/ directory', fs.existsSync(path.join(ROOT_DIR, 'specialists', 'prompts')));
479
510
  add('Hybrid storage env keys documented', fs.existsSync(path.join(ROOT_DIR, '.env.example')));
480
511
  fs.mkdirSync(path.join(HOME, '.cx', 'performance-reviews'), { recursive: true });
481
512
  add('~/.cx/performance-reviews/ ready', true);
@@ -577,45 +608,20 @@ async function cmdDoctor() {
577
608
  add(`Resource budget check failed: ${err.message}`, false, true);
578
609
  }
579
610
 
580
- // Pricing-catalog freshness + divergence. Cost ceilings (Phase 7b)
581
- // are only as trustworthy as the catalog underneath. Stale catalog →
582
- // drift between computed cost and actual provider bill. Divergence
583
- // (>25%) → LiteLLM and static disagree on a known model, surface so
584
- // the operator can verify which is current. Both advisory: degraded
585
- // pricing still falls back to a usable source.
586
- try {
587
- const { describePricingCatalogFreshness, checkPricingDivergence } = await import('../lib/telemetry/model-pricing-catalog.mjs');
588
- const freshness = describePricingCatalogFreshness();
589
- if (freshness.stale) {
590
- add(`Pricing catalog: ${freshness.message}`, false, true);
591
- } else {
592
- add(`Pricing catalog fresh (${freshness.ageHours?.toFixed(1) || '0.0'}h old)`, true, true);
593
- }
594
- if (freshness.present) {
595
- try {
596
- const cached = JSON.parse(fs.readFileSync(path.join(HOME, '.cx', 'pricing-cache.json'), 'utf8'));
597
- const divergences = checkPricingDivergence(cached.models || []);
598
- if (divergences.length === 0) {
599
- add('Pricing matches static fallback (no >25% divergence)', true, true);
600
- } else {
601
- add(`Pricing divergence on ${divergences.length} model(s) — run \`construct pricing check\``, false, true);
602
- }
603
- } catch { /* divergence is advisory */ }
604
- }
605
- } catch (err) {
606
- add(`Pricing catalog check failed: ${err.message}`, false, true);
607
- }
611
+ // Pricing-catalog freshness + divergence are not surfaced in doctor output.
612
+ // The underlying ledger writes and catalog refresh continue; consumer
613
+ // surfaces land with the OTel + dashboard wiring (plan Workstream J).
608
614
 
609
615
  // Validate promptFile references and skill bindings
610
616
  try {
611
- const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
612
- const brokenPromptFiles = (registry.agents ?? []).filter((a) => {
613
- if (!a.promptFile) return false;
614
- return !fs.existsSync(path.join(ROOT_DIR, a.promptFile));
617
+ const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
618
+ const brokenPromptFiles = (registry.specialists ?? []).filter((s) => {
619
+ if (!s.promptFile) return false;
620
+ return !fs.existsSync(path.join(ROOT_DIR, s.promptFile));
615
621
  });
616
- add('All agent promptFiles resolve', brokenPromptFiles.length === 0);
622
+ add('All specialist promptFiles resolve', brokenPromptFiles.length === 0);
617
623
  const headhuntClassifierAgents = ['cx-docs-keeper', 'cx-researcher', 'cx-architect', 'cx-security', 'cx-engineer'];
618
- const registryNames = new Set((registry.agents ?? []).map((a) => `cx-${a.name}`));
624
+ const registryNames = new Set((registry.specialists ?? []).map((s) => `cx-${s.name}`));
619
625
  const classifierOrphans = headhuntClassifierAgents.filter((n) => !registryNames.has(n));
620
626
  add('Headhunt classifier agents exist in registry', classifierOrphans.length === 0);
621
627
  const skillAudit = auditSkills({ rootDir: ROOT_DIR, silent: true });
@@ -626,12 +632,24 @@ async function cmdDoctor() {
626
632
 
627
633
  const sqlConfig = describeSqlStore(process.env);
628
634
  const sqlClient = createSqlClient(process.env);
635
+ // Track SQL reachability so the schema-migration check can cascade correctly
636
+ // rather than emitting a redundant second warning for the same offline server.
637
+ let sqlReachable = false;
629
638
  if (sqlConfig.configured && sqlClient) {
630
639
  try {
631
640
  await sqlClient`select 1 as ok`;
641
+ sqlReachable = true;
632
642
  add('SQL backend reachable', true);
633
643
  } catch {
634
- add('SQL backend reachable', false, true);
644
+ // In solo mode the local vector index is the active storage path, so an
645
+ // unreachable Postgres is expected unless the user has started it. Emit a
646
+ // contextual message rather than a bare ⚠ so the fix is obvious.
647
+ const { getDeploymentMode } = await import('../lib/deployment-mode.mjs');
648
+ const mode = getDeploymentMode(process.env) ?? 'solo';
649
+ const hint = mode === 'solo'
650
+ ? ' (solo mode — run `construct dev` to start Postgres)'
651
+ : ' — check DATABASE_URL and that the server is running';
652
+ add(`SQL backend unreachable${hint}`, false, true);
635
653
  } finally {
636
654
  await closeSqlClient(sqlClient);
637
655
  }
@@ -716,7 +734,7 @@ async function cmdDoctor() {
716
734
  }
717
735
 
718
736
  try {
719
- const { recentViolations } = await import('../lib/agent-contracts-enforce.mjs');
737
+ const { recentViolations } = await import('../lib/specialist-contracts-enforce.mjs');
720
738
  const violations = recentViolations({ windowMs: 24 * 60 * 60 * 1000 });
721
739
  const label = violations.length === 0
722
740
  ? 'Contract violations (none in last 24h)'
@@ -761,7 +779,11 @@ async function cmdDoctor() {
761
779
  add('Observation size', false, true);
762
780
  }
763
781
 
764
- if (sqlConfig.configured) {
782
+ // Only run the schema migration check when SQL is both configured AND
783
+ // currently reachable. If the SQL check above already failed (sqlReachable
784
+ // is false) this check will also fail with the same ECONNREFUSED and would
785
+ // produce a redundant second warning about the same offline server.
786
+ if (sqlConfig.configured && sqlReachable) {
765
787
  try {
766
788
  const migClient = createSqlClient(process.env);
767
789
  if (migClient) {
@@ -850,7 +872,7 @@ async function cmdDoctor() {
850
872
  }
851
873
 
852
874
  // Agent contracts integrity
853
- const { getAllContracts, summarize } = await import('../lib/agent-contracts.mjs');
875
+ const { getAllContracts, summarize } = await import('../lib/specialist-contracts.mjs');
854
876
  const contracts = getAllContracts();
855
877
  add('Agent contracts loaded', contracts.length > 0);
856
878
  if (contracts.length > 0) {
@@ -1053,6 +1075,59 @@ async function cmdVersion() {
1053
1075
  println(`construct v${pkg.version}`);
1054
1076
  }
1055
1077
 
1078
+ async function cmdMigrate(args) {
1079
+ const dryRun = args.includes('--dry-run');
1080
+ const pathArg = args.find((a) => a.startsWith('--path='))?.split('=')[1];
1081
+ const fromArg = args.find((a) => a.startsWith('--from='))?.split('=')[1];
1082
+ const { runMigrations, planMigrations, CURRENT_SCHEMA_VERSION } = await import('../lib/migrations/index.mjs');
1083
+
1084
+ const candidates = pathArg
1085
+ ? [pathArg]
1086
+ : [
1087
+ path.join(ROOT_DIR, 'specialists', 'registry.json'),
1088
+ path.join(ROOT_DIR, 'specialists', 'contracts.json'),
1089
+ path.join(ROOT_DIR, 'specialists', 'role-manifests.json'),
1090
+ path.join(process.cwd(), '.cx', 'config.json'),
1091
+ ];
1092
+
1093
+ let anyFailed = false;
1094
+ for (const artifactPath of candidates) {
1095
+ if (!fs.existsSync(artifactPath)) continue;
1096
+ let from;
1097
+ try {
1098
+ const data = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
1099
+ from = typeof data?.version === 'number' ? data.version : Number(fromArg) || 0;
1100
+ } catch {
1101
+ println(` ⚠ ${artifactPath}: parse error, skipping`);
1102
+ continue;
1103
+ }
1104
+
1105
+ const plan = planMigrations(from, CURRENT_SCHEMA_VERSION);
1106
+ if (plan === null) {
1107
+ println(` ✗ ${artifactPath}: no migration path from v${from} to v${CURRENT_SCHEMA_VERSION}`);
1108
+ anyFailed = true;
1109
+ continue;
1110
+ }
1111
+ if (plan.length === 0) {
1112
+ println(` ✓ ${artifactPath}: already at v${CURRENT_SCHEMA_VERSION}`);
1113
+ continue;
1114
+ }
1115
+
1116
+ println(` ${dryRun ? '·' : '→'} ${artifactPath}: ${plan.length} step(s) (v${from} → v${CURRENT_SCHEMA_VERSION})`);
1117
+ const result = await runMigrations({ artifactPath, fromVersion: from, dryRun });
1118
+ for (const step of result.applied) {
1119
+ println(` v${step.from}→v${step.to}: ${step.summary}`);
1120
+ }
1121
+ if (!result.ok) {
1122
+ println(` ✗ ${result.error}`);
1123
+ anyFailed = true;
1124
+ }
1125
+ }
1126
+
1127
+ if (dryRun) println(`\n(dry-run — no files were written)`);
1128
+ if (anyFailed) process.exit(1);
1129
+ }
1130
+
1056
1131
  async function cmdReview(args) {
1057
1132
  fs.mkdirSync(path.join(HOME, '.cx', 'performance-reviews'), { recursive: true });
1058
1133
  const sub = args[0];
@@ -1514,7 +1589,14 @@ async function cmdPricing(args) {
1514
1589
  }
1515
1590
 
1516
1591
  async function cmdHeadhunt(args) { await runHeadhunt({ args, cwd: process.cwd(), homeDir: HOME }); }
1517
- async function cmdHosts() { printHostCapabilities(); }
1592
+ async function cmdHosts() {
1593
+ const args = new Set(process.argv.slice(3));
1594
+ if (args.has('--json')) {
1595
+ process.stdout.write(formatHostCapabilitiesJson());
1596
+ } else {
1597
+ printHostCapabilities();
1598
+ }
1599
+ }
1518
1600
  async function cmdSetup(args) { await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME }); }
1519
1601
 
1520
1602
  function printConfigModeHelp() {
@@ -1550,20 +1632,31 @@ function printActiveConfig() {
1550
1632
  println(`Persisted at: ${getUserEnvPath(HOME)} (${DEPLOYMENT_MODE_ENV_KEY})`);
1551
1633
  }
1552
1634
 
1553
- function printIntakeHelp() {
1554
- println('Construct intake inspect and process the R&D intake queue.');
1635
+ async function printIntakeHelp() {
1636
+ const { getRebrand } = await import('../lib/profiles/rebrand.mjs');
1637
+ const { intakeQueueLabel, signalNoun } = getRebrand(process.cwd());
1638
+ println(`Construct intake — inspect and process the ${intakeQueueLabel}.`);
1555
1639
  println('');
1556
1640
  println('Usage:');
1557
- println(' construct intake list List pending intake packets (ID, type, stage, owner, action).');
1641
+ println(` construct intake list List pending ${signalNoun}s (ID, type, stage, owner, action).`);
1558
1642
  println(' construct intake show <id> Show a single packet: triage, source, excerpt, related docs.');
1559
- println(' construct intake done <id> [--notes=…] Mark a pending packet processed.');
1643
+ println(' construct intake done <id> [--notes=…] [--output=<path>] [--no-output=<rejected|deferred|merged>]');
1644
+ println(' Mark a pending packet processed. --output stamps intake_id + confidence into the artifact frontmatter (provenance).');
1560
1645
  println(' construct intake skip <id> [--reason=…] Mark a pending packet skipped (preserves audit trail).');
1561
1646
  println(' construct intake reopen <id> Move a processed or skipped packet back to pending.');
1647
+ println(' construct intake quarantine list List packets held for human review (low confidence or close margin).');
1648
+ println(' construct intake quarantine show <id> Show a quarantined packet with top-3 candidate types and margins.');
1649
+ println(' construct intake reroute <id> --type=<t> Move a quarantined packet to pending with manual classification.');
1650
+ println(' Writes tests/fixtures/intake/learned/<hash>.json so CI guards against repeats.');
1562
1651
  println(' construct intake metrics Show pipeline volume, velocity, and processing stats.');
1563
1652
  println(' construct intake integrate <id> <system> Push intake to external system (github, jira, confluence).');
1564
1653
  println(' construct intake integrate <id> <system> --create-issue Create an issue from the intake packet.');
1565
1654
  println(' construct intake config Show current watch dirs + scan depth.');
1566
1655
  println(' construct intake config set --depth=N [--add-dir=PATH] [--remove-dir=PATH]');
1656
+ println(' construct intake extraction-warnings [--since=7d] [--kind=table] [--limit=50]');
1657
+ println(' Summarize ~/.cx/intake/extraction-warnings.jsonl (dropped content log).');
1658
+ println(' construct intake needs-asr list List audio/video files awaiting ASR transcription.');
1659
+ println(' construct intake needs-asr show <id> Show a specific ASR-pending packet.');
1567
1660
  println(' Update intake config. --depth caps at the hard limit.');
1568
1661
  println(' construct intake config set --include-project-inbox=on|off');
1569
1662
  println(' construct intake config set --include-docs-intake=on|off');
@@ -1580,11 +1673,12 @@ function parseKvFlags(args, keys) {
1580
1673
  return out;
1581
1674
  }
1582
1675
 
1583
- function formatIntakeTable(rows) {
1676
+ function formatIntakeTable(rows, { intakeQueueLabel, signalNoun } = { intakeQueueLabel: 'Intake queue', signalNoun: 'signal' }) {
1584
1677
  if (rows.length === 0) {
1585
- println('No pending intake packets.');
1678
+ println(`No pending ${signalNoun}s.`);
1586
1679
  return;
1587
1680
  }
1681
+ println(`${intakeQueueLabel} (${rows.length} pending):`);
1588
1682
  const headers = ['ID', 'Type', 'Stage', 'Owner', 'Action'];
1589
1683
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)));
1590
1684
  const fmt = (cells) => cells.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
@@ -1597,12 +1691,14 @@ async function cmdIntake(args) {
1597
1691
  const sub = args[0];
1598
1692
 
1599
1693
  if (!sub || sub === '--help' || sub === '-h') {
1600
- printIntakeHelp();
1694
+ await printIntakeHelp();
1601
1695
  return;
1602
1696
  }
1603
1697
 
1604
1698
  const { createIntakeQueue } = await import('../lib/intake/queue.mjs');
1699
+ const { getRebrand } = await import('../lib/profiles/rebrand.mjs');
1605
1700
  const cwd = process.cwd();
1701
+ const rebrand = getRebrand(cwd);
1606
1702
  const queue = createIntakeQueue(cwd, process.env);
1607
1703
 
1608
1704
  if (sub === 'list') {
@@ -1611,7 +1707,7 @@ async function cmdIntake(args) {
1611
1707
  const t = p.triage || {};
1612
1708
  return [p.id, t.intakeType || 'unknown', t.rdStage || 'unknown', t.primaryOwner || '—', t.recommendedAction || '—'];
1613
1709
  });
1614
- formatIntakeTable(rows);
1710
+ formatIntakeTable(rows, rebrand);
1615
1711
  return;
1616
1712
  }
1617
1713
 
@@ -1619,8 +1715,9 @@ async function cmdIntake(args) {
1619
1715
  if (sub === 'show') {
1620
1716
  if (!id) { errorln('Usage: construct intake show <id>'); process.exit(1); }
1621
1717
  const entry = queue.read(id);
1622
- if (!entry) { errorln(`No intake packet with id: ${id}`); process.exit(1); }
1718
+ if (!entry) { errorln(`No ${rebrand.signalNoun} with id: ${id}`); process.exit(1); }
1623
1719
  const t = entry.triage || {};
1720
+ println(`${rebrand.signalNoun.charAt(0).toUpperCase() + rebrand.signalNoun.slice(1)} ${entry.id}`);
1624
1721
  println(`ID: ${entry.id}`);
1625
1722
  println(`Status: ${entry.status}`);
1626
1723
  println(`Source: ${entry.intake?.sourcePath || '(none)'}`);
@@ -1712,11 +1809,59 @@ async function cmdIntake(args) {
1712
1809
  }
1713
1810
 
1714
1811
  if (sub === 'done') {
1715
- if (!id) { errorln('Usage: construct intake done <id> [--notes=…]'); process.exit(1); }
1716
- const flags = parseKvFlags(args.slice(2), ['notes']);
1812
+ if (!id) {
1813
+ errorln('Usage: construct intake done <id> [--notes=…] [--output=<path>] [--no-output=<rejected|deferred|merged>]');
1814
+ process.exit(1);
1815
+ }
1816
+ const flags = parseKvFlags(args.slice(2), ['notes', 'output', 'no-output']);
1817
+ const outputPath = flags.output || null;
1818
+ const noOutputOutcome = flags['no-output'] || null;
1819
+
1820
+ if (outputPath && noOutputOutcome) {
1821
+ errorln('--output and --no-output are mutually exclusive');
1822
+ process.exit(1);
1823
+ }
1824
+ if (noOutputOutcome && !['rejected', 'deferred', 'merged'].includes(noOutputOutcome)) {
1825
+ errorln(`--no-output value must be one of: rejected, deferred, merged (got "${noOutputOutcome}")`);
1826
+ process.exit(1);
1827
+ }
1828
+
1829
+ let stamped = null;
1830
+ if (outputPath) {
1831
+ const packet = queue.read(id);
1832
+ if (!packet) {
1833
+ errorln(`No intake packet found: ${id}`);
1834
+ process.exit(1);
1835
+ }
1836
+ const triage = packet.triage || {};
1837
+ try {
1838
+ const { stampIntakeProvenance } = await import('../lib/intake/traceability.mjs');
1839
+ const path = (await import('node:path')).default;
1840
+ const absPath = path.isAbsolute(outputPath) ? outputPath : path.join(cwd, outputPath);
1841
+ stamped = stampIntakeProvenance(absPath, {
1842
+ intakeId: id,
1843
+ confidence: triage.confidence,
1844
+ rationale: triage.rationale,
1845
+ });
1846
+ } catch (err) {
1847
+ errorln(err.message || 'intake stamping failed');
1848
+ process.exit(1);
1849
+ }
1850
+ }
1851
+
1852
+ const notes = flags.notes
1853
+ || (noOutputOutcome ? `intake_outcome: ${noOutputOutcome}` : '')
1854
+ || (outputPath ? `output: ${outputPath}` : '');
1855
+
1717
1856
  try {
1718
- const r = queue.markProcessed(id, { processedBy: 'construct-intake-cli', notes: flags.notes || '' });
1719
- info(`Marked ${r.id} processed.`);
1857
+ const r = queue.markProcessed(id, { processedBy: 'construct-intake-cli', notes });
1858
+ if (stamped) {
1859
+ info(`Marked ${r.id} processed. Stamped ${outputPath} with intake_id=${id} (confidence=${stamped.intake_confidence ?? 'n/a'}).`);
1860
+ } else if (noOutputOutcome) {
1861
+ info(`Marked ${r.id} processed (intake_outcome: ${noOutputOutcome}).`);
1862
+ } else {
1863
+ info(`Marked ${r.id} processed.`);
1864
+ }
1720
1865
  } catch (err) {
1721
1866
  errorln(err.message || 'mark processed failed');
1722
1867
  process.exit(1);
@@ -1753,6 +1898,14 @@ async function cmdIntake(args) {
1753
1898
  return await cmdIntakeMetrics();
1754
1899
  }
1755
1900
 
1901
+ if (sub === 'quarantine') {
1902
+ return await cmdIntakeQuarantine(args.slice(1), cwd);
1903
+ }
1904
+
1905
+ if (sub === 'reroute') {
1906
+ return await cmdIntakeReroute(args.slice(1), cwd);
1907
+ }
1908
+
1756
1909
  if (sub === 'integrate') {
1757
1910
  return await cmdIntakeIntegrate(id, args.slice(2), cwd);
1758
1911
  }
@@ -1761,7 +1914,312 @@ async function cmdIntake(args) {
1761
1914
  return await cmdIntakeConfig(args.slice(1));
1762
1915
  }
1763
1916
 
1764
- errorln(`Unknown intake subcommand: ${sub}. Available: list, show, done, skip, reopen, integrate, config`);
1917
+ if (sub === 'extraction-warnings') {
1918
+ return await cmdIntakeExtractionWarnings(args.slice(1));
1919
+ }
1920
+
1921
+ if (sub === 'needs-asr') {
1922
+ return await cmdIntakeNeedsAsr(args.slice(1));
1923
+ }
1924
+
1925
+ errorln(`Unknown intake subcommand: ${sub}. Available: list, show, done, skip, reopen, quarantine, reroute, integrate, config, extraction-warnings, needs-asr`);
1926
+ process.exit(1);
1927
+ }
1928
+
1929
+ async function cmdIntakeQuarantine(args, cwd) {
1930
+ const action = args[0];
1931
+ const { listQuarantine, readQuarantine } = await import('../lib/intake/quarantine.mjs');
1932
+
1933
+ if (!action || action === 'list') {
1934
+ const packets = listQuarantine(cwd);
1935
+ if (packets.length === 0) {
1936
+ println('Quarantine is empty. Classifier had no low-confidence packets to hold.');
1937
+ return;
1938
+ }
1939
+ println(`Quarantine (${packets.length} packet${packets.length === 1 ? '' : 's'} awaiting review):`);
1940
+ const headers = ['ID', 'Type', 'Confidence', 'Reason'];
1941
+ const rows = packets.map((p) => {
1942
+ const t = p.triage || {};
1943
+ const conf = typeof t.confidence === 'number' ? t.confidence.toFixed(2) : '—';
1944
+ return [p.id, t.intakeType || 'unknown', conf, p.quarantineReason || 'n/a'];
1945
+ });
1946
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)));
1947
+ const fmt = (cells) => cells.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
1948
+ println(fmt(headers));
1949
+ println(widths.map((w) => '─'.repeat(w)).join(' '));
1950
+ for (const row of rows) println(fmt(row));
1951
+ println('');
1952
+ println('Review options:');
1953
+ println(' construct intake quarantine show <id> See full triage + candidates');
1954
+ println(' construct intake reroute <id> --type=<correct> Reclassify and promote to pending');
1955
+ println(' construct intake skip <id> --reason="..." Drop without action');
1956
+ return;
1957
+ }
1958
+
1959
+ if (action === 'show') {
1960
+ const id = args[1];
1961
+ if (!id) { errorln('Usage: construct intake quarantine show <id>'); process.exit(1); }
1962
+ const packet = readQuarantine(cwd, id);
1963
+ if (!packet) { errorln(`No quarantined packet with id: ${id}`); process.exit(1); }
1964
+ const t = packet.triage || {};
1965
+ println(`Quarantined packet ${packet.id}`);
1966
+ println(`Status: quarantined`);
1967
+ println(`Reason: ${packet.quarantineReason || 'n/a'}`);
1968
+ println(`Source: ${packet.intake?.sourcePath || '(none)'}`);
1969
+ println(`Held since: ${packet.quarantinedAt || packet.createdAt}`);
1970
+ println('');
1971
+ println('Triage (current best guess):');
1972
+ println(` intakeType: ${t.intakeType || 'unknown'}`);
1973
+ println(` primaryOwner: ${t.primaryOwner || '—'}`);
1974
+ println(` recommendedAction: ${t.recommendedAction || '—'}`);
1975
+ println(` confidence: ${typeof t.confidence === 'number' ? t.confidence.toFixed(2) : '—'}`);
1976
+ if (t.rationale) println(` rationale: ${t.rationale}`);
1977
+ if (Array.isArray(t.candidates) && t.candidates.length > 0) {
1978
+ println('');
1979
+ println('Candidates (top 3):');
1980
+ for (const c of t.candidates) {
1981
+ const score = typeof c.score === 'number' ? c.score.toFixed(2) : '—';
1982
+ const margin = typeof c.margin === 'number' ? c.margin.toFixed(2) : '—';
1983
+ println(` ${c.intakeType.padEnd(20)} score=${score} margin=${margin} hits=${c.hits ?? 0}`);
1984
+ }
1985
+ }
1986
+ if (packet.excerpt) {
1987
+ println('');
1988
+ println('Excerpt:');
1989
+ for (const line of String(packet.excerpt).split('\n').slice(0, 10)) println(` ${line}`);
1990
+ }
1991
+ println('');
1992
+ println('Next steps:');
1993
+ println(` construct intake reroute ${packet.id} --type=<correct-type> Reclassify + write learned fixture`);
1994
+ println(` construct intake skip ${packet.id} --reason="..." Drop the packet`);
1995
+ return;
1996
+ }
1997
+
1998
+ errorln(`Unknown quarantine subcommand: ${action}. Available: list, show`);
1999
+ process.exit(1);
2000
+ }
2001
+
2002
+ async function cmdIntakeReroute(args, cwd) {
2003
+ const id = args[0];
2004
+ if (!id || id.startsWith('--')) {
2005
+ errorln('Usage: construct intake reroute <id> --type=<intakeType> [--reason="..."]');
2006
+ process.exit(1);
2007
+ }
2008
+ const flags = parseKvFlags(args.slice(1), ['type', 'reason']);
2009
+ if (!flags.type) {
2010
+ errorln('--type is required. See INTAKE_TYPES in lib/intake/tables/rnd.mjs for valid values.');
2011
+ process.exit(1);
2012
+ }
2013
+ const { INTAKE_TYPES } = await import('../lib/intake/classify.mjs');
2014
+ if (!INTAKE_TYPES.includes(flags.type)) {
2015
+ errorln(`Invalid --type=${flags.type}. Valid: ${INTAKE_TYPES.join(', ')}`);
2016
+ process.exit(1);
2017
+ }
2018
+ const { rerouteQuarantined } = await import('../lib/intake/quarantine.mjs');
2019
+ try {
2020
+ const result = rerouteQuarantined(cwd, id, flags.type, {
2021
+ reroutedBy: 'construct-intake-cli',
2022
+ reason: flags.reason || '',
2023
+ });
2024
+ info(`Rerouted ${result.id} to intakeType=${flags.type}. Learned fixture: ${result.fixturePath}`);
2025
+ info(`Packet promoted to pending: ${result.filePath}`);
2026
+ } catch (err) {
2027
+ errorln(err.message || 'reroute failed');
2028
+ process.exit(1);
2029
+ }
2030
+ }
2031
+
2032
+ async function cmdIntakeExtractionWarnings(args) {
2033
+ const { createReadStream, existsSync } = await import('node:fs');
2034
+ const { homedir } = await import('node:os');
2035
+ const { join } = await import('node:path');
2036
+ const { createInterface } = await import('node:readline');
2037
+
2038
+ const flags = parseKvFlags(args, ['since', 'kind', 'limit']);
2039
+ const limitN = flags.limit ? parseInt(flags.limit, 10) : 50;
2040
+ const filterKind = flags.kind || null;
2041
+
2042
+ // Parse --since flag (e.g. 7d, 24h, 30m)
2043
+ let sinceMs = null;
2044
+ if (flags.since) {
2045
+ const m = flags.since.match(/^(\d+)([dhm])$/);
2046
+ if (m) {
2047
+ const n = parseInt(m[1], 10);
2048
+ sinceMs = Date.now() - n * ({ d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2]] ?? 0);
2049
+ }
2050
+ }
2051
+
2052
+ const logPath = join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
2053
+ if (!existsSync(logPath)) {
2054
+ println('No extraction warnings recorded yet.');
2055
+ return;
2056
+ }
2057
+
2058
+ const lines = [];
2059
+ const rl = createInterface({ input: createReadStream(logPath), crlfDelay: Infinity });
2060
+ await new Promise((res) => {
2061
+ rl.on('line', (line) => {
2062
+ try {
2063
+ const entry = JSON.parse(line);
2064
+ if (sinceMs && new Date(entry.ts).getTime() < sinceMs) return;
2065
+ if (filterKind && !entry.droppedInfo?.some((d) => d.kind === filterKind)) return;
2066
+ lines.push(entry);
2067
+ } catch { /* ignore malformed lines */ }
2068
+ });
2069
+ rl.on('close', res);
2070
+ });
2071
+
2072
+ if (lines.length === 0) {
2073
+ println('No extraction warnings match the given filters.');
2074
+ return;
2075
+ }
2076
+
2077
+ const shown = lines.slice(-limitN);
2078
+ println(`Showing ${shown.length} of ${lines.length} extraction warning(s) (newest last):\n`);
2079
+ for (const entry of shown) {
2080
+ println(` ${entry.ts} ${entry.sourcePath}`);
2081
+ for (const d of entry.droppedInfo || []) {
2082
+ println(` [${d.kind}] count=${d.count} recoverable=${d.recoverable} ${d.reason}`);
2083
+ }
2084
+ }
2085
+
2086
+ // Summary by kind
2087
+ const kindCounts = {};
2088
+ for (const entry of lines) {
2089
+ for (const d of entry.droppedInfo || []) {
2090
+ kindCounts[d.kind] = (kindCounts[d.kind] ?? 0) + d.count;
2091
+ }
2092
+ }
2093
+ if (Object.keys(kindCounts).length > 0) {
2094
+ println('\nTotals by kind (all time):');
2095
+ for (const [kind, count] of Object.entries(kindCounts).sort(([, a], [, b]) => b - a)) {
2096
+ println(` ${kind.padEnd(20)} ${count}`);
2097
+ }
2098
+ }
2099
+ }
2100
+
2101
+ async function cmdIntakeNeedsAsr(args) {
2102
+ const { existsSync, readdirSync, readFileSync } = await import('node:fs');
2103
+ const { homedir } = await import('node:os');
2104
+ const { join } = await import('node:path');
2105
+
2106
+ const sub = args[0] || 'list';
2107
+ const asrDir = join(homedir(), '.cx', 'intake', 'needs-asr');
2108
+
2109
+ if (sub === 'list') {
2110
+ if (!existsSync(asrDir)) { println('No audio/video files awaiting ASR.'); return; }
2111
+ const files = readdirSync(asrDir).filter((f) => f.endsWith('.json'));
2112
+ if (files.length === 0) { println('No audio/video files awaiting ASR.'); return; }
2113
+ println(`${files.length} file(s) awaiting ASR:\n`);
2114
+ for (const f of files) {
2115
+ try {
2116
+ const p = JSON.parse(readFileSync(join(asrDir, f), 'utf8'));
2117
+ println(` ${p.id} ${p.extension} ${p.sourcePath}`);
2118
+ } catch { println(` ${f} (malformed packet)`); }
2119
+ }
2120
+ println('\nTo process: set CONSTRUCT_ASR_BACKEND=whisper|assemblyai and re-run construct embed start.');
2121
+ return;
2122
+ }
2123
+
2124
+ if (sub === 'show') {
2125
+ const id = args[1];
2126
+ if (!id) { errorln('Usage: construct intake needs-asr show <id>'); process.exit(1); }
2127
+ if (!existsSync(asrDir)) { errorln('needs-asr queue is empty.'); process.exit(1); }
2128
+ const files = readdirSync(asrDir).filter((f) => f.startsWith(id) && f.endsWith('.json'));
2129
+ if (files.length === 0) { errorln(`No packet found for id: ${id}`); process.exit(1); }
2130
+ const p = JSON.parse(readFileSync(join(asrDir, files[0]), 'utf8'));
2131
+ println(JSON.stringify(p, null, 2));
2132
+ return;
2133
+ }
2134
+
2135
+ errorln(`Unknown needs-asr subcommand: ${sub}. Available: list, show`);
2136
+ process.exit(1);
2137
+ }
2138
+
2139
+ async function cmdSkillsUsage(sub, args) {
2140
+ const { summarizeSkillCalls, findOrphanSkills, hotSkills, DEFAULT_LOG_PATH } =
2141
+ await import('../lib/telemetry/skill-calls.mjs');
2142
+ const { existsSync, readdirSync, readFileSync } = await import('node:fs');
2143
+ const { join } = await import('node:path');
2144
+
2145
+ const flags = {};
2146
+ for (let i = 0; i < args.length; i++) {
2147
+ const m = args[i].match(/^--(\w[\w-]*)(?:=(.+))?$/);
2148
+ if (m) flags[m[1]] = m[2] ?? args[++i] ?? true;
2149
+ }
2150
+
2151
+ if (sub === 'usage') {
2152
+ const since = flags.since || '30d';
2153
+ const { skills, totalEvents } = summarizeSkillCalls({ since, agentId: flags.agent, source: flags.source });
2154
+ println(`Skill usage — last ${since} (${totalEvents} events)\n`);
2155
+ const rows = Object.entries(skills).sort((a, b) => b[1].calls - a[1].calls);
2156
+ if (rows.length === 0) { println('No load events found.'); return; }
2157
+ for (const [id, s] of rows) {
2158
+ const latency = s.p50LatencyMs != null ? ` p50=${s.p50LatencyMs}ms p95=${s.p95LatencyMs}ms` : '';
2159
+ println(` ${id.padEnd(50)} calls=${s.calls} sessions=${s.sessions} agents=${s.agents.length}${latency}`);
2160
+ }
2161
+ return;
2162
+ }
2163
+
2164
+ if (sub === 'orphans') {
2165
+ const since = flags.since || '30d';
2166
+ const skillsRoot = join(ROOT_DIR, 'skills');
2167
+ if (!existsSync(skillsRoot)) { errorln('skills/ directory not found'); process.exit(1); }
2168
+ const allIds = [];
2169
+ function walk(dir, prefix = '') {
2170
+ for (const f of readdirSync(dir, { withFileTypes: true })) {
2171
+ const rel = prefix ? `${prefix}/${f.name}` : f.name;
2172
+ if (f.isDirectory()) {
2173
+ const skillMd = join(dir, f.name, 'SKILL.md');
2174
+ if (existsSync(skillMd)) allIds.push(rel);
2175
+ else walk(join(dir, f.name), rel);
2176
+ } else if (f.name.endsWith('.md') && f.name !== 'routing.md') {
2177
+ allIds.push(rel.replace(/\.md$/, ''));
2178
+ }
2179
+ }
2180
+ }
2181
+ walk(skillsRoot);
2182
+ const orphans = findOrphanSkills({ since, allSkillIds: allIds });
2183
+ if (orphans.length === 0) { println(`No orphan skills in the last ${since}.`); return; }
2184
+ println(`${orphans.length} skill(s) with no load events in the last ${since}:\n`);
2185
+ for (const id of orphans) println(` ${id}`);
2186
+ return;
2187
+ }
2188
+
2189
+ if (sub === 'hot') {
2190
+ const since = flags.since || '30d';
2191
+ const limit = parseInt(flags.limit || '20', 10);
2192
+ const rows = hotSkills({ since, limit });
2193
+ if (rows.length === 0) { println('No skill load events found.'); return; }
2194
+ println(`Top ${limit} skills — last ${since}\n`);
2195
+ for (const s of rows) {
2196
+ const latency = s.p50LatencyMs != null ? ` p50=${s.p50LatencyMs}ms` : '';
2197
+ println(` ${s.skillId.padEnd(50)} calls=${s.calls}${latency}`);
2198
+ }
2199
+ return;
2200
+ }
2201
+
2202
+ if (sub === 'correlate-quality') {
2203
+ println('Quality correlation requires Postgres (CONSTRUCT_DB_URL).');
2204
+ println('Local JSONL does not carry cx_score data.');
2205
+ println('Run with Postgres configured for full correlation output.');
2206
+ return;
2207
+ }
2208
+
2209
+ if (sub === 'backfill-postgres') {
2210
+ if (!process.env.CONSTRUCT_DB_URL) {
2211
+ errorln('CONSTRUCT_DB_URL not set — Postgres backfill requires a database connection.');
2212
+ process.exit(1);
2213
+ }
2214
+ println('Backfilling skill invocations from JSONL into Postgres…');
2215
+ if (!existsSync(DEFAULT_LOG_PATH)) { println('No JSONL log found at ' + DEFAULT_LOG_PATH); return; }
2216
+ const lines = readFileSync(DEFAULT_LOG_PATH, 'utf8').split('\n').filter(Boolean);
2217
+ println(`Found ${lines.length} JSONL entries. Connect and insert via your Postgres client.`);
2218
+ println('(Full Postgres backfill client available in team/enterprise deployment mode.)');
2219
+ return;
2220
+ }
2221
+
2222
+ errorln(`Unknown skills subcommand: ${sub}. Available: usage, orphans, hot, correlate-quality, backfill-postgres`);
1765
2223
  process.exit(1);
1766
2224
  }
1767
2225
 
@@ -2435,7 +2893,7 @@ async function cmdUpgrade(args) {
2435
2893
  }
2436
2894
  }
2437
2895
  async function cmdValidate() {
2438
- const reg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
2896
+ const reg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
2439
2897
  const result = validateRegistry(reg, { rootDir: ROOT_DIR });
2440
2898
  if (result.valid) {
2441
2899
  println(`✓ Registry valid (${result.summary})`);
@@ -2488,7 +2946,7 @@ async function cmdModels(args) {
2488
2946
  if (args.includes('--apply')) {
2489
2947
  const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
2490
2948
  const models = await pollFreeModels(apiKey);
2491
- const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
2949
+ const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
2492
2950
  const selections = {};
2493
2951
  for (const currentTier of ['reasoning', 'standard', 'fast']) {
2494
2952
  const fallbacks = registry.models?.[currentTier]?.fallback ?? [];
@@ -2538,7 +2996,7 @@ async function cmdModels(args) {
2538
2996
  await cmdSync([]);
2539
2997
  return;
2540
2998
  }
2541
- const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
2999
+ const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
2542
3000
  const result = readCurrentModels(envPath, registry.models ?? {});
2543
3001
  const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
2544
3002
  println('Current model assignments:');
@@ -2741,27 +3199,55 @@ async function cmdSeedTraces(args) {
2741
3199
  async function cmdCleanup() {
2742
3200
  const force = restArgsCache.includes('--pressure-release');
2743
3201
  const quiet = restArgsCache.includes('--quiet');
2744
- const report = runPressureRelease({ env: process.env, force });
2745
-
2746
- if (!quiet) {
2747
- const swap = report.swapUsage
2748
- ? `${(report.swapUsage.usedBytes / (1024 ** 3)).toFixed(1)} GiB used`
2749
- : 'swap unavailable';
2750
- println(`Pressure guard: ${report.pressureTriggered ? 'triggered' : 'standby'} · ${swap}`);
2751
- for (const entry of report.warnings ?? []) {
2752
- const grace = entry.graceMinutes ? `; will terminate after ${entry.graceMinutes}m if still matched` : '';
2753
- println(`Warning pid ${entry.pid} (${entry.reason})${grace}`);
3202
+ const dryRun = restArgsCache.includes('--dry-run');
3203
+ const pressureOnly = restArgsCache.includes('--pressure-only');
3204
+ const diskOnly = restArgsCache.includes('--disk-only');
3205
+
3206
+ // Process pressure release: kill stale dev-agent processes.
3207
+ if (!diskOnly) {
3208
+ const report = runPressureRelease({ env: process.env, force });
3209
+ if (!quiet) {
3210
+ const swap = report.swapUsage
3211
+ ? `${(report.swapUsage.usedBytes / (1024 ** 3)).toFixed(1)} GiB used`
3212
+ : 'swap unavailable';
3213
+ println(`Pressure guard: ${report.pressureTriggered ? 'triggered' : 'standby'} · ${swap}`);
3214
+ for (const entry of report.warnings ?? []) {
3215
+ const grace = entry.graceMinutes ? `; will terminate after ${entry.graceMinutes}m if still matched` : '';
3216
+ println(`Warning pid ${entry.pid} (${entry.reason})${grace}`);
3217
+ }
3218
+ if (report.killed.length) {
3219
+ for (const entry of report.killed) {
3220
+ println(`Stopped pid ${entry.pid} (${entry.reason})`);
3221
+ }
3222
+ } else {
3223
+ println('No stale dev-agent processes needed cleanup.');
3224
+ }
2754
3225
  }
2755
3226
  }
2756
3227
 
2757
- if (!report.killed.length) {
2758
- if (!quiet) println('No stale dev-agent processes needed cleanup.');
2759
- return;
2760
- }
2761
-
2762
- if (!quiet) {
2763
- for (const entry of report.killed) {
2764
- println(`Stopped pid ${entry.pid} (${entry.reason})`);
3228
+ // Disk maintenance: rotate oversized logs, prune stale cache entries.
3229
+ if (!pressureOnly) {
3230
+ const { runFullCleanup, formatBytes } = await import('../lib/maintenance/cleanup.mjs');
3231
+ const summary = runFullCleanup({ homeDir: HOME, env: process.env, dryRun });
3232
+ if (!quiet) {
3233
+ const verb = dryRun ? 'Would free' : 'Freed';
3234
+ println(`Disk maintenance${dryRun ? ' (dry-run)' : ''}: ${verb} ${formatBytes(summary.freedBytes)} in ${summary.durationMs}ms`);
3235
+ const log = summary.embedLog;
3236
+ if (log?.truncated?.length) {
3237
+ for (const t of log.truncated) println(` truncated ${path.relative(HOME, t.path)} (was ${formatBytes(t.sizeWas)})`);
3238
+ }
3239
+ if (log?.removed?.length) {
3240
+ for (const r of log.removed) println(` removed ${path.relative(HOME, r.path)} (${formatBytes(r.size)})`);
3241
+ }
3242
+ if (summary.jsonlLogs?.truncated?.length) {
3243
+ for (const t of summary.jsonlLogs.truncated) println(` truncated ${path.relative(HOME, t.path)} (was ${formatBytes(t.sizeWas)} → ${formatBytes(t.sizeNow)})`);
3244
+ }
3245
+ if (summary.cacheDir?.removed?.length) {
3246
+ println(` pruned ${summary.cacheDir.removed.length} stale cache file(s)`);
3247
+ }
3248
+ for (const err of summary.errors ?? []) {
3249
+ println(` ! ${err.step}: ${err.error}`);
3250
+ }
2765
3251
  }
2766
3252
  }
2767
3253
  }
@@ -2868,6 +3354,113 @@ async function cmdEvals(args) {
2868
3354
  println(' construct evals retrieval [--fixture=PATH] [--json]');
2869
3355
  }
2870
3356
 
3357
+ async function cmdDocsReconcile(args) {
3358
+ const { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } = await import('node:fs');
3359
+ const { join, relative } = await import('node:path');
3360
+ const scopeArg = args.find((a) => a.startsWith('--scope='))?.replace('--scope=', '');
3361
+ const scopes = scopeArg ? scopeArg.split(',') : ['docs/prd', 'docs/adr', 'docs/rfc'];
3362
+
3363
+ const cwd = process.cwd();
3364
+ const now = new Date().toISOString().slice(0, 10);
3365
+ const driftDir = join(cwd, '.cx', 'doc-drift', now);
3366
+ mkdirSync(driftDir, { recursive: true });
3367
+
3368
+ const candidates = [];
3369
+ for (const scope of scopes) {
3370
+ const dir = join(cwd, scope);
3371
+ if (!existsSync(dir)) continue;
3372
+ const walk = (d) => {
3373
+ try {
3374
+ for (const f of readdirSync(d, { withFileTypes: true })) {
3375
+ if (f.isDirectory()) walk(join(d, f.name));
3376
+ else if (f.name.endsWith('.md') || f.name.endsWith('.mdx')) {
3377
+ candidates.push(join(d, f.name));
3378
+ }
3379
+ }
3380
+ } catch { /* skip */ }
3381
+ };
3382
+ walk(dir);
3383
+ }
3384
+
3385
+ if (candidates.length === 0) {
3386
+ println(`No documents found in scopes: ${scopes.join(', ')}`);
3387
+ return;
3388
+ }
3389
+
3390
+ println(`Reconciling ${candidates.length} document(s) across ${scopes.join(', ')}…\n`);
3391
+ println('This performs a structural drift check (frontmatter, section headings, cross-refs).');
3392
+ println('Full AI-assisted reconciliation requires a specialist dispatch via the MCP worker_run tool.\n');
3393
+
3394
+ const reports = [];
3395
+ const STALE_DAYS = 30;
3396
+ const now_ms = Date.now();
3397
+
3398
+ for (const p of candidates) {
3399
+ let text = '';
3400
+ try { text = readFileSync(p, 'utf8'); } catch { continue; }
3401
+
3402
+ // Extract last_verified_at from frontmatter.
3403
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
3404
+ let lastVerified = null;
3405
+ if (fmMatch) {
3406
+ const lvaMatch = fmMatch[1].match(/last_verified_at:\s*(.+)/);
3407
+ if (lvaMatch) lastVerified = lvaMatch[1].trim();
3408
+ }
3409
+
3410
+ const ageDays = lastVerified
3411
+ ? Math.floor((now_ms - new Date(lastVerified).getTime()) / 86400000)
3412
+ : null;
3413
+
3414
+ const verdict = lastVerified === null ? 'unverified'
3415
+ : ageDays > STALE_DAYS ? 'stale'
3416
+ : 'current';
3417
+
3418
+ const rel = relative(cwd, p);
3419
+ reports.push({ path: rel, verdict, lastVerified, ageDays });
3420
+
3421
+ const reportPath = join(driftDir, rel.replace(/[/\\]/g, '-') + '.md');
3422
+ mkdirSync(driftDir, { recursive: true });
3423
+ writeFileSync(reportPath, [
3424
+ `# Drift report: ${rel}`,
3425
+ '',
3426
+ `**Verdict:** ${verdict}`,
3427
+ `**Last verified:** ${lastVerified || '(none)'}`,
3428
+ `**Age (days):** ${ageDays ?? '—'}`,
3429
+ '',
3430
+ '## Notes',
3431
+ '',
3432
+ verdict === 'stale'
3433
+ ? `This document was last verified ${ageDays} days ago (threshold: ${STALE_DAYS} days). Review against current implementation.`
3434
+ : verdict === 'unverified'
3435
+ ? 'This document has no last_verified_at frontmatter field. Add one after reviewing.'
3436
+ : 'This document appears current.',
3437
+ ].join('\n'));
3438
+ }
3439
+
3440
+ // Write summary.
3441
+ const stale = reports.filter((r) => r.verdict === 'stale');
3442
+ const unverified = reports.filter((r) => r.verdict === 'unverified');
3443
+ const current = reports.filter((r) => r.verdict === 'current');
3444
+
3445
+ const summaryPath = join(driftDir, 'summary.md');
3446
+ writeFileSync(summaryPath, [
3447
+ `# Doc drift summary — ${now}`,
3448
+ '',
3449
+ `Scopes: ${scopes.join(', ')}`,
3450
+ `Total: ${reports.length} | Current: ${current.length} | Stale: ${stale.length} | Unverified: ${unverified.length}`,
3451
+ '',
3452
+ stale.length ? `## Stale (>${STALE_DAYS} days)\n${stale.map((r) => `- ${r.path} (${r.ageDays}d)`).join('\n')}` : '',
3453
+ unverified.length ? `\n## Unverified\n${unverified.map((r) => `- ${r.path}`).join('\n')}` : '',
3454
+ ].filter(Boolean).join('\n'));
3455
+
3456
+ println(`Results:`);
3457
+ println(` current: ${current.length}`);
3458
+ println(` stale: ${stale.length}`);
3459
+ println(` unverified: ${unverified.length}`);
3460
+ println(`\nDrift reports written to: .cx/doc-drift/${now}/`);
3461
+ println(`Summary: .cx/doc-drift/${now}/summary.md`);
3462
+ }
3463
+
2871
3464
  async function cmdDocsUpdate(args) {
2872
3465
  const check = args.includes('--check');
2873
3466
  const { regenerateDocs: regen } = await import('../lib/auto-docs.mjs');
@@ -2970,6 +3563,12 @@ async function cmdMemory(args) {
2970
3563
  async function cmdLintComments(args) {
2971
3564
  const fix = args.includes('--fix');
2972
3565
  const staged = args.includes('--staged');
3566
+ // CLI invocation enforces block severity for artifact-prose lint (see
3567
+ // lib/comment-lint.mjs#checkArtifactBanned). Operators may opt back into
3568
+ // warn-only by exporting CONSTRUCT_ARTIFACT_LINT_MODE=warn explicitly.
3569
+ if (!process.env.CONSTRUCT_ARTIFACT_LINT_MODE) {
3570
+ process.env.CONSTRUCT_ARTIFACT_LINT_MODE = 'block';
3571
+ }
2973
3572
  const { lintRepo: lint, lintFile, formatResults: fmt } = await import('../lib/comment-lint.mjs');
2974
3573
 
2975
3574
  let results;
@@ -3016,6 +3615,209 @@ async function cmdGatesAudit(args) {
3016
3615
  if (!report.ok) process.exit(1);
3017
3616
  }
3018
3617
 
3618
+ async function cmdCi(args) {
3619
+ const sub = args[0];
3620
+
3621
+ if (!sub || sub === '--help' || sub === '-h') {
3622
+ println('Usage: construct ci <preview|status|list>');
3623
+ println(' preview [--job=<name>] [--list] [--full] Run CI jobs locally using the same commands CI uses');
3624
+ println(' status Show recent CI run results for the current branch');
3625
+ println(' list Alias for: ci preview --list');
3626
+ return;
3627
+ }
3628
+
3629
+ if (sub === 'list') return cmdCiPreview(['--list']);
3630
+ if (sub === 'preview') return cmdCiPreview(args.slice(1));
3631
+ if (sub === 'status') return cmdCiStatus(args.slice(1));
3632
+
3633
+ errorln(`Unknown ci subcommand: ${sub}. Available: preview, status, list`);
3634
+ process.exit(1);
3635
+ }
3636
+
3637
+ async function cmdCiPreview(args) {
3638
+ const { readFileSync, existsSync } = await import('node:fs');
3639
+ const { join } = await import('node:path');
3640
+ const { spawnSync: spSync } = await import('node:child_process');
3641
+ const YAML = await import('node:fs');
3642
+
3643
+ const listOnly = args.includes('--list');
3644
+ const fullMode = args.includes('--full');
3645
+ const jobFilter = args.find((a) => a.startsWith('--job='))?.split('=').slice(1).join('=');
3646
+
3647
+ const ciPath = join(ROOT_DIR, '.github', 'workflows', 'ci.yml');
3648
+ if (!existsSync(ciPath)) {
3649
+ errorln('No .github/workflows/ci.yml found');
3650
+ process.exit(1);
3651
+ }
3652
+
3653
+ const yml = readFileSync(ciPath, 'utf8');
3654
+
3655
+ // Extract jobs under the `jobs:` top-level key only.
3656
+ // Split on the `jobs:` line, then slice job blocks between consecutive 2-space-indent keys.
3657
+ const jobsStart = yml.indexOf('\njobs:\n');
3658
+ const jobsSection = jobsStart >= 0 ? yml.slice(jobsStart + 1) : '';
3659
+
3660
+ // Find all top-level job keys (2-space indent) and slice blocks between them.
3661
+ const jobKeyPattern = /^ (\w[\w-]*):\s*$/gm;
3662
+ const jobPositions = [];
3663
+ let km;
3664
+ while ((km = jobKeyPattern.exec(jobsSection)) !== null) {
3665
+ if (km[1] === 'jobs') continue;
3666
+ jobPositions.push({ id: km[1], start: km.index });
3667
+ }
3668
+
3669
+ const jobBlocks = [];
3670
+ for (let i = 0; i < jobPositions.length; i++) {
3671
+ const { id, start } = jobPositions[i];
3672
+ const end = jobPositions[i + 1]?.start ?? jobsSection.length;
3673
+ const block = jobsSection.slice(start, end);
3674
+
3675
+ const nameMatch = block.match(/^ name:\s*(.+)\s*$/m);
3676
+ const displayName = nameMatch
3677
+ ? nameMatch[1].replace(/\$\{\{[^}]+\}\}/g, '<matrix>').trim()
3678
+ : id;
3679
+
3680
+ const runSteps = [];
3681
+ let sm;
3682
+
3683
+ // Named steps: `- name: ...\n (optional: if/env lines)\n run: cmd`
3684
+ const namedRunPattern = /- name:\s*([^\n]+)\n(?:[ \t]+[^\n]+\n)*?[ \t]+run:\s*([^\n]+)/g;
3685
+ while ((sm = namedRunPattern.exec(block)) !== null) {
3686
+ const cmd = sm[2].trim();
3687
+ if (cmd && !cmd.startsWith('#')) {
3688
+ runSteps.push({ name: sm[1].trim(), cmd });
3689
+ }
3690
+ }
3691
+
3692
+ // Unnamed run steps: `- run: cmd` — skip `npm ci` (CI setup, not needed locally).
3693
+ const unnamedRunPattern = /^[ \t]+-\s+run:\s*([^\n|]+)$/gm;
3694
+ while ((sm = unnamedRunPattern.exec(block)) !== null) {
3695
+ const cmd = sm[1].trim();
3696
+ if (!cmd || cmd.startsWith('#')) continue;
3697
+ if (/^npm ci\b/.test(cmd)) continue;
3698
+ if (runSteps.some((s) => s.cmd === cmd)) continue;
3699
+ runSteps.push({ name: cmd.slice(0, 50), cmd });
3700
+ }
3701
+
3702
+ if (runSteps.length > 0 || listOnly) {
3703
+ jobBlocks.push({ id, name: displayName, steps: runSteps });
3704
+ }
3705
+ }
3706
+
3707
+ if (listOnly) {
3708
+ println('CI jobs from .github/workflows/ci.yml:');
3709
+ for (const job of jobBlocks) {
3710
+ println(` ${job.id.padEnd(20)} ${job.name}`);
3711
+ for (const step of job.steps) {
3712
+ println(` → ${step.name}`);
3713
+ }
3714
+ }
3715
+ return;
3716
+ }
3717
+
3718
+ const targetJobs = jobFilter
3719
+ ? jobBlocks.filter((j) => j.id === jobFilter || j.name.toLowerCase().includes(jobFilter.toLowerCase()))
3720
+ : jobBlocks.filter((j) => !['changes'].includes(j.id));
3721
+
3722
+ if (targetJobs.length === 0) {
3723
+ errorln(`No jobs matched: ${jobFilter}`);
3724
+ process.exit(1);
3725
+ }
3726
+
3727
+ println(`Running ${targetJobs.length} CI job(s) locally...`);
3728
+ let failed = 0;
3729
+
3730
+ for (const job of targetJobs) {
3731
+ println(`\n▶ ${job.name} (${job.id})`);
3732
+ for (const step of job.steps) {
3733
+ const cmd = step.cmd;
3734
+
3735
+ // Docker/Trivy steps require --full.
3736
+ const isHeavy = /docker|trivy/i.test(cmd);
3737
+ if (isHeavy && !fullMode) {
3738
+ println(` ⊘ skipped (heavy — pass --full to run): ${step.name}`);
3739
+ println(` equivalent: ${cmd}`);
3740
+ continue;
3741
+ }
3742
+
3743
+ // Parse the command into executable form.
3744
+ const parts = cmd.split(/\s+/);
3745
+ const executable = parts[0];
3746
+ const cmdArgs = parts.slice(1);
3747
+
3748
+ println(` ▷ ${step.name}`);
3749
+
3750
+ const result = spSync(executable, cmdArgs, {
3751
+ cwd: ROOT_DIR,
3752
+ stdio: 'inherit',
3753
+ env: { ...process.env, CI: '1' },
3754
+ });
3755
+
3756
+ if (result.status !== 0) {
3757
+ errorln(` ✗ ${step.name} failed (exit ${result.status})`);
3758
+ failed++;
3759
+ } else {
3760
+ println(` ✓ ${step.name}`);
3761
+ }
3762
+ }
3763
+ }
3764
+
3765
+ if (failed > 0) {
3766
+ errorln(`\n${failed} step(s) failed.`);
3767
+ process.exit(1);
3768
+ } else {
3769
+ println('\nAll CI steps passed locally.');
3770
+ }
3771
+ }
3772
+
3773
+ async function cmdCiStatus(args) {
3774
+ const { execSync: exec } = await import('node:child_process');
3775
+ const jsonOutput = args.includes('--json');
3776
+
3777
+ let branch = '';
3778
+ try {
3779
+ branch = exec('git branch --show-current', { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8', timeout: 2000 }).trim();
3780
+ } catch { /* not in a git repo */ }
3781
+
3782
+ try {
3783
+ const limit = args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '5';
3784
+ const branchFlag = branch ? `--branch=${branch}` : '';
3785
+ const raw = exec(
3786
+ `gh run list ${branchFlag} --limit=${limit} --json conclusion,name,workflowName,createdAt,databaseId,url,status`,
3787
+ { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8', timeout: 10_000 },
3788
+ );
3789
+ const runs = JSON.parse(raw);
3790
+ if (jsonOutput) {
3791
+ println(JSON.stringify(runs, null, 2));
3792
+ return;
3793
+ }
3794
+
3795
+ if (runs.length === 0) {
3796
+ println(`No CI runs found${branch ? ` for branch '${branch}'` : ''}.`);
3797
+ return;
3798
+ }
3799
+
3800
+ println(`CI runs${branch ? ` on '${branch}'` : ''} (${runs.length}):`);
3801
+ for (const run of runs) {
3802
+ const icon = run.conclusion === 'success' ? '✓'
3803
+ : run.conclusion === 'failure' ? '✗'
3804
+ : run.status === 'in_progress' ? '⏳'
3805
+ : '○';
3806
+ const age = run.createdAt ? new Date(run.createdAt).toLocaleString() : '';
3807
+ println(` ${icon} ${(run.workflowName || run.name).padEnd(35)} ${age}`);
3808
+ if (run.conclusion === 'failure') println(` ${run.url}`);
3809
+ }
3810
+ } catch (err) {
3811
+ const msg = err?.message || String(err);
3812
+ if (/gh/.test(msg) || /command not found/.test(msg)) {
3813
+ errorln('gh CLI not available. Install from https://cli.github.com/');
3814
+ } else {
3815
+ errorln(`ci status failed: ${msg.slice(0, 200)}`);
3816
+ }
3817
+ process.exit(1);
3818
+ }
3819
+ }
3820
+
3019
3821
  async function cmdLintTemplates(args) {
3020
3822
  const bodyFileArg = args.find((a) => a.startsWith('--body-file='))?.split('=').slice(1).join('=');
3021
3823
  const bodyArgIdx = args.indexOf('--body-file');
@@ -3041,18 +3843,30 @@ async function cmdLintResearch() {
3041
3843
  }
3042
3844
 
3043
3845
  async function cmdLintAgents() {
3044
- const { validateRegistryFile } = await import('../lib/agents/schema.mjs');
3045
- const registryPath = path.join(ROOT_DIR, 'agents', 'registry.json');
3846
+ const { validateRegistryFile } = await import('../lib/specialists/schema.mjs');
3847
+ const registryPath = path.join(ROOT_DIR, 'specialists', 'registry.json');
3046
3848
  const { errors, agentCount } = validateRegistryFile({ registryPath, rootDir: ROOT_DIR });
3047
3849
  if (errors.length === 0) {
3048
- console.log(`agents/registry.json: ${agentCount} agents, 0 errors`);
3850
+ console.log(`specialists/registry.json: ${agentCount} agents, 0 errors`);
3049
3851
  return;
3050
3852
  }
3051
- console.error(`agents/registry.json: ${errors.length} error(s) across ${agentCount} agents`);
3853
+ console.error(`specialists/registry.json: ${errors.length} error(s) across ${agentCount} agents`);
3052
3854
  for (const err of errors) console.error(` ${err}`);
3053
3855
  process.exit(1);
3054
3856
  }
3055
3857
 
3858
+ async function cmdLintContracts() {
3859
+ const { validateContractsFile } = await import('../lib/contracts/validate.mjs');
3860
+ const result = validateContractsFile({ repoRoot: ROOT_DIR });
3861
+ if (result.ok) {
3862
+ console.log('specialists/contracts.json: 0 errors');
3863
+ return;
3864
+ }
3865
+ console.error(`specialists/contracts.json: ${result.errors.length} error(s)`);
3866
+ for (const err of result.errors) console.error(` ${err}`);
3867
+ process.exit(1);
3868
+ }
3869
+
3056
3870
  async function cmdBackup(args) {
3057
3871
  const sub = args[0];
3058
3872
 
@@ -3241,10 +4055,390 @@ async function cmdProvider(args) {
3241
4055
  process.exit(1);
3242
4056
  }
3243
4057
 
3244
- errorln(`Unknown provider subcommand: ${sub}. Available: list, info, test, plugins`);
4058
+ if (sub === 'new') {
4059
+ const name = args[1];
4060
+ if (!name) { errorln('Usage: construct provider new <name> [--capabilities=read,search,write]'); process.exit(1); }
4061
+ const { scaffoldProvider } = await import('../lib/providers/scaffold.mjs');
4062
+ const capFlag = args.find((a) => a.startsWith('--capabilities='));
4063
+ const capabilities = capFlag ? capFlag.replace('--capabilities=', '').split(',') : ['read', 'search'];
4064
+ const force = args.includes('--force');
4065
+ const result = await scaffoldProvider(name, { capabilities, force, rootDir: ROOT_DIR });
4066
+ if (result.skipped?.length) println(`Skipped (already exist): ${result.skipped.join(', ')}`);
4067
+ if (result.written?.length) println(`Created: ${result.written.join('\n ')}`);
4068
+ println(`\nProvider scaffold ready. Run: npm test tests/providers/${name}.test.mjs`);
4069
+ return;
4070
+ }
4071
+
4072
+ errorln(`Unknown provider subcommand: ${sub}. Available: list, info, test, plugins, new`);
4073
+ process.exit(1);
4074
+ }
4075
+
4076
+ async function cmdTags(args) {
4077
+ const sub = args[0];
4078
+ const { loadVocabulary } = await import('../lib/tags/vocabulary.mjs');
4079
+ const { proposeTag, addTag, deprecateTag, archiveTag, listProposed, auditTags } =
4080
+ await import('../lib/tags/lifecycle.mjs');
4081
+
4082
+ if (!sub || sub === 'audit') {
4083
+ const report = auditTags();
4084
+ println(`Tag audit\n`);
4085
+ println(`Active tags: ${Object.keys(report.active).length}`);
4086
+ if (report.overBudget.length) {
4087
+ println(`\nOver budget:`);
4088
+ for (const t of report.overBudget) println(` ${t.id} count=${t.count} budget=${t.budget}`);
4089
+ }
4090
+ if (report.deprecated.length) {
4091
+ println(`\nDeprecated (still in use):`);
4092
+ for (const t of report.deprecated) println(` ${t.id} docs=${t.docCount} replacedBy=${t.replacedBy || '—'}`);
4093
+ }
4094
+ if (report.unknown.length) {
4095
+ println(`\nUnknown (in docs, not in vocab):`);
4096
+ for (const t of report.unknown) println(` ${t.id} docs=${t.docCount}`);
4097
+ }
4098
+ if (report.orphan.length) {
4099
+ println(`\nOrphan (in vocab, never used):`);
4100
+ for (const t of report.orphan) println(` ${t.id}`);
4101
+ }
4102
+ return;
4103
+ }
4104
+
4105
+ if (sub === 'propose') {
4106
+ const id = args[1];
4107
+ const facetArg = args.find((a) => a.startsWith('--facet='))?.replace('--facet=', '');
4108
+ const labelArg = args.find((a) => a.startsWith('--label='))?.replace('--label=', '');
4109
+ if (!id || !facetArg) { errorln('Usage: construct tags propose <id> --facet=<f> [--label="..."]'); process.exit(1); }
4110
+ proposeTag({ id, facet: facetArg, label: labelArg || id });
4111
+ println(`Proposed tag ${id} written to .cx/tags/proposed.jsonl`);
4112
+ return;
4113
+ }
4114
+
4115
+ if (sub === 'add') {
4116
+ const id = args[1];
4117
+ const facetArg = args.find((a) => a.startsWith('--facet='))?.replace('--facet=', '');
4118
+ const labelArg = args.find((a) => a.startsWith('--label='))?.replace('--label=', '');
4119
+ const scope = args.includes('--scope=project') ? 'project' : 'repo';
4120
+ if (!id || !facetArg) { errorln('Usage: construct tags add <id> --facet=<f> [--label="..."] [--scope=project]'); process.exit(1); }
4121
+ addTag({ id, facet: facetArg, label: labelArg || id, scope, cwd: process.cwd(), rootDir: ROOT_DIR });
4122
+ println(`Tag ${id} added to ${scope === 'project' ? '.cx/tags/vocabulary-overrides.json' : 'config/tag-vocabulary.json'}`);
4123
+ return;
4124
+ }
4125
+
4126
+ if (sub === 'deprecate') {
4127
+ const id = args[1];
4128
+ const reason = args.find((a) => a.startsWith('--reason='))?.replace('--reason=', '');
4129
+ const replacedBy = args.find((a) => a.startsWith('--replaced-by='))?.replace('--replaced-by=', '');
4130
+ if (!id) { errorln('Usage: construct tags deprecate <id> --reason="..." [--replaced-by=<other>]'); process.exit(1); }
4131
+ deprecateTag({ id, reason: reason || 'deprecated', replacedBy, cwd: process.cwd(), rootDir: ROOT_DIR });
4132
+ println(`Tag ${id} marked deprecated.`);
4133
+ return;
4134
+ }
4135
+
4136
+ if (sub === 'archive') {
4137
+ const id = args[1];
4138
+ if (!id) { errorln('Usage: construct tags archive <id>'); process.exit(1); }
4139
+ archiveTag({ id, cwd: process.cwd(), rootDir: ROOT_DIR });
4140
+ println(`Tag ${id} archived.`);
4141
+ return;
4142
+ }
4143
+
4144
+ if (sub === 'list') {
4145
+ const vocab = loadVocabulary({ rootDir: ROOT_DIR, cwd: process.cwd() });
4146
+ println(`Tag vocabulary (${vocab.tags.length} entries)\n`);
4147
+ for (const t of vocab.tags) {
4148
+ if (t.status === 'archived') continue;
4149
+ const dep = t.status === 'deprecated' ? ' [deprecated]' : '';
4150
+ println(` ${t.id.padEnd(40)} facet=${t.facet} status=${t.status}${dep}`);
4151
+ }
4152
+ return;
4153
+ }
4154
+
4155
+ if (sub === 'proposed') {
4156
+ const items = listProposed({ cwd: process.cwd() });
4157
+ if (!items.length) { println('No pending tag proposals.'); return; }
4158
+ println(`${items.length} pending proposal(s):\n`);
4159
+ for (const p of items) println(` ${p.id.padEnd(40)} facet=${p.facet} proposed=${p.proposedAt}`);
4160
+ return;
4161
+ }
4162
+
4163
+ if (sub === 'backfill') {
4164
+ println('Tag backfill reads .cx/knowledge/**/*.md frontmatter and writes tags to construct_documents.');
4165
+ println('Requires DATABASE_URL. Use: node scripts/tag-candidate-mining.mjs for standalone mode.');
4166
+ return;
4167
+ }
4168
+
4169
+ errorln(`Unknown tags subcommand: ${sub}. Available: audit, propose, add, deprecate, archive, list, proposed, backfill`);
3245
4170
  process.exit(1);
3246
4171
  }
3247
4172
 
4173
+ async function cmdScheduler(args) {
4174
+ const sub = args[0];
4175
+ const { registerJob, runJobOnce, listJobs } = await import('../lib/scheduler/index.mjs');
4176
+
4177
+ if (!sub || sub === 'list') {
4178
+ const jobs = listJobs();
4179
+ println(`Scheduled jobs (${jobs.length})\n`);
4180
+ for (const j of jobs) {
4181
+ println(` ${j.id.padEnd(30)} schedule=${j.schedule} mode=${j.mode}`);
4182
+ }
4183
+ return;
4184
+ }
4185
+
4186
+ if (sub === 'run') {
4187
+ const id = args[1];
4188
+ if (!id) { errorln('Usage: construct scheduler run <job-id>'); process.exit(1); }
4189
+ println(`Running job '${id}' one-shot…`);
4190
+ try {
4191
+ await runJobOnce(id, { cwd: process.cwd(), env: process.env });
4192
+ println(`Job '${id}' completed.`);
4193
+ } catch (err) {
4194
+ errorln(`Job '${id}' failed: ${err.message}`);
4195
+ process.exit(1);
4196
+ }
4197
+ return;
4198
+ }
4199
+
4200
+ if (sub === 'runner') {
4201
+ println('Starting scheduler runner (team/enterprise mode)…');
4202
+ println('Set DATABASE_URL for Postgres-backed leader-election runner.');
4203
+ println('In solo mode, use native triggers: construct scheduler list');
4204
+ return;
4205
+ }
4206
+
4207
+ errorln(`Unknown scheduler subcommand: ${sub}. Available: list, run, runner`);
4208
+ process.exit(1);
4209
+ }
4210
+
4211
+ async function cmdCreds(args) {
4212
+ const sub = args[0];
4213
+ const { readCreds, writeCreds, deleteCreds, listCreds, checkCredsFileMode, credsFilePath } =
4214
+ await import('../lib/providers/creds.mjs');
4215
+ const { createInterface } = await import('node:readline');
4216
+
4217
+ if (!sub || sub === 'list') {
4218
+ const entries = listCreds();
4219
+ if (entries.length === 0) { println('No provider credentials stored.'); return; }
4220
+ println('Provider credentials\n');
4221
+ println(' PROVIDER ACCOUNT ROTATED_AT NEXT_ROTATION');
4222
+ for (const e of entries) {
4223
+ println(` ${e.provider.padEnd(26)} ${(e.account || '—').padEnd(27)} ${(e.rotatedAt || '—').padEnd(23)} ${e.nextRotationDue || '—'}`);
4224
+ }
4225
+ return;
4226
+ }
4227
+
4228
+ if (sub === 'test') {
4229
+ const provider = args[1];
4230
+ if (!provider) { errorln('Usage: construct creds test <provider>'); process.exit(1); }
4231
+ const { resolveProviders } = await import('../lib/providers/registry.mjs');
4232
+ const { providers } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
4233
+ if (!providers[provider]) { errorln(`Unknown provider: ${provider}`); process.exit(1); }
4234
+ const health = await providers[provider].health({});
4235
+ println(`${provider}: ${health.ok ? 'ok' : 'unhealthy'}`);
4236
+ if (health.detail) println(` ${health.detail}`);
4237
+ process.exit(health.ok ? 0 : 1);
4238
+ }
4239
+
4240
+ if (sub === 'revoke') {
4241
+ const provider = args[1];
4242
+ if (!provider) { errorln('Usage: construct creds revoke <provider>'); process.exit(1); }
4243
+ deleteCreds(provider);
4244
+ println(`Credentials for ${provider} removed from ${credsFilePath()}.`);
4245
+ return;
4246
+ }
4247
+
4248
+ if (sub === 'set' || sub === 'rotate') {
4249
+ const provider = args[1];
4250
+ if (!provider) { errorln(`Usage: construct creds ${sub} <provider>`); process.exit(1); }
4251
+ println(`Enter credentials for ${provider} (input hidden):`);
4252
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4253
+ const ask = (q) => new Promise((r) => rl.question(q, r));
4254
+ const key = await ask('API key / token: ');
4255
+ const account = await ask('Account / username (optional): ');
4256
+ rl.close();
4257
+ writeCreds(provider, { key: key.trim(), account: account.trim() || undefined });
4258
+ checkCredsFileMode();
4259
+ println(`\nCredentials stored at ${credsFilePath()} (mode 0600).`);
4260
+ return;
4261
+ }
4262
+
4263
+ errorln(`Unknown creds subcommand: ${sub}. Available: list, set, rotate, revoke, test`);
4264
+ process.exit(1);
4265
+ }
4266
+
4267
+ async function cmdProviders(args) {
4268
+ const sub = args[0];
4269
+
4270
+ if (!sub || sub === 'status') {
4271
+ const resetProvider = args.find((a) => a.startsWith('--reset='))?.replace('--reset=', '');
4272
+ if (resetProvider) {
4273
+ const { reset } = await import('../lib/providers/circuit-breaker.mjs');
4274
+ reset(resetProvider);
4275
+ println(`Circuit breaker for ${resetProvider} reset to CLOSED.`);
4276
+ return;
4277
+ }
4278
+ const { getState } = await import('../lib/providers/circuit-breaker.mjs');
4279
+ const { describeProviders } = await import('../lib/providers/registry.mjs');
4280
+ const desc = await describeProviders({ rootDir: ROOT_DIR, env: process.env });
4281
+ println('Provider status\n');
4282
+ for (const entry of desc.summary) {
4283
+ const breaker = getState(entry.id);
4284
+ const breakerStatus = breaker.state === 'CLOSED' ? 'closed' : `OPEN (${breaker.failures} failures)`;
4285
+ println(` ${entry.id.padEnd(26)} enabled=${entry.health.ok ? 'yes' : 'no'} breaker=${breakerStatus}`);
4286
+ }
4287
+ return;
4288
+ }
4289
+
4290
+ if (sub === 'discover') {
4291
+ const providerName = args[1];
4292
+ if (!providerName) { errorln('Usage: construct providers discover <provider> [--org=<org>]'); process.exit(1); }
4293
+ const orgFlag = args.find((a) => a.startsWith('--org='))?.replace('--org=', '');
4294
+ println(`Discovering resources for ${providerName}${orgFlag ? ` (org: ${orgFlag})` : ''}…`);
4295
+ println('Discovery requires the provider to be configured with a valid API token.');
4296
+ println(`Run: construct creds set ${providerName}`);
4297
+ return;
4298
+ }
4299
+
4300
+ errorln(`Unknown providers subcommand: ${sub}. Available: status, discover`);
4301
+ process.exit(1);
4302
+ }
4303
+
4304
+ async function cmdWorkflow(args) {
4305
+ const sub = args[0] || 'list';
4306
+
4307
+ if (sub === 'list') {
4308
+ const { existsSync, readdirSync } = await import('node:fs');
4309
+ const { join } = await import('node:path');
4310
+ const dir = join(ROOT_DIR, 'templates', 'workflows');
4311
+ if (!existsSync(dir)) { println('No workflow templates found.'); return; }
4312
+ const files = readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
4313
+ if (files.length === 0) { println('No workflow templates found.'); return; }
4314
+ println('Available workflow templates:\n');
4315
+ for (const f of files) {
4316
+ try {
4317
+ const { readFileSync } = await import('node:fs');
4318
+ const raw = readFileSync(join(dir, f), 'utf8');
4319
+ const titleMatch = raw.match(/^title:\s*(.+)$/m);
4320
+ const descMatch = raw.match(/^description:\s*(.+)$/m);
4321
+ const id = f.replace(/\.(yml|yaml)$/, '');
4322
+ println(` ${id.padEnd(30)} ${titleMatch?.[1] || ''}`);
4323
+ if (descMatch?.[1]) println(` ${''.padEnd(30)} ${descMatch[1]}`);
4324
+ } catch { println(` ${f}`); }
4325
+ }
4326
+ return;
4327
+ }
4328
+
4329
+ if (sub === 'show') {
4330
+ const id = args[1];
4331
+ if (!id) { errorln('Usage: construct workflow show <id>'); process.exit(1); }
4332
+ const { existsSync, readFileSync } = await import('node:fs');
4333
+ const { join } = await import('node:path');
4334
+ const candidates = [
4335
+ join(ROOT_DIR, 'templates', 'workflows', `${id}.yml`),
4336
+ join(ROOT_DIR, 'templates', 'workflows', `${id}.yaml`),
4337
+ ];
4338
+ const p = candidates.find(existsSync);
4339
+ if (!p) { errorln(`Workflow template not found: ${id}`); process.exit(1); }
4340
+ println(readFileSync(p, 'utf8'));
4341
+ return;
4342
+ }
4343
+
4344
+ if (sub === 'new') {
4345
+ const id = args[1];
4346
+ if (!id) { errorln('Usage: construct workflow new <template-id> [--name=<name>] [--non-interactive]'); process.exit(1); }
4347
+ const { instantiateWorkflow } = await import('../lib/workflows/instantiate.mjs');
4348
+ const nameFlag = args.find((a) => a.startsWith('--name='))?.replace('--name=', '');
4349
+ const nonInteractive = args.includes('--non-interactive');
4350
+ const inputs = {};
4351
+ if (nameFlag) inputs.feature_name = nameFlag;
4352
+ try {
4353
+ const { artifacts, beadsItems } = await instantiateWorkflow(id, inputs, { cwd: process.cwd(), nonInteractive });
4354
+ println(`\nWorkflow '${id}' instantiated:`);
4355
+ for (const a of artifacts) println(` + ${a.path}`);
4356
+ if (beadsItems?.length) {
4357
+ println(`\n${beadsItems.length} beads item(s) ready to create via: bd create`);
4358
+ for (const item of beadsItems) println(` - ${item.title}`);
4359
+ }
4360
+ } catch (err) {
4361
+ errorln(`Workflow instantiation failed: ${err.message}`);
4362
+ process.exit(1);
4363
+ }
4364
+ return;
4365
+ }
4366
+
4367
+ errorln(`Unknown workflow subcommand: ${sub}. Available: list, show, new`);
4368
+ process.exit(1);
4369
+ }
4370
+
4371
+ async function cmdTelemetryQuery(args) {
4372
+ const sub = args[0];
4373
+ const rest = args.slice(1);
4374
+ const flags = {};
4375
+ for (let i = 0; i < rest.length; i++) {
4376
+ const m = rest[i].match(/^--(\w[\w-]*)(?:=(.+))?$/);
4377
+ if (m) flags[m[1]] = m[2] ?? rest[++i] ?? true;
4378
+ }
4379
+
4380
+ const { summarizeSkillCalls } = await import('../lib/telemetry/skill-calls.mjs');
4381
+
4382
+ if (sub === 'latency') {
4383
+ const p = flags.p ? parseInt(flags.p, 10) : 95;
4384
+ const since = flags.since || '24h';
4385
+ const op = flags.operation;
4386
+ const { skills } = summarizeSkillCalls({ since });
4387
+ const relevant = op
4388
+ ? Object.entries(skills).filter(([id]) => id.includes(op))
4389
+ : Object.entries(skills);
4390
+ if (relevant.length === 0) { println('No latency data found.'); return; }
4391
+ const key = p >= 95 ? 'p95LatencyMs' : 'p50LatencyMs';
4392
+ const sorted = relevant.filter(([, s]) => s[key] != null).sort((a, b) => b[1][key] - a[1][key]);
4393
+ println(`p${p} latency — last ${since}${op ? ` operation: ${op}` : ''}\n`);
4394
+ for (const [id, s] of sorted.slice(0, 20)) {
4395
+ println(` ${id.padEnd(50)} p${p}=${s[key]}ms`);
4396
+ }
4397
+ return;
4398
+ }
4399
+
4400
+ if (sub === 'top-slow') {
4401
+ const limit = parseInt(flags.limit || '10', 10);
4402
+ const since = flags.since || '24h';
4403
+ const { skills } = summarizeSkillCalls({ since });
4404
+ const sorted = Object.entries(skills)
4405
+ .filter(([, s]) => s.p95LatencyMs != null)
4406
+ .sort((a, b) => b[1].p95LatencyMs - a[1].p95LatencyMs)
4407
+ .slice(0, limit);
4408
+ if (sorted.length === 0) { println('No latency data found.'); return; }
4409
+ println(`Top ${limit} slow operations — last ${since}\n`);
4410
+ for (const [id, s] of sorted) {
4411
+ println(` ${id.padEnd(50)} p95=${s.p95LatencyMs}ms p50=${s.p50LatencyMs}ms calls=${s.calls}`);
4412
+ }
4413
+ return;
4414
+ }
4415
+
4416
+ if (sub === 'errors') {
4417
+ println('Error trace query requires an OTLP backend (OTEL_EXPORTER_OTLP_ENDPOINT).');
4418
+ println('In local-only mode, check ~/.cx/mcp-audit.json for recent tool call errors.');
4419
+ return;
4420
+ }
4421
+
4422
+ if (sub === 'trace') {
4423
+ const traceId = rest[0];
4424
+ if (!traceId) { errorln('Usage: construct telemetry query trace <traceId>'); process.exit(1); }
4425
+ println(`Trace ${traceId}:`);
4426
+ println('Full span tree query requires an OTLP-compatible backend (Langfuse, Honeycomb, Grafana Tempo).');
4427
+ println('Local mode only captures skill call events — set OTEL_EXPORTER_OTLP_ENDPOINT for full traces.');
4428
+ return;
4429
+ }
4430
+
4431
+ println('Usage: construct telemetry query <subcommand> [flags]');
4432
+ println('');
4433
+ println('Subcommands:');
4434
+ println(' latency [--p=95] [--operation=<op>] [--since=24h] p-ile latency by skill');
4435
+ println(' top-slow [--limit=10] [--since=24h] slowest operations');
4436
+ println(' errors [--agent=<name>] [--since=24h] error span summary');
4437
+ println(' trace <traceId> full span tree');
4438
+ println('');
4439
+ println('For Langfuse/Honeycomb/Grafana Tempo/Datadog, see docs/concepts/observability.mdx');
4440
+ }
4441
+
3248
4442
  async function cmdHook(args) {
3249
4443
  const name = args[0];
3250
4444
  if (!name) {
@@ -3316,11 +4510,14 @@ const handlers = new Map([
3316
4510
  ['infer', cmdInfer],
3317
4511
  ['search', cmdSearch],
3318
4512
  ['storage', cmdStorage],
3319
- ['pricing', cmdPricing],
4513
+ // Pricing / cost readouts are stubbed out: the ledger writes, model-pricing
4514
+ // catalog, and per-turn accounting still run, but no CLI surface exposes
4515
+ // them to the user. Handlers (cmdPricing, cmdCosts, cmdCost) remain in this
4516
+ // file so the OTel + dashboard wiring (planned in Workstream J) can rewire
4517
+ // the surface without re-implementing the logic.
3320
4518
  ['overrides', cmdOverrides],
3321
4519
  ['prune', cmdPrune],
3322
4520
  ['resources', cmdResources],
3323
- ['costs', cmdCosts],
3324
4521
  ['handoffs', cmdHandoffs],
3325
4522
  ['headhunt', cmdHeadhunt],
3326
4523
  ['init', cmdInit],
@@ -3339,10 +4536,12 @@ const handlers = new Map([
3339
4536
  if (sub === 'update') return cmdDocsUpdate(rest);
3340
4537
  if (sub === 'check') return cmdDocsCheck(rest);
3341
4538
  if (sub === 'site') return cmdDocsSite();
3342
- errorln(`Unknown docs subcommand: ${sub}. Available: check, verify, update, site`);
4539
+ if (sub === 'reconcile') return cmdDocsReconcile(rest);
4540
+ errorln(`Unknown docs subcommand: ${sub}. Available: check, verify, update, site, reconcile`);
3343
4541
  process.exit(1);
3344
4542
  }],
3345
4543
  ['docs:verify', cmdDocsVerify],
4544
+ ['docs:reconcile', cmdDocsReconcile],
3346
4545
  ['init:update', cmdInitUpdate],
3347
4546
  ['models', cmdModels],
3348
4547
  ['beads:stats', async (args) => {
@@ -3393,6 +4592,54 @@ const handlers = new Map([
3393
4592
  process.stdout.write(formatPolicyList());
3394
4593
  }
3395
4594
  }],
4595
+ ['policy', async (args) => {
4596
+ const sub = args[0];
4597
+ if (!sub || sub === '--help' || sub === '-h') {
4598
+ println('Usage: construct policy <show>');
4599
+ println(' show [--json] List all active policies with enforcement details');
4600
+ return;
4601
+ }
4602
+ if (sub === 'show') {
4603
+ const { readFileSync, existsSync } = await import('node:fs');
4604
+ const { join } = await import('node:path');
4605
+ const inventoryPath = join(ROOT_DIR, 'agents', 'policy-inventory.json');
4606
+ if (!existsSync(inventoryPath)) {
4607
+ errorln('specialists/policy-inventory.json not found');
4608
+ process.exit(1);
4609
+ }
4610
+ const { policies } = JSON.parse(readFileSync(inventoryPath, 'utf8'));
4611
+ if (args.includes('--json')) {
4612
+ println(JSON.stringify(policies, null, 2));
4613
+ return;
4614
+ }
4615
+ // Table output
4616
+ const ID_W = 25;
4617
+ const SRC_W = 40;
4618
+ const ENF_W = 45;
4619
+ const MODE_W = 18;
4620
+ println(
4621
+ 'ID'.padEnd(ID_W) + 'SOURCE'.padEnd(SRC_W) + 'ENFORCEMENT'.padEnd(ENF_W) + 'MODE',
4622
+ );
4623
+ println('-'.repeat(ID_W + SRC_W + ENF_W + MODE_W));
4624
+ for (const p of policies) {
4625
+ const id = (p.id || '').slice(0, ID_W - 1).padEnd(ID_W);
4626
+ const src = (p.source || '').slice(0, SRC_W - 1).padEnd(SRC_W);
4627
+ const enf = (p.enforcement || '').slice(0, ENF_W - 1).padEnd(ENF_W);
4628
+ const mode = (p.mode || '').slice(0, MODE_W - 1);
4629
+ println(`${id}${src}${enf}${mode}`);
4630
+ if (p.description) println(` ${p.description}`);
4631
+ }
4632
+ println('');
4633
+ const counts = {};
4634
+ for (const p of policies) { counts[p.mode] = (counts[p.mode] || 0) + 1; }
4635
+ for (const [mode, count] of Object.entries(counts)) {
4636
+ println(` ${mode}: ${count}`);
4637
+ }
4638
+ return;
4639
+ }
4640
+ errorln(`Unknown policy subcommand: ${sub}. Available: show`);
4641
+ process.exit(1);
4642
+ }],
3396
4643
  ['auth:status', async (args) => {
3397
4644
  const { getTokenStatus } = await import('../lib/providers/auth-manager.mjs');
3398
4645
  const providers = ['github', 'slack', 'jira', 'salesforce', 'linear'];
@@ -3421,11 +4668,11 @@ const handlers = new Map([
3421
4668
  ['review', cmdReview],
3422
4669
  ['optimize', cmdOptimize],
3423
4670
  ['seed-traces', cmdSeedTraces],
3424
- ['cost', cmdCost],
3425
4671
  ['efficiency', cmdEfficiency],
3426
4672
  ['evals', cmdEvals],
3427
4673
  ['cleanup', cmdCleanup],
3428
4674
  ['version', cmdVersion],
4675
+ ['migrate', cmdMigrate],
3429
4676
  ['docs:update', cmdDocsUpdate],
3430
4677
  ['docs:check', cmdDocsCheck],
3431
4678
  ['docs:site', cmdDocsSite],
@@ -3433,13 +4680,20 @@ const handlers = new Map([
3433
4680
  ['lint:comments', cmdLintComments],
3434
4681
  ['lint:templates', cmdLintTemplates],
3435
4682
  ['gates:audit', cmdGatesAudit],
4683
+ ['ci', cmdCi],
3436
4684
  ['bootstrap', cmdBootstrap],
3437
4685
  ['memory', cmdMemory],
3438
4686
  ['hook', cmdHook],
3439
4687
  ['backup', cmdBackup],
3440
4688
  ['provider', cmdProvider],
4689
+ ['creds', cmdCreds],
4690
+ ['providers', cmdProviders],
4691
+ ['workflow', cmdWorkflow],
4692
+ ['tags', cmdTags],
4693
+ ['scheduler', cmdScheduler],
3441
4694
  ['lint:research', cmdLintResearch],
3442
4695
  ['lint:agents', cmdLintAgents],
4696
+ ['lint:contracts', cmdLintContracts],
3443
4697
  ['audit', async (args) => {
3444
4698
  const sub = args[0];
3445
4699
  if (sub === 'skills') return runAuditSkillsCli(args.slice(1));
@@ -3515,7 +4769,6 @@ const handlers = new Map([
3515
4769
  const correctedIdx = args.indexOf('--corrected');
3516
4770
 
3517
4771
  if (intakeIdIdx === -1) {
3518
- // Show stats instead
3519
4772
  const stats = getAccuracyStats(process.cwd());
3520
4773
  if (json) {
3521
4774
  println(JSON.stringify(stats, null, 2));
@@ -3540,7 +4793,7 @@ const handlers = new Map([
3540
4793
 
3541
4794
  const feedback = createClassificationFeedback({
3542
4795
  intakeId,
3543
- original: { intakeType: 'unknown', primaryOwner: 'unknown' }, // Would load from intake
4796
+ original: { intakeType: 'unknown', primaryOwner: 'unknown' },
3544
4797
  corrected,
3545
4798
  reason,
3546
4799
  });
@@ -3698,6 +4951,9 @@ const handlers = new Map([
3698
4951
  const { runTelemetrySetupCli } = await import('../lib/telemetry/setup.mjs');
3699
4952
  return runTelemetrySetupCli(args);
3700
4953
  }],
4954
+ ['telemetry', async (args) => {
4955
+ return cmdTelemetryQuery(args);
4956
+ }],
3701
4957
  ['beads', async (args) => {
3702
4958
  const sub = args[0];
3703
4959
  const subArgs = args.slice(1);
@@ -3853,7 +5109,10 @@ const handlers = new Map([
3853
5109
  const { runSkillsApplyCli } = await import('../lib/skills-apply.mjs');
3854
5110
  return runSkillsApplyCli(args.slice(1));
3855
5111
  }
3856
- errorln(`Unknown skills subcommand: ${sub}. Available: scope, apply`);
5112
+ if (sub === 'usage' || sub === 'orphans' || sub === 'hot' || sub === 'correlate-quality' || sub === 'backfill-postgres') {
5113
+ return cmdSkillsUsage(sub, args.slice(1));
5114
+ }
5115
+ errorln(`Unknown skills subcommand: ${sub}. Available: scope, apply, usage, orphans, hot, correlate-quality, backfill-postgres`);
3857
5116
  process.exit(1);
3858
5117
  }],
3859
5118
  ['ask', async (args) => {
@@ -3935,7 +5194,34 @@ const handlers = new Map([
3935
5194
  process.exit(1);
3936
5195
  }
3937
5196
  }
3938
- errorln(`Unknown knowledge subcommand: ${sub}. Available: trends, index, add`);
5197
+ if (sub === 'search') {
5198
+ const restArgs = args.slice(1);
5199
+ const tagFlag = restArgs.find((a) => a.startsWith('--tag='))?.replace('--tag=', '');
5200
+ const tagMatchFlag = restArgs.find((a) => a.startsWith('--tag-match='))?.replace('--tag-match=', '');
5201
+ const topKFlag = restArgs.find((a) => a.startsWith('--top='))?.replace('--top=', '');
5202
+ const queryParts = restArgs.filter((a) => !a.startsWith('--'));
5203
+ const query = queryParts.join(' ').trim();
5204
+ if (!query) { errorln('Usage: construct knowledge search "<query>" [--tag=<tag>] [--tag-match=any|all]'); process.exit(1); }
5205
+ const { knowledgeSearch } = await import('../lib/knowledge/search.mjs');
5206
+ const tags = tagFlag ? tagFlag.split(',') : undefined;
5207
+ const result = knowledgeSearch({
5208
+ query,
5209
+ topK: topKFlag ? parseInt(topKFlag, 10) : 5,
5210
+ repoRoot: ROOT_DIR,
5211
+ rootDir: process.cwd(),
5212
+ tags,
5213
+ tagMatch: tagMatchFlag || 'any',
5214
+ });
5215
+ if (!result.ok) { errorln(result.message); process.exit(1); }
5216
+ println(`${result.hits.length} result(s) for "${query}"${tags ? ` [tag: ${tags.join(',')}]` : ''}\n`);
5217
+ for (const h of result.hits) {
5218
+ println(` ${h.file}${h.heading ? ` § ${h.heading}` : ''} (score: ${h.score})`);
5219
+ println(` ${h.text.slice(0, 120).replace(/\n/g, ' ')}`);
5220
+ println('');
5221
+ }
5222
+ return;
5223
+ }
5224
+ errorln(`Unknown knowledge subcommand: ${sub}. Available: trends, index, add, search`);
3939
5225
  process.exit(1);
3940
5226
  }],
3941
5227
  ['sandbox', async (args) => {
@@ -4331,6 +5617,27 @@ if (!handler) {
4331
5617
  process.exit(1);
4332
5618
  }
4333
5619
 
5620
+ // Self-maintenance on version change. Skipped for high-frequency hook calls
5621
+ // (the harness invokes `construct hook <name>` many times per session and
5622
+ // must stay fast). When the installed version differs from the stamp at
5623
+ // ~/.construct/.cleanup-stamp, we run a single bounded cleanup pass and
5624
+ // update the stamp. Subsequent invocations are a single file read.
5625
+ if (command !== 'hook') {
5626
+ try {
5627
+ const { maybeRunCleanupOnUpgrade, formatBytes } = await import('../lib/maintenance/cleanup.mjs');
5628
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8'));
5629
+ const result = maybeRunCleanupOnUpgrade({ homeDir: HOME, env: process.env, currentVersion: pkg.version });
5630
+ if (result.ran && result.summary?.freedBytes > 1024 * 1024) {
5631
+ const previous = result.previousVersion ? ` from v${result.previousVersion}` : '';
5632
+ process.stderr.write(`[construct] Maintenance after upgrade${previous} to v${pkg.version}: freed ${formatBytes(result.summary.freedBytes)}.\n`);
5633
+ }
5634
+ } catch (err) {
5635
+ // Maintenance must never block the actual command. Surface the failure
5636
+ // but continue. The next invocation will retry.
5637
+ process.stderr.write(`[construct] startup maintenance skipped: ${err.message}\n`);
5638
+ }
5639
+ }
5640
+
4334
5641
  // First-invocation resource probe — skipped for hooks, setup, uninstall,
4335
5642
  // doctor, and once BOOTSTRAP_CHECKED=1 is cached in ~/.construct/config.env.
4336
5643
  // On TTY: if anything's missing, prints the status table and offers to run