@geraldmaron/construct 1.0.7 → 1.0.9

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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +130 -58
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /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 ?? []) {
@@ -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) {
@@ -1062,9 +1084,9 @@ async function cmdMigrate(args) {
1062
1084
  const candidates = pathArg
1063
1085
  ? [pathArg]
1064
1086
  : [
1065
- path.join(ROOT_DIR, 'agents', 'registry.json'),
1066
- path.join(ROOT_DIR, 'agents', 'contracts.json'),
1067
- path.join(ROOT_DIR, 'agents', 'role-manifests.json'),
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'),
1068
1090
  path.join(process.cwd(), '.cx', 'config.json'),
1069
1091
  ];
1070
1092
 
@@ -1567,7 +1589,14 @@ async function cmdPricing(args) {
1567
1589
  }
1568
1590
 
1569
1591
  async function cmdHeadhunt(args) { await runHeadhunt({ args, cwd: process.cwd(), homeDir: HOME }); }
1570
- 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
+ }
1571
1600
  async function cmdSetup(args) { await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME }); }
1572
1601
 
1573
1602
  function printConfigModeHelp() {
@@ -1615,11 +1644,19 @@ async function printIntakeHelp() {
1615
1644
  println(' Mark a pending packet processed. --output stamps intake_id + confidence into the artifact frontmatter (provenance).');
1616
1645
  println(' construct intake skip <id> [--reason=…] Mark a pending packet skipped (preserves audit trail).');
1617
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.');
1618
1651
  println(' construct intake metrics Show pipeline volume, velocity, and processing stats.');
1619
1652
  println(' construct intake integrate <id> <system> Push intake to external system (github, jira, confluence).');
1620
1653
  println(' construct intake integrate <id> <system> --create-issue Create an issue from the intake packet.');
1621
1654
  println(' construct intake config Show current watch dirs + scan depth.');
1622
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.');
1623
1660
  println(' Update intake config. --depth caps at the hard limit.');
1624
1661
  println(' construct intake config set --include-project-inbox=on|off');
1625
1662
  println(' construct intake config set --include-docs-intake=on|off');
@@ -1861,6 +1898,14 @@ async function cmdIntake(args) {
1861
1898
  return await cmdIntakeMetrics();
1862
1899
  }
1863
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
+
1864
1909
  if (sub === 'integrate') {
1865
1910
  return await cmdIntakeIntegrate(id, args.slice(2), cwd);
1866
1911
  }
@@ -1869,7 +1914,312 @@ async function cmdIntake(args) {
1869
1914
  return await cmdIntakeConfig(args.slice(1));
1870
1915
  }
1871
1916
 
1872
- 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`);
1873
2223
  process.exit(1);
1874
2224
  }
1875
2225
 
@@ -2543,7 +2893,7 @@ async function cmdUpgrade(args) {
2543
2893
  }
2544
2894
  }
2545
2895
  async function cmdValidate() {
2546
- 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'));
2547
2897
  const result = validateRegistry(reg, { rootDir: ROOT_DIR });
2548
2898
  if (result.valid) {
2549
2899
  println(`✓ Registry valid (${result.summary})`);
@@ -2596,7 +2946,7 @@ async function cmdModels(args) {
2596
2946
  if (args.includes('--apply')) {
2597
2947
  const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
2598
2948
  const models = await pollFreeModels(apiKey);
2599
- 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'));
2600
2950
  const selections = {};
2601
2951
  for (const currentTier of ['reasoning', 'standard', 'fast']) {
2602
2952
  const fallbacks = registry.models?.[currentTier]?.fallback ?? [];
@@ -2646,7 +2996,7 @@ async function cmdModels(args) {
2646
2996
  await cmdSync([]);
2647
2997
  return;
2648
2998
  }
2649
- 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'));
2650
3000
  const result = readCurrentModels(envPath, registry.models ?? {});
2651
3001
  const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
2652
3002
  println('Current model assignments:');
@@ -3004,6 +3354,113 @@ async function cmdEvals(args) {
3004
3354
  println(' construct evals retrieval [--fixture=PATH] [--json]');
3005
3355
  }
3006
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
+
3007
3464
  async function cmdDocsUpdate(args) {
3008
3465
  const check = args.includes('--check');
3009
3466
  const { regenerateDocs: regen } = await import('../lib/auto-docs.mjs');
@@ -3158,6 +3615,209 @@ async function cmdGatesAudit(args) {
3158
3615
  if (!report.ok) process.exit(1);
3159
3616
  }
3160
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
+
3161
3821
  async function cmdLintTemplates(args) {
3162
3822
  const bodyFileArg = args.find((a) => a.startsWith('--body-file='))?.split('=').slice(1).join('=');
3163
3823
  const bodyArgIdx = args.indexOf('--body-file');
@@ -3183,14 +3843,14 @@ async function cmdLintResearch() {
3183
3843
  }
3184
3844
 
3185
3845
  async function cmdLintAgents() {
3186
- const { validateRegistryFile } = await import('../lib/agents/schema.mjs');
3187
- 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');
3188
3848
  const { errors, agentCount } = validateRegistryFile({ registryPath, rootDir: ROOT_DIR });
3189
3849
  if (errors.length === 0) {
3190
- console.log(`agents/registry.json: ${agentCount} agents, 0 errors`);
3850
+ console.log(`specialists/registry.json: ${agentCount} agents, 0 errors`);
3191
3851
  return;
3192
3852
  }
3193
- 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`);
3194
3854
  for (const err of errors) console.error(` ${err}`);
3195
3855
  process.exit(1);
3196
3856
  }
@@ -3199,10 +3859,10 @@ async function cmdLintContracts() {
3199
3859
  const { validateContractsFile } = await import('../lib/contracts/validate.mjs');
3200
3860
  const result = validateContractsFile({ repoRoot: ROOT_DIR });
3201
3861
  if (result.ok) {
3202
- console.log('agents/contracts.json: 0 errors');
3862
+ console.log('specialists/contracts.json: 0 errors');
3203
3863
  return;
3204
3864
  }
3205
- console.error(`agents/contracts.json: ${result.errors.length} error(s)`);
3865
+ console.error(`specialists/contracts.json: ${result.errors.length} error(s)`);
3206
3866
  for (const err of result.errors) console.error(` ${err}`);
3207
3867
  process.exit(1);
3208
3868
  }
@@ -3395,10 +4055,390 @@ async function cmdProvider(args) {
3395
4055
  process.exit(1);
3396
4056
  }
3397
4057
 
3398
- 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`);
3399
4170
  process.exit(1);
3400
4171
  }
3401
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
+
3402
4442
  async function cmdHook(args) {
3403
4443
  const name = args[0];
3404
4444
  if (!name) {
@@ -3470,11 +4510,14 @@ const handlers = new Map([
3470
4510
  ['infer', cmdInfer],
3471
4511
  ['search', cmdSearch],
3472
4512
  ['storage', cmdStorage],
3473
- ['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.
3474
4518
  ['overrides', cmdOverrides],
3475
4519
  ['prune', cmdPrune],
3476
4520
  ['resources', cmdResources],
3477
- ['costs', cmdCosts],
3478
4521
  ['handoffs', cmdHandoffs],
3479
4522
  ['headhunt', cmdHeadhunt],
3480
4523
  ['init', cmdInit],
@@ -3493,10 +4536,12 @@ const handlers = new Map([
3493
4536
  if (sub === 'update') return cmdDocsUpdate(rest);
3494
4537
  if (sub === 'check') return cmdDocsCheck(rest);
3495
4538
  if (sub === 'site') return cmdDocsSite();
3496
- 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`);
3497
4541
  process.exit(1);
3498
4542
  }],
3499
4543
  ['docs:verify', cmdDocsVerify],
4544
+ ['docs:reconcile', cmdDocsReconcile],
3500
4545
  ['init:update', cmdInitUpdate],
3501
4546
  ['models', cmdModels],
3502
4547
  ['beads:stats', async (args) => {
@@ -3547,6 +4592,54 @@ const handlers = new Map([
3547
4592
  process.stdout.write(formatPolicyList());
3548
4593
  }
3549
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
+ }],
3550
4643
  ['auth:status', async (args) => {
3551
4644
  const { getTokenStatus } = await import('../lib/providers/auth-manager.mjs');
3552
4645
  const providers = ['github', 'slack', 'jira', 'salesforce', 'linear'];
@@ -3575,7 +4668,6 @@ const handlers = new Map([
3575
4668
  ['review', cmdReview],
3576
4669
  ['optimize', cmdOptimize],
3577
4670
  ['seed-traces', cmdSeedTraces],
3578
- ['cost', cmdCost],
3579
4671
  ['efficiency', cmdEfficiency],
3580
4672
  ['evals', cmdEvals],
3581
4673
  ['cleanup', cmdCleanup],
@@ -3588,11 +4680,17 @@ const handlers = new Map([
3588
4680
  ['lint:comments', cmdLintComments],
3589
4681
  ['lint:templates', cmdLintTemplates],
3590
4682
  ['gates:audit', cmdGatesAudit],
4683
+ ['ci', cmdCi],
3591
4684
  ['bootstrap', cmdBootstrap],
3592
4685
  ['memory', cmdMemory],
3593
4686
  ['hook', cmdHook],
3594
4687
  ['backup', cmdBackup],
3595
4688
  ['provider', cmdProvider],
4689
+ ['creds', cmdCreds],
4690
+ ['providers', cmdProviders],
4691
+ ['workflow', cmdWorkflow],
4692
+ ['tags', cmdTags],
4693
+ ['scheduler', cmdScheduler],
3596
4694
  ['lint:research', cmdLintResearch],
3597
4695
  ['lint:agents', cmdLintAgents],
3598
4696
  ['lint:contracts', cmdLintContracts],
@@ -3671,7 +4769,6 @@ const handlers = new Map([
3671
4769
  const correctedIdx = args.indexOf('--corrected');
3672
4770
 
3673
4771
  if (intakeIdIdx === -1) {
3674
- // Show stats instead
3675
4772
  const stats = getAccuracyStats(process.cwd());
3676
4773
  if (json) {
3677
4774
  println(JSON.stringify(stats, null, 2));
@@ -3696,7 +4793,7 @@ const handlers = new Map([
3696
4793
 
3697
4794
  const feedback = createClassificationFeedback({
3698
4795
  intakeId,
3699
- original: { intakeType: 'unknown', primaryOwner: 'unknown' }, // Would load from intake
4796
+ original: { intakeType: 'unknown', primaryOwner: 'unknown' },
3700
4797
  corrected,
3701
4798
  reason,
3702
4799
  });
@@ -3854,6 +4951,9 @@ const handlers = new Map([
3854
4951
  const { runTelemetrySetupCli } = await import('../lib/telemetry/setup.mjs');
3855
4952
  return runTelemetrySetupCli(args);
3856
4953
  }],
4954
+ ['telemetry', async (args) => {
4955
+ return cmdTelemetryQuery(args);
4956
+ }],
3857
4957
  ['beads', async (args) => {
3858
4958
  const sub = args[0];
3859
4959
  const subArgs = args.slice(1);
@@ -4009,7 +5109,10 @@ const handlers = new Map([
4009
5109
  const { runSkillsApplyCli } = await import('../lib/skills-apply.mjs');
4010
5110
  return runSkillsApplyCli(args.slice(1));
4011
5111
  }
4012
- 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`);
4013
5116
  process.exit(1);
4014
5117
  }],
4015
5118
  ['ask', async (args) => {
@@ -4091,7 +5194,34 @@ const handlers = new Map([
4091
5194
  process.exit(1);
4092
5195
  }
4093
5196
  }
4094
- 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`);
4095
5225
  process.exit(1);
4096
5226
  }],
4097
5227
  ['sandbox', async (args) => {