@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
package/bin/construct CHANGED
@@ -65,10 +65,9 @@ import { runTeamReviewCli } from '../lib/telemetry/team-rollup.mjs';
65
65
  import { readDashboardState, startDashboard, startServices } from '../lib/service-manager.mjs';
66
66
  import { readCostLog, summarizeCostData, formatCostReport, clearCostLog } from '../lib/cost.mjs';
67
67
  import { readEfficiencyLog, summarizeEfficiencyData, formatEfficiencyReport } from '../lib/efficiency.mjs';
68
+ import { resolveColors } from '../lib/term-format.mjs';
68
69
  import { buildHybridSearchResultsAsync } from '../lib/storage/hybrid-query.mjs';
69
- import { createSqlClient, closeSqlClient, readVectorConfig } from '../lib/storage/backend.mjs';
70
- import { describeVectorStore } from '../lib/storage/vector-store.mjs';
71
- import { describeSqlStore } from '../lib/storage/sql-store.mjs';
70
+ import { createSqlClient, closeSqlClient } from '../lib/storage/backend.mjs';
72
71
  import { syncFileStateToSql } from '../lib/storage/sync.mjs';
73
72
  import { deleteIngestedArtifacts, getStorageStatus, inferProjectName, resetStorage } from '../lib/storage/admin.mjs';
74
73
  import { runPressureRelease } from '../lib/runtime-pressure.mjs';
@@ -80,12 +79,7 @@ for (const [key, value] of Object.entries(ENV)) {
80
79
  if (!(key in process.env)) process.env[key] = value;
81
80
  }
82
81
 
83
- const COLORS = (!process.env.NO_COLOR && process.stdout.isTTY && process.env.TERM !== 'dumb')
84
- ? {
85
- bold: '\u001b[1m', dim: '\u001b[2m', reset: '\u001b[0m',
86
- red: '\u001b[31m', green: '\u001b[32m', yellow: '\u001b[33m', cyan: '\u001b[36m',
87
- }
88
- : { bold: '', dim: '', reset: '', red: '', green: '', yellow: '', cyan: '' };
82
+ const COLORS = resolveColors();
89
83
 
90
84
  function println(message = '') { process.stdout.write(`${message}\n`); }
91
85
  function errorln(message = '') { process.stderr.write(`${message}\n`); }
@@ -250,6 +244,45 @@ async function cmdSync(args) {
250
244
  opLog.close('failed', { stage: 'sync-specialists', status: syncStatus });
251
245
  process.exit(syncStatus);
252
246
  }
247
+
248
+ // State reconciliation (ADR-0027): bring on-disk state in line with the
249
+ // current disposition contract. Auto-safety tasks run silently; ask-safety
250
+ // tasks surface via construct doctor instead. --reconcile=<id> runs one task
251
+ // on explicit consent (the path for ask-safety tasks). --no-reconcile
252
+ // bypasses the auto pass entirely for diagnostic runs.
253
+
254
+ const reconcileArg = args.find((a) => a.startsWith('--reconcile='));
255
+ if (reconcileArg) {
256
+ const id = reconcileArg.slice('--reconcile='.length);
257
+ try {
258
+ const { runReconciliation } = await import('../lib/reconcile/index.mjs');
259
+ const result = await runReconciliation(id, {});
260
+ if (!result.ok) {
261
+ errorln(`Reconciliation failed (${id}): ${result.reason}`);
262
+ if (result.available) errorln(`Available: ${result.available.join(', ')}`);
263
+ opLog.close('failed', { stage: 'reconcile', id, reason: result.reason });
264
+ process.exit(1);
265
+ }
266
+ if (result.ran) ok(`Reconciled (${id}): ${result.summary}`);
267
+ else println(`Reconciliation (${id}): ${result.summary}`);
268
+ opLog.event('reconcile-explicit', { id, ran: !!result.ran });
269
+ } catch (err) {
270
+ errorln(`Reconciliation error (${id}): ${err.message}`);
271
+ opLog.close('failed', { stage: 'reconcile', id, reason: err.message });
272
+ process.exit(1);
273
+ }
274
+ } else if (!args.includes('--no-reconcile')) {
275
+ try {
276
+ const verbose = args.includes('--verbose') || args.includes('-v');
277
+ const { runAutoReconciliations } = await import('../lib/reconcile/index.mjs');
278
+ const { applied } = await runAutoReconciliations({ verbose });
279
+ for (const entry of applied) ok(`Reconciled (${entry.id}): ${entry.summary}`);
280
+ opLog.event('reconcile', { applied: applied.length });
281
+ } catch (err) {
282
+ opLog.event('reconcile-error', { message: err.message });
283
+ }
284
+ }
285
+
253
286
  if (args.includes('--no-docs')) {
254
287
  opLog.close('ok', { docs: 'skipped' });
255
288
  return;
@@ -584,23 +617,24 @@ async function cmdDoctor() {
584
617
  // Tier model selection. Construct ships with no default — at least
585
618
  // one tier must be configured (registry.json primary OR CX_MODEL_*
586
619
  // env override) before any LLM-backed workflow can run.
587
- // Skip in CI model tiers are user-specific, not repo-wide.
588
- const inCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
620
+ // A missing, partial, or unresolvable tier is advisory, not a repo defect:
621
+ // model tiers are user/machine-specific config, so doctor warns and never
622
+ // fails the gate on them.
589
623
  try {
590
624
  const { resolveModelTiers } = await import('../lib/model-registry.mjs');
591
625
  const resolved = resolveModelTiers({ env: process.env });
592
626
  if (resolved.complete && !resolved.errors) {
593
627
  add('Models — all tiers configured', true);
594
628
  } else if (resolved.configured === 0) {
595
- add('Models — no tier configured (pick one in the dashboard or run `construct models --apply`)', inCI, inCI);
629
+ add('Models — no tier configured (pick one in the dashboard or run `construct models --apply`)', false, true);
596
630
  } else {
597
631
  const missing = Object.entries(resolved.models)
598
632
  .filter(([, v]) => !v)
599
633
  .map(([k]) => k);
600
- add(`Models — only ${resolved.configured}/3 tiers configured (missing: ${missing.join(', ')})`, inCI, inCI);
634
+ add(`Models — only ${resolved.configured}/3 tiers configured (missing: ${missing.join(', ')})`, false, true);
601
635
  }
602
636
  } catch (err) {
603
- add(`Models — check failed: ${err.message}`, inCI, inCI);
637
+ add(`Models — check failed: ${err.message}`, false, true);
604
638
  }
605
639
 
606
640
  add('Node.js 20+ (recommended)', Number.parseInt(process.versions.node.split('.')[0], 10) >= 20);
@@ -786,35 +820,10 @@ async function cmdDoctor() {
786
820
  const researchLintResults = lintResearchRepo({ rootDir: process.cwd() });
787
821
  add('Research artifacts meet minimum evidence structure', researchLintResults.every((entry) => entry.errors.length === 0), true);
788
822
 
789
- const sqlConfig = describeSqlStore(process.env);
790
- const sqlClient = createSqlClient(process.env);
791
- // Track SQL reachability so the schema-migration check can cascade correctly
792
- // rather than emitting a redundant second warning for the same offline server.
793
- let sqlReachable = false;
794
- if (sqlConfig.configured && sqlClient) {
795
- try {
796
- await sqlClient`select 1 as ok`;
797
- sqlReachable = true;
798
- add('SQL backend reachable', true);
799
- } catch {
800
- // In solo mode the local vector index is the active storage path, so an
801
- // unreachable Postgres is expected unless the user has started it. Emit a
802
- // contextual message rather than a bare ⚠ so the fix is obvious.
803
- const { getDeploymentMode } = await import('../lib/deployment-mode.mjs');
804
- const mode = getDeploymentMode(process.env) ?? 'solo';
805
- const hint = mode === 'solo'
806
- ? ' (solo mode — run `construct dev` to start Postgres)'
807
- : ' — check DATABASE_URL and that the server is running';
808
- add(`SQL backend unreachable${hint}`, false, true);
809
- } finally {
810
- await closeSqlClient(sqlClient);
811
- }
812
- } else {
813
- add('SQL backend configured', sqlConfig.fallbackAvailable, true);
814
- }
815
-
816
- const vectorConfig = describeVectorStore(process.env);
817
- add('Vector backend configured', Boolean(vectorConfig.endpoint || vectorConfig.indexPath || vectorConfig.mode === 'file'));
823
+ const { VectorClient } = await import('../lib/storage/vector-client.mjs');
824
+ const vectorClient = new VectorClient({ env: process.env });
825
+ const vectorOk = await vectorClient.isHealthy();
826
+ add('Vector backend (LanceDB) reachable', vectorOk);
818
827
 
819
828
  const { regenerateDocs } = await import('../lib/auto-docs.mjs');
820
829
  const { changed: stale } = await regenerateDocs({ rootDir: ROOT_DIR, check: true });
@@ -943,35 +952,6 @@ async function cmdDoctor() {
943
952
  add('Observation size', false, true);
944
953
  }
945
954
 
946
- // Only run the schema migration check when SQL is both configured AND
947
- // currently reachable. If the SQL check above already failed (sqlReachable
948
- // is false) this check will also fail with the same ECONNREFUSED and would
949
- // produce a redundant second warning about the same offline server.
950
- if (sqlConfig.configured && sqlReachable) {
951
- try {
952
- const migClient = createSqlClient(process.env);
953
- if (migClient) {
954
- const { describeMigrations } = await import('../lib/storage/migrations.mjs');
955
- const m = await describeMigrations(migClient);
956
- await closeSqlClient(migClient);
957
- const ok = !!m.ok && m.drift.length === 0;
958
- let label = 'Schema migrations applied + no drift';
959
- if (!ok && m.drift && m.drift.length > 0) {
960
- const repairable = m.drift.filter((d) => d.idempotent).length;
961
- const notRepairable = m.drift.length - repairable;
962
- if (notRepairable === 0) {
963
- label = `Schema migrations: ${m.drift.length} drifted (all idempotent — run \`construct storage repair-migrations --yes\`)`;
964
- } else {
965
- label = `Schema migrations: ${m.drift.length} drifted (${notRepairable} non-idempotent — write a new migration file)`;
966
- }
967
- }
968
- add(label, ok, true);
969
- }
970
- } catch {
971
- add('Schema migrations applied + no drift', false, true);
972
- }
973
- }
974
-
975
955
  try {
976
956
  const { detectBeadsDrift } = await import('../lib/beads/drift.mjs');
977
957
  const drift = detectBeadsDrift();
@@ -1001,24 +981,6 @@ async function cmdDoctor() {
1001
981
  add('cm available', false, true);
1002
982
  }
1003
983
 
1004
- try {
1005
- const dockerCli = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['docker'], { stdio: 'pipe' });
1006
- let dockerOk = false;
1007
- let dockerNote = 'Docker not installed (optional — managed Postgres needs it)';
1008
- if (dockerCli.status === 0) {
1009
- const dockerInfo = spawnSync('docker', ['info'], { stdio: 'pipe' });
1010
- if (dockerInfo.status === 0) {
1011
- dockerOk = true;
1012
- dockerNote = 'Docker daemon reachable';
1013
- } else {
1014
- dockerNote = 'Docker CLI installed but daemon not reachable — run `construct up` on macOS to auto-start, or start Docker manually';
1015
- }
1016
- }
1017
- add(dockerOk ? 'Docker daemon reachable' : `Docker daemon reachable (${dockerNote})`, dockerOk, true);
1018
- } catch {
1019
- add('Docker daemon reachable', false, true);
1020
- }
1021
-
1022
984
  try {
1023
985
  const failuresPath = path.join(HOME, '.cx', 'hook-failures.jsonl');
1024
986
  if (fs.existsSync(failuresPath)) {
@@ -1069,6 +1031,26 @@ async function cmdDoctor() {
1069
1031
  add('Registry integrity check', false);
1070
1032
  }
1071
1033
 
1034
+ // Reconciliation drift (ADR-0027). Any task reporting needsRepair is surfaced
1035
+ // with its summary and the exact `construct sync --reconcile=<id>` command.
1036
+ // Auto tasks that still show drift (e.g. a run with --no-reconcile) and all
1037
+ // ask tasks both land here, since ask tasks never run from the auto pass.
1038
+ let reconcileDrift = [];
1039
+ try {
1040
+ const { listReconciliations } = await import('../lib/reconcile/index.mjs');
1041
+ const tasks = await listReconciliations();
1042
+ reconcileDrift = tasks.filter((t) => t.needsRepair);
1043
+ add(
1044
+ reconcileDrift.length === 0
1045
+ ? 'Reconciliation drift: none'
1046
+ : `Reconciliation drift: ${reconcileDrift.length} task${reconcileDrift.length === 1 ? '' : 's'} need repair (see below)`,
1047
+ reconcileDrift.length === 0,
1048
+ true,
1049
+ );
1050
+ } catch (err) {
1051
+ add(`Reconciliation drift check failed: ${err.message}`, false, true);
1052
+ }
1053
+
1072
1054
  println('Construct Health Check');
1073
1055
  println('══════════════════════');
1074
1056
  println('');
@@ -1080,6 +1062,17 @@ async function cmdDoctor() {
1080
1062
  else if (check.optional) warnCount += 1;
1081
1063
  else failCount += 1;
1082
1064
  }
1065
+
1066
+ if (reconcileDrift.length > 0) {
1067
+ println('');
1068
+ println('Reconciliation drift:');
1069
+ for (const task of reconcileDrift) {
1070
+ println(` ⚠ ${task.id} (${task.safety})`);
1071
+ if (task.summary) println(` ${task.summary}`);
1072
+ println(` fix: construct sync --reconcile=${task.id}`);
1073
+ }
1074
+ }
1075
+
1083
1076
  println('');
1084
1077
  println(`Results: ${okCount} passed, ${warnCount} warnings, ${failCount} failed`);
1085
1078
  if (failCount > 0) process.exit(1);
@@ -1178,6 +1171,16 @@ async function cmdUp(args = []) {
1178
1171
  else warn(`${svc.name}: ${svc.note}`);
1179
1172
  }
1180
1173
 
1174
+ // Show Langfuse credentials guidance after first start
1175
+ const langfuseService = results.find(r => r.name === 'Langfuse');
1176
+ if (langfuseService && langfuseService.status === 'started' && langfuseService.note?.includes('auto-configured')) {
1177
+ println('');
1178
+ info('Langfuse credentials:');
1179
+ info(' Dashboard: http://localhost:54330 (login: admin@construct.local / construct-admin)');
1180
+ info(' API keys auto-saved to ~/.construct/config.env');
1181
+ info(' Run `construct status` anytime to view credentials');
1182
+ }
1183
+
1181
1184
  if (recovery) {
1182
1185
  println('');
1183
1186
  info(`Recovery: ${recovery.message}`);
@@ -1410,6 +1413,44 @@ async function cmdIngest(args) {
1410
1413
  }
1411
1414
  }
1412
1415
 
1416
+ async function cmdExport(args) {
1417
+ // construct export <markdown-file> --to=<pdf|docx|html> [--output=<path>]
1418
+ // Optional capability per ADR-0024: requires pandoc on PATH (and typst for PDF).
1419
+ // Absent tooling returns a structured "install X" error, not a crash.
1420
+
1421
+ const positional = args.filter((a) => !a.startsWith('--'));
1422
+ const inputPath = positional[0];
1423
+ const toFlag = args.find((a) => a.startsWith('--to='))?.split('=')[1];
1424
+ const outFlag = args.find((a) => a.startsWith('--output='))?.split('=')[1];
1425
+ const detectOnly = args.includes('--detect');
1426
+
1427
+ const { detect, exportMarkdown, EXPORT_FORMATS } = await import('../lib/document-export.mjs');
1428
+
1429
+ if (detectOnly) {
1430
+ const format = toFlag || 'pdf';
1431
+ println(JSON.stringify(detect(format), null, 2));
1432
+ return;
1433
+ }
1434
+
1435
+ if (!inputPath) {
1436
+ errorln('Usage: construct export <markdown-file> --to=<pdf|docx|html> [--output=<path>] [--detect]');
1437
+ process.exit(1);
1438
+ }
1439
+ if (!toFlag) {
1440
+ errorln(`--to is required. Supported formats: ${EXPORT_FORMATS.join(', ')}`);
1441
+ process.exit(1);
1442
+ }
1443
+
1444
+ const resolvedIn = path.resolve(process.cwd(), inputPath);
1445
+ const resolvedOut = outFlag ? path.resolve(process.cwd(), outFlag) : null;
1446
+ const result = exportMarkdown({ inputPath: resolvedIn, outputPath: resolvedOut, format: toFlag });
1447
+ if (!result.ok) {
1448
+ errorln(result.message);
1449
+ process.exit(result.missing?.length ? 2 : 1);
1450
+ }
1451
+ ok(result.message);
1452
+ }
1453
+
1413
1454
  async function cmdInfer(args) {
1414
1455
  try {
1415
1456
  const result = await runInferCli(args, { cwd: process.cwd() });
@@ -1454,49 +1495,11 @@ async function cmdStorage(args) {
1454
1495
  println(`Reconciled observations: re-embedded ${result.reembedded} of ${result.checked} (model ${result.model}).`);
1455
1496
  return;
1456
1497
  }
1457
- if (action === 'migrations') {
1458
- const { describeMigrations } = await import('../lib/storage/migrations.mjs');
1459
- const sqlClient = createSqlClient(process.env);
1460
- if (!sqlClient) { errorln('No DATABASE_URL configured.'); process.exit(1); }
1461
- try {
1462
- const report = await describeMigrations(sqlClient);
1463
- println(JSON.stringify(report, null, 2));
1464
- if (report.drift?.length > 0) process.exit(2);
1465
- } finally {
1466
- await closeSqlClient(sqlClient);
1467
- }
1468
- return;
1469
- }
1470
- if (action === 'repair-migrations') {
1471
- const confirm = rest.includes('--yes');
1472
- if (!confirm) {
1473
- errorln('This re-applies drifted migrations (idempotent only) and updates their recorded SHA.');
1474
- errorln('Destructive migrations (DROP / TRUNCATE / ALTER…DROP / DELETE) are refused.');
1475
- errorln('Re-run with --yes to proceed.');
1476
- process.exit(1);
1477
- }
1478
- const { runMigrations } = await import('../lib/storage/migrations.mjs');
1479
- const sqlClient = createSqlClient(process.env);
1480
- if (!sqlClient) { errorln('No DATABASE_URL configured.'); process.exit(1); }
1481
- try {
1482
- const result = await runMigrations(sqlClient, { repair: true });
1483
- println(JSON.stringify({
1484
- applied: result.applied,
1485
- repaired: result.repaired,
1486
- skipped: result.skipped,
1487
- drift: result.drift,
1488
- }, null, 2));
1489
- if (result.drift.length > 0) {
1490
- errorln('');
1491
- errorln('Refused to repair non-idempotent migrations:');
1492
- for (const d of result.drift) errorln(` ${d.filename}`);
1493
- process.exit(2);
1494
- }
1495
- info(`Repaired ${result.repaired.length} migration(s); applied ${result.applied.length} new.`);
1496
- } finally {
1497
- await closeSqlClient(sqlClient);
1498
- }
1499
- return;
1498
+ if (action === 'migrations' || action === 'repair-migrations') {
1499
+ // Postgres schema migrations were removed with the SQL backend; LanceDB
1500
+ // tables are created on demand and need no migration runner.
1501
+ errorln('construct storage ' + action + ' is no longer supported: Postgres was removed.');
1502
+ process.exit(1);
1500
1503
  }
1501
1504
  if (action === 'sync') {
1502
1505
  const result = await syncFileStateToSql(process.cwd(), { env: process.env, project: inferProjectName(process.cwd()) });
@@ -1976,7 +1979,17 @@ async function cmdEmbeddedTriage(args) {
1976
1979
  input = fs.readFileSync(0, 'utf8');
1977
1980
  }
1978
1981
  if (!input.trim() && !ingestion?.error) {
1979
- errorln('Usage: construct intake classify --json [--text "..." | --file <path> | <stdin>] [--source <path>] [--artifact-type <t>] [--available-roles a,b]');
1982
+ // This is an embedded-contract verb: an embedder that pipes empty/no input
1983
+ // must still get a parseable, versioned envelope — not a bare exit. Emit a
1984
+ // typed error envelope on stdout so the contract holds, and exit non-zero so
1985
+ // shell callers still see failure.
1986
+ const { wrapResponse } = await import('../lib/embedded-contract/envelope.mjs');
1987
+ const envelope = wrapResponse({
1988
+ surface: 'cli',
1989
+ warnings: ['No artifact provided — pass --text "...", --file <path>, or pipe content on stdin.'],
1990
+ data: { error: { code: 'missing_input', message: 'intake classify requires an artifact to classify; received empty input.' } },
1991
+ });
1992
+ println(JSON.stringify(envelope, null, 2));
1980
1993
  process.exit(1);
1981
1994
  }
1982
1995
  const request = {
@@ -3364,6 +3377,47 @@ async function cmdOrchestrate(args) {
3364
3377
  const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
3365
3378
  const wantsJson = rest.includes('--json') || args.includes('--json');
3366
3379
 
3380
+ // Thin-client mode: drive the local orchestration daemon over HTTP instead of
3381
+ // running in-process, proving the engine-as-service contract editors/CI use.
3382
+ if (rest.includes('--remote')) {
3383
+ const remoteVal = flag('--remote');
3384
+ const base = (remoteVal && /^https?:\/\//.test(remoteVal)) ? remoteVal.replace(/\/$/, '') : `http://${process.env.BIND_HOST || '127.0.0.1'}:${process.env.PORT || '4242'}`;
3385
+ const { getDashboardToken } = await import('../lib/server/auth.mjs');
3386
+ const token = getDashboardToken();
3387
+ const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
3388
+ const positional = rest.filter((a) => !a.startsWith('--'));
3389
+ try {
3390
+ if (sub === 'run') {
3391
+ const text = flag('--text') || positional.join(' ').trim();
3392
+ if (!text) { errorln('Usage: construct orchestrate run "<request>" --remote [--strategy S] [--worker-backend provider] [--json]'); process.exit(1); }
3393
+ const res = await fetch(`${base}/api/orchestration/runs`, { method: 'POST', headers, body: JSON.stringify({ request: text, workflowType: flag('--workflow-type'), requestedStrategy: flag('--strategy') || 'auto', host: flag('--host'), hostModel: flag('--host-model'), hostProvider: flag('--host-provider'), workerBackend: flag('--worker-backend'), fileCount: Number(flag('--file-count') || 0), moduleCount: Number(flag('--module-count') || 0) }) });
3394
+ const env = await res.json();
3395
+ if (!res.ok) { errorln(`Daemon error (${res.status}): ${env.error || 'unknown'}`); process.exit(1); }
3396
+ if (wantsJson) { println(JSON.stringify(env.data, null, 2)); return; }
3397
+ println(`Run ${env.data.runId} started on ${base} — ${env.data.status} · executionMode=${env.data.execution?.executionMode}. Poll: construct orchestrate status ${env.data.runId} --remote`);
3398
+ return;
3399
+ }
3400
+ if (sub === 'status') {
3401
+ const runId = positional[0];
3402
+ const path = runId ? `/api/orchestration/runs/${encodeURIComponent(runId)}` : '/api/orchestration/runs';
3403
+ const res = await fetch(`${base}${path}`, { headers });
3404
+ const env = await res.json();
3405
+ if (!res.ok) { errorln(`Daemon error (${res.status}): ${env.error || 'unknown'}`); process.exit(1); }
3406
+ if (wantsJson) { println(JSON.stringify(env.data, null, 2)); return; }
3407
+ if (runId) {
3408
+ println(`Run ${env.data.runId} — ${env.data.status} · executionMode=${env.data.execution?.executionMode}`);
3409
+ for (const t of env.data.tasks || []) println(` ${t.id} ${t.role} — ${t.status}${t.output ? ` (${t.output.length} chars)` : ''}`);
3410
+ } else {
3411
+ for (const r of env.data.runs || []) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
3412
+ }
3413
+ return;
3414
+ }
3415
+ } catch (err) {
3416
+ errorln(`Could not reach the orchestration daemon at ${base}: ${err.message}. Start it with \`construct dashboard\`.`);
3417
+ process.exit(1);
3418
+ }
3419
+ }
3420
+
3367
3421
  if (sub === 'run') {
3368
3422
  const { runOrchestration, planRun, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3369
3423
  const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--') && !['--no-construct', '--no-execute', '--json'].includes(rest[i - 1])));
@@ -3377,6 +3431,7 @@ async function cmdOrchestrate(args) {
3377
3431
  host: flag('--host'),
3378
3432
  hostModel: flag('--host-model'),
3379
3433
  hostProvider: flag('--host-provider'),
3434
+ workerBackend: flag('--worker-backend'),
3380
3435
  fileCount: Number(flag('--file-count') || 0),
3381
3436
  moduleCount: Number(flag('--module-count') || 0),
3382
3437
  };
@@ -3386,6 +3441,9 @@ async function cmdOrchestrate(args) {
3386
3441
  if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
3387
3442
  if (run.tasks.length) println(` tasks: ${run.tasks.map((t) => `${t.role}(${t.status})`).join(' → ')}`);
3388
3443
  else println(' tasks: none (no Construct-owned specialist sequence for this execution mode)');
3444
+ for (const t of run.tasks) {
3445
+ if (t.reasoning) println(` ↳ ${t.role} reasoning: ${t.reasoning.length > 300 ? `${t.reasoning.slice(0, 300)}…` : t.reasoning}`);
3446
+ }
3389
3447
  return;
3390
3448
  }
3391
3449
 
@@ -3397,7 +3455,10 @@ async function cmdOrchestrate(args) {
3397
3455
  if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
3398
3456
  if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3399
3457
  println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
3400
- for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
3458
+ for (const t of run.tasks) {
3459
+ println(` ${t.id} ${t.role} — ${t.status}`);
3460
+ if (t.reasoning) println(` reasoning: ${t.reasoning.length > 300 ? `${t.reasoning.slice(0, 300)}…` : t.reasoning}`);
3461
+ }
3401
3462
  return;
3402
3463
  }
3403
3464
  const runs = await getRuns(process.cwd());
@@ -4394,99 +4455,10 @@ async function cmdLintContracts() {
4394
4455
  }
4395
4456
 
4396
4457
  async function cmdBackup(args) {
4397
- const sub = args[0];
4398
-
4399
- if (!sub || sub === 'create') {
4400
- const includeSecrets = args.includes('--include-secrets');
4401
- const noPrune = args.includes('--no-prune');
4402
- const keepArg = args.find((a) => a.startsWith('--keep='));
4403
- const keep = keepArg
4404
- ? Number(keepArg.split('=')[1])
4405
- : Number(process.env.CONSTRUCT_BACKUP_RETAIN || 10);
4406
- const { createBackup, pruneBackups } = await import('../lib/storage/backup.mjs');
4407
- println('Creating backup…');
4408
- try {
4409
- const result = await createBackup({ includeSecrets });
4410
- ok(`Backup created: ${result.path}`);
4411
- println(` Contents: ${result.manifest.contents.join(', ') || '(empty)'}`);
4412
- if (!includeSecrets) println(' Secrets redacted. Use --include-secrets to include them.');
4413
- if (!noPrune) {
4414
- const pruned = pruneBackups({ keep });
4415
- if (pruned.removed.length) {
4416
- println(` Auto-pruned ${pruned.removed.length} old backup(s); kept ${pruned.kept}.`);
4417
- }
4418
- }
4419
- } catch (err) {
4420
- errorln(`Backup failed: ${err.message}`);
4421
- process.exit(1);
4422
- }
4423
- return;
4424
- }
4425
-
4426
- if (sub === 'prune') {
4427
- const keepArg = args.find((a) => a.startsWith('--keep='));
4428
- const keep = keepArg
4429
- ? Number(keepArg.split('=')[1])
4430
- : Number(process.env.CONSTRUCT_BACKUP_RETAIN || 10);
4431
- const { pruneBackups } = await import('../lib/storage/backup.mjs');
4432
- const result = pruneBackups({ keep });
4433
- if (result.removed.length === 0) {
4434
- println(`Nothing to prune (have ${result.kept}, keeping ${keep}).`);
4435
- } else {
4436
- ok(`Pruned ${result.removed.length} backup(s); kept ${result.kept}.`);
4437
- for (const r of result.removed) println(` removed ${r}`);
4438
- }
4439
- return;
4440
- }
4441
-
4442
- if (sub === 'verify') {
4443
- const archivePath = args[1];
4444
- if (!archivePath) { errorln('Usage: construct backup verify <archive>'); process.exit(1); }
4445
- const { verifyBackup } = await import('../lib/storage/backup.mjs');
4446
- const result = await verifyBackup(archivePath);
4447
- if (result.ok) {
4448
- ok('Backup verified — all checksums match.');
4449
- } else {
4450
- errorln(`Backup verification failed:\n ${result.errors.join('\n ')}`);
4451
- process.exit(1);
4452
- }
4453
- return;
4454
- }
4455
-
4456
- if (sub === 'restore') {
4457
- const archivePath = args[1];
4458
- const yes = args.includes('--yes');
4459
- if (!archivePath) { errorln('Usage: construct backup restore <archive> [--yes]'); process.exit(1); }
4460
- if (!yes) {
4461
- println(`About to restore from: ${archivePath}`);
4462
- println('This will overwrite observations, sessions, config.env, and registry.json.');
4463
- println('Press Ctrl+C to cancel, or re-run with --yes to proceed.');
4464
- await new Promise((r) => setTimeout(r, 3000));
4465
- }
4466
- const { restoreBackup } = await import('../lib/storage/backup.mjs');
4467
- const result = await restoreBackup(archivePath, { yes });
4468
- if (result.ok) {
4469
- ok(`Restored: ${result.restored.join(', ')}`);
4470
- } else {
4471
- if (result.restored.length) println(`Partially restored: ${result.restored.join(', ')}`);
4472
- errorln(`Errors:\n ${result.errors.join('\n ')}`);
4473
- process.exit(1);
4474
- }
4475
- return;
4476
- }
4477
-
4478
- if (sub === 'list') {
4479
- const { listBackups } = await import('../lib/storage/backup.mjs');
4480
- const backups = listBackups();
4481
- if (!backups.length) { println('No backups found.'); return; }
4482
- for (const b of backups) {
4483
- const mb = (b.size / 1024 / 1024).toFixed(1);
4484
- println(` ${b.name} (${mb} MB) ${b.mtime.toISOString().slice(0, 19)}`);
4485
- }
4486
- return;
4487
- }
4488
-
4489
- errorln(`Unknown backup subcommand: ${sub}. Available: create, verify, restore, list, prune`);
4458
+ // Postgres-era backups were removed with the SQL backend (lib/storage/backup.mjs).
4459
+ // State is now Git-backed (.cx/) plus the local LanceDB index under .cx/lancedb.
4460
+ void args;
4461
+ errorln('construct backup is no longer supported: Postgres was removed. Commit .cx/ for Git-backed state; the LanceDB index lives under .cx/lancedb.');
4490
4462
  process.exit(1);
4491
4463
  }
4492
4464
 
@@ -5059,6 +5031,7 @@ async function readStdin() {
5059
5031
  const handlers = new Map([
5060
5032
  // Core
5061
5033
  ['dev', cmdUp],
5034
+ ['dashboard', cmdServe],
5062
5035
  ['stop', cmdDown],
5063
5036
  ['status', cmdStatus],
5064
5037
  ['install', cmdSetup],
@@ -5080,6 +5053,7 @@ const handlers = new Map([
5080
5053
  ['diff', cmdDiff],
5081
5054
  ['distill', cmdDistill],
5082
5055
  ['ingest', cmdIngest],
5056
+ ['export', cmdExport],
5083
5057
  ['infer', cmdInfer],
5084
5058
  ['search', cmdSearch],
5085
5059
  ['storage', cmdStorage],
@@ -5119,6 +5093,10 @@ const handlers = new Map([
5119
5093
  ['models', cmdModels],
5120
5094
  ['execution', cmdExecution],
5121
5095
  ['orchestrate', cmdOrchestrate],
5096
+ ['acp', async () => {
5097
+ const { runAcpServer } = await import('../lib/acp/server.mjs');
5098
+ runAcpServer({ input: process.stdin, output: process.stdout, env: process.env, defaultCwd: process.cwd() });
5099
+ }],
5122
5100
  ['beads:stats', async (args) => {
5123
5101
  const { getContentionStats, getHumanStatus } = await import('../lib/beads-optimistic.mjs');
5124
5102
  const jsonOutput = args.includes('--json');
@@ -24,6 +24,7 @@ import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
 
26
26
  import { stageProjectAdapters } from '../lib/install/stage-project.mjs';
27
+ import { missingIgnorePatterns } from '../lib/host-disposition.mjs';
27
28
 
28
29
  const HERE = path.dirname(fileURLToPath(import.meta.url));
29
30
  const PKG_ROOT = path.resolve(HERE, '..');
@@ -70,23 +71,17 @@ try {
70
71
  }
71
72
  } catch { /* fall through */ }
72
73
 
73
- // `npm i -g @geraldmaron/construct` runs the postinstall with
74
- // npm_config_global=true. Wire the `construct` front-door agent into the
75
- // user's home directories so it's reachable from every host (Claude Code,
76
- // Codex, Copilot, OpenCode) immediately after a global install. Specialists
77
- // stay project-only and land when the user runs `construct init` in a repo.
74
+ // ADR-0029: machine-scope writes are opt-in. The postinstall hook for a global
75
+ // install prints scope guidance and exits; `~/.claude/CLAUDE.md`,
76
+ // `~/.claude/settings.json`, and `~/.construct/*` land only when the user runs
77
+ // `construct install --scope=user`, so the consent point is visible.
78
78
 
79
79
  if (process.env.npm_config_global === 'true' || process.env.npm_config_global === true) {
80
- const syncScript = path.join(PKG_ROOT, 'scripts', 'sync-specialists.mjs');
81
- if (existsSync(syncScript)) {
82
- log('global install detected; syncing front-door agent into ~/');
83
- const result = spawnSync(process.execPath, [syncScript, '--global'], {
84
- stdio: 'inherit',
85
- });
86
- if (result.status !== 0) {
87
- fail(`Global front-door sync failed (exit ${result.status}).`, 'Run `construct sync --global` to complete it.');
88
- }
89
- }
80
+ log('global install detected; machine-scope setup is opt-in (ADR-0029)');
81
+ log('to wire ~/.construct/* and the front-door agent, run:');
82
+ log(' construct install --scope=user');
83
+ log('to set up a project, cd into it and run:');
84
+ log(' construct init');
90
85
  process.exit(0);
91
86
  }
92
87
 
@@ -114,6 +109,21 @@ try {
114
109
  pkgVersion: PKG_VERSION,
115
110
  log,
116
111
  });
112
+
113
+ // ADR-0027: Ensure .gitignore covers the newly staged adapters (construct-f6l6).
114
+ // Idempotent: missingIgnorePatterns returns only patterns not already present.
115
+ const giPath = path.join(initCwd, '.gitignore');
116
+ const existing = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
117
+ const missing = missingIgnorePatterns(existing);
118
+ if (missing.length > 0) {
119
+ const HEADER = '# Construct — generated adapters, launcher, and runtime state.';
120
+ const SUBHEADER = '# Machine-specific, recreated by `construct sync`; never source (ADR-0027).';
121
+ const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
122
+ const block = `${prefix}\n${HEADER}\n${SUBHEADER}\n${missing.join('\n')}\n`;
123
+ const { appendFileSync } = await import('node:fs');
124
+ appendFileSync(giPath, block, 'utf8');
125
+ log(`appended ${missing.length} Construct ignore pattern(s) to .gitignore`);
126
+ }
117
127
  } catch (err) {
118
128
  fail(`Adapter staging failed: ${err.message}`, 'The package is installed; run `npx construct init` in this project to complete setup.');
119
129
  // Intentionally exit 0: staging is best-effort completion, and a non-zero exit