@geraldmaron/construct 1.0.21 → 1.0.23

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 (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  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 +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
package/README.md CHANGED
@@ -22,12 +22,14 @@ Install the CLI (once per machine):
22
22
  npm install -g @geraldmaron/construct
23
23
  ```
24
24
 
25
- Bootstrap local services (once per machine):
25
+ Bootstrap local services (once per machine, opt-in to machine-scope writes):
26
26
 
27
27
  ```bash
28
- construct install --yes
28
+ construct install --scope=user --yes
29
29
  ```
30
30
 
31
+ `construct install` defaults to `--scope=project`, which writes nothing and prints scope guidance — see the [footprint contract](#footprint-contract) below or [ADR 0029](docs/adr/0029-install-scopes-and-hook-budgets.md). Use `--scope=user` for machine setup, `--scope=both` for both.
32
+
31
33
  Initialize a project:
32
34
 
33
35
  ```bash
@@ -37,7 +39,7 @@ construct init --yes
37
39
 
38
40
  `construct init` scaffolds the project (`.cx/`, `AGENTS.md`, `plan.md`, adapters) and starts the local services by default. Pass `--no-start` to skip service startup, or `--interactive` for the guided flow.
39
41
 
40
- Open your editor and talk to `@construct`. A walkthrough lives in `construct_guide.md` at your project root.
42
+ Open your editor and talk to `@construct`. A walkthrough lives in `.cx/construct_guide.md` (gitignored local reference only).
41
43
 
42
44
  No Node? Try `brew install geraldmaron/construct/construct`. Cloning a project that already uses Construct? `npx -y @geraldmaron/construct init` wires it up.
43
45
 
@@ -97,6 +99,18 @@ First run downloads `uv` and creates `.cx/runtime/docling/.venv` (~1.5 GB includ
97
99
 
98
100
  Every code mutation runs through enforcement. No secrets committed, tests green, docs current, comments lint-clean, CI passes. Gates live in three places: write-time, commit-time, CI safety net. They can only be bypassed with explicit env vars so every exception leaves an audit trail. [Gates and enforcement](https://geraldmaron.github.io/construct/concepts/gates-and-enforcement).
99
101
 
102
+ ## Footprint contract
103
+
104
+ Construct's writes are scoped and disclosed up front. The default `construct install` (no flag) writes nothing — it prints scope guidance. Project writes happen only under `construct init` inside a project directory; machine writes happen only under `construct install --scope=user`, with an itemized interactive consent prompt for any global Claude Code config mutation.
105
+
106
+ | Scope | Trigger | Paths |
107
+ |---|---|---|
108
+ | Project | `construct init` | `.construct/`, `.cx/`, `.claude/` adapter tree, host adapters (`.codex/`, `.opencode/`, `.cursor/`, `.vscode/`), `construct.config.json`, marker block in `CLAUDE.md` / `AGENTS.md`, `.gitignore` append, `.beads/` |
109
+ | Machine | `construct install --scope=user` | `~/.construct/config.env`, `~/.construct/lib` (symlink), `~/.construct/services/`, `~/Library/LaunchAgents/` (macOS), MCP entries in `~/.config/opencode/opencode.json` and `~/.codex/config.toml`, marker block in `~/.claude/CLAUDE.md`, hook injection in `~/.claude/settings.json` (last two require interactive consent or `--yes`) |
110
+ | Never touched | — | Shell rc files (`~/.bashrc`, `~/.zshrc`), npm global config, `git config --global` |
111
+
112
+ Full table with file:line citations and the per-hook performance budget contract: [Architecture — Footprint contract](https://geraldmaron.github.io/construct/concepts/architecture#footprint-contract) and [ADR 0029](docs/adr/0029-install-scopes-and-hook-budgets.md).
113
+
100
114
  ## Learning loops
101
115
 
102
116
  Construct gets smarter on its own. Every session ends with an automatic capture: tools used, files touched, what the final reply said. That goes into `.cx/observations/` and is searchable from the next session. See [`docs/concepts/learning-loops.mdx`](./docs/concepts/learning-loops.mdx) for what's wired, what's coming, and how to turn pieces off.
@@ -114,11 +128,12 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
114
128
 
115
129
  | Command | What it does |
116
130
  |---|---|
131
+ | `construct dashboard` | Start the local dashboard/orchestration daemon (or --token to mint a dashboard token) |
117
132
  | `construct dev` | Start services for development |
118
133
  | `construct docs` | Documentation commands |
119
134
  | `construct doctor` | Check installation health |
120
135
  | `construct init` | Project setup (once per repo): scaffold .cx/, AGENTS.md, plan.md, adapters |
121
- | `construct install` | Machine setup (once per machine): Docker, cm/cass, config, embeddings |
136
+ | `construct install` | Machine setup (scoped per ADR-0029): --scope=project\|user\|both, default project |
122
137
  | `construct intake` | View and process the active profile's intake queue (queue label varies by profile) |
123
138
  | `construct profile` | Manage the active org profile and its lifecycle (draft, promote, archive, health) |
124
139
  | `construct recommendations` | View and manage artifact recommendations |
@@ -136,6 +151,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
136
151
  | `construct customer` | Manage customer profiles for product intelligence |
137
152
  | `construct distill` | Distill documents with query-focused chunking |
138
153
  | `construct drop` | Ingest file from Downloads/Desktop |
154
+ | `construct export` | Export markdown to PDF/DOCX/HTML via Pandoc + Typst (optional system binaries; ADR-0024) |
139
155
  | `construct graph` | Task graph management |
140
156
  | `construct handoffs` | List and inspect session handoff files in .cx/handoffs/ |
141
157
  | `construct headhunt` | Create domain expertise overlays |
@@ -157,13 +173,14 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
157
173
 
158
174
  | Command | What it does |
159
175
  |---|---|
176
+ | `construct acp` | Run Construct as an Agent Client Protocol (ACP) server over stdio for Zed/JetBrains/VS Code ACP clients |
160
177
  | `construct capability` | Describe what this Construct install can do (embedded contract; read-only, secret-free) |
161
178
  | `construct claude:allow` | Manage Claude Code `permissions.allow` from the outside (auto-classifier blocks the agent from editing it) |
162
179
  | `construct execution` | Resolve the execution-capability contract for an embedded workflow (orchestrated vs prompt-only; descriptive, not enforced) |
163
180
  | `construct hosts` | Show host support for Construct orchestration |
164
181
  | `construct mcp` | Manage MCP integrations |
165
182
  | `construct models` | Show or update model tier assignments |
166
- | `construct orchestrate` | Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker) |
183
+ | `construct orchestrate` | Construct-owned local orchestration runtime, in-process or against the local daemon (--remote) |
167
184
  | `construct plugin` | Manage external Construct plugin manifests |
168
185
 
169
186
  ### Integrations
@@ -251,6 +268,7 @@ construct/
251
268
  ├── bin CLI entrypoint (`construct`)
252
269
  ├── commands Command prompt assets
253
270
  ├── config
271
+ ├── dashboard
254
272
  ├── db
255
273
  ├── deploy
256
274
  ├── docs Architecture notes, runbooks, and documentation contract
package/bin/construct CHANGED
@@ -65,6 +65,7 @@ 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
70
  import { createSqlClient, closeSqlClient, readVectorConfig } from '../lib/storage/backend.mjs';
70
71
  import { describeVectorStore } from '../lib/storage/vector-store.mjs';
@@ -80,12 +81,7 @@ for (const [key, value] of Object.entries(ENV)) {
80
81
  if (!(key in process.env)) process.env[key] = value;
81
82
  }
82
83
 
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: '' };
84
+ const COLORS = resolveColors();
89
85
 
90
86
  function println(message = '') { process.stdout.write(`${message}\n`); }
91
87
  function errorln(message = '') { process.stderr.write(`${message}\n`); }
@@ -250,6 +246,45 @@ async function cmdSync(args) {
250
246
  opLog.close('failed', { stage: 'sync-specialists', status: syncStatus });
251
247
  process.exit(syncStatus);
252
248
  }
249
+
250
+ // State reconciliation (ADR-0027): bring on-disk state in line with the
251
+ // current disposition contract. Auto-safety tasks run silently; ask-safety
252
+ // tasks surface via construct doctor instead. --reconcile=<id> runs one task
253
+ // on explicit consent (the path for ask-safety tasks). --no-reconcile
254
+ // bypasses the auto pass entirely for diagnostic runs.
255
+
256
+ const reconcileArg = args.find((a) => a.startsWith('--reconcile='));
257
+ if (reconcileArg) {
258
+ const id = reconcileArg.slice('--reconcile='.length);
259
+ try {
260
+ const { runReconciliation } = await import('../lib/reconcile/index.mjs');
261
+ const result = await runReconciliation(id, {});
262
+ if (!result.ok) {
263
+ errorln(`Reconciliation failed (${id}): ${result.reason}`);
264
+ if (result.available) errorln(`Available: ${result.available.join(', ')}`);
265
+ opLog.close('failed', { stage: 'reconcile', id, reason: result.reason });
266
+ process.exit(1);
267
+ }
268
+ if (result.ran) ok(`Reconciled (${id}): ${result.summary}`);
269
+ else println(`Reconciliation (${id}): ${result.summary}`);
270
+ opLog.event('reconcile-explicit', { id, ran: !!result.ran });
271
+ } catch (err) {
272
+ errorln(`Reconciliation error (${id}): ${err.message}`);
273
+ opLog.close('failed', { stage: 'reconcile', id, reason: err.message });
274
+ process.exit(1);
275
+ }
276
+ } else if (!args.includes('--no-reconcile')) {
277
+ try {
278
+ const verbose = args.includes('--verbose') || args.includes('-v');
279
+ const { runAutoReconciliations } = await import('../lib/reconcile/index.mjs');
280
+ const { applied } = await runAutoReconciliations({ verbose });
281
+ for (const entry of applied) ok(`Reconciled (${entry.id}): ${entry.summary}`);
282
+ opLog.event('reconcile', { applied: applied.length });
283
+ } catch (err) {
284
+ opLog.event('reconcile-error', { message: err.message });
285
+ }
286
+ }
287
+
253
288
  if (args.includes('--no-docs')) {
254
289
  opLog.close('ok', { docs: 'skipped' });
255
290
  return;
@@ -584,23 +619,24 @@ async function cmdDoctor() {
584
619
  // Tier model selection. Construct ships with no default — at least
585
620
  // one tier must be configured (registry.json primary OR CX_MODEL_*
586
621
  // 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';
622
+ // A missing, partial, or unresolvable tier is advisory, not a repo defect:
623
+ // model tiers are user/machine-specific config, so doctor warns and never
624
+ // fails the gate on them.
589
625
  try {
590
626
  const { resolveModelTiers } = await import('../lib/model-registry.mjs');
591
627
  const resolved = resolveModelTiers({ env: process.env });
592
628
  if (resolved.complete && !resolved.errors) {
593
629
  add('Models — all tiers configured', true);
594
630
  } else if (resolved.configured === 0) {
595
- add('Models — no tier configured (pick one in the dashboard or run `construct models --apply`)', inCI, inCI);
631
+ add('Models — no tier configured (pick one in the dashboard or run `construct models --apply`)', false, true);
596
632
  } else {
597
633
  const missing = Object.entries(resolved.models)
598
634
  .filter(([, v]) => !v)
599
635
  .map(([k]) => k);
600
- add(`Models — only ${resolved.configured}/3 tiers configured (missing: ${missing.join(', ')})`, inCI, inCI);
636
+ add(`Models — only ${resolved.configured}/3 tiers configured (missing: ${missing.join(', ')})`, false, true);
601
637
  }
602
638
  } catch (err) {
603
- add(`Models — check failed: ${err.message}`, inCI, inCI);
639
+ add(`Models — check failed: ${err.message}`, false, true);
604
640
  }
605
641
 
606
642
  add('Node.js 20+ (recommended)', Number.parseInt(process.versions.node.split('.')[0], 10) >= 20);
@@ -1069,6 +1105,26 @@ async function cmdDoctor() {
1069
1105
  add('Registry integrity check', false);
1070
1106
  }
1071
1107
 
1108
+ // Reconciliation drift (ADR-0027). Any task reporting needsRepair is surfaced
1109
+ // with its summary and the exact `construct sync --reconcile=<id>` command.
1110
+ // Auto tasks that still show drift (e.g. a run with --no-reconcile) and all
1111
+ // ask tasks both land here, since ask tasks never run from the auto pass.
1112
+ let reconcileDrift = [];
1113
+ try {
1114
+ const { listReconciliations } = await import('../lib/reconcile/index.mjs');
1115
+ const tasks = await listReconciliations();
1116
+ reconcileDrift = tasks.filter((t) => t.needsRepair);
1117
+ add(
1118
+ reconcileDrift.length === 0
1119
+ ? 'Reconciliation drift: none'
1120
+ : `Reconciliation drift: ${reconcileDrift.length} task${reconcileDrift.length === 1 ? '' : 's'} need repair (see below)`,
1121
+ reconcileDrift.length === 0,
1122
+ true,
1123
+ );
1124
+ } catch (err) {
1125
+ add(`Reconciliation drift check failed: ${err.message}`, false, true);
1126
+ }
1127
+
1072
1128
  println('Construct Health Check');
1073
1129
  println('══════════════════════');
1074
1130
  println('');
@@ -1080,6 +1136,17 @@ async function cmdDoctor() {
1080
1136
  else if (check.optional) warnCount += 1;
1081
1137
  else failCount += 1;
1082
1138
  }
1139
+
1140
+ if (reconcileDrift.length > 0) {
1141
+ println('');
1142
+ println('Reconciliation drift:');
1143
+ for (const task of reconcileDrift) {
1144
+ println(` ⚠ ${task.id} (${task.safety})`);
1145
+ if (task.summary) println(` ${task.summary}`);
1146
+ println(` fix: construct sync --reconcile=${task.id}`);
1147
+ }
1148
+ }
1149
+
1083
1150
  println('');
1084
1151
  println(`Results: ${okCount} passed, ${warnCount} warnings, ${failCount} failed`);
1085
1152
  if (failCount > 0) process.exit(1);
@@ -1178,6 +1245,16 @@ async function cmdUp(args = []) {
1178
1245
  else warn(`${svc.name}: ${svc.note}`);
1179
1246
  }
1180
1247
 
1248
+ // Show Langfuse credentials guidance after first start
1249
+ const langfuseService = results.find(r => r.name === 'Langfuse');
1250
+ if (langfuseService && langfuseService.status === 'started' && langfuseService.note?.includes('auto-configured')) {
1251
+ println('');
1252
+ info('Langfuse credentials:');
1253
+ info(' Dashboard: http://localhost:54330 (login: admin@construct.local / construct-admin)');
1254
+ info(' API keys auto-saved to ~/.construct/config.env');
1255
+ info(' Run `construct status` anytime to view credentials');
1256
+ }
1257
+
1181
1258
  if (recovery) {
1182
1259
  println('');
1183
1260
  info(`Recovery: ${recovery.message}`);
@@ -1410,6 +1487,44 @@ async function cmdIngest(args) {
1410
1487
  }
1411
1488
  }
1412
1489
 
1490
+ async function cmdExport(args) {
1491
+ // construct export <markdown-file> --to=<pdf|docx|html> [--output=<path>]
1492
+ // Optional capability per ADR-0024: requires pandoc on PATH (and typst for PDF).
1493
+ // Absent tooling returns a structured "install X" error, not a crash.
1494
+
1495
+ const positional = args.filter((a) => !a.startsWith('--'));
1496
+ const inputPath = positional[0];
1497
+ const toFlag = args.find((a) => a.startsWith('--to='))?.split('=')[1];
1498
+ const outFlag = args.find((a) => a.startsWith('--output='))?.split('=')[1];
1499
+ const detectOnly = args.includes('--detect');
1500
+
1501
+ const { detect, exportMarkdown, EXPORT_FORMATS } = await import('../lib/document-export.mjs');
1502
+
1503
+ if (detectOnly) {
1504
+ const format = toFlag || 'pdf';
1505
+ println(JSON.stringify(detect(format), null, 2));
1506
+ return;
1507
+ }
1508
+
1509
+ if (!inputPath) {
1510
+ errorln('Usage: construct export <markdown-file> --to=<pdf|docx|html> [--output=<path>] [--detect]');
1511
+ process.exit(1);
1512
+ }
1513
+ if (!toFlag) {
1514
+ errorln(`--to is required. Supported formats: ${EXPORT_FORMATS.join(', ')}`);
1515
+ process.exit(1);
1516
+ }
1517
+
1518
+ const resolvedIn = path.resolve(process.cwd(), inputPath);
1519
+ const resolvedOut = outFlag ? path.resolve(process.cwd(), outFlag) : null;
1520
+ const result = exportMarkdown({ inputPath: resolvedIn, outputPath: resolvedOut, format: toFlag });
1521
+ if (!result.ok) {
1522
+ errorln(result.message);
1523
+ process.exit(result.missing?.length ? 2 : 1);
1524
+ }
1525
+ ok(result.message);
1526
+ }
1527
+
1413
1528
  async function cmdInfer(args) {
1414
1529
  try {
1415
1530
  const result = await runInferCli(args, { cwd: process.cwd() });
@@ -1976,7 +2091,17 @@ async function cmdEmbeddedTriage(args) {
1976
2091
  input = fs.readFileSync(0, 'utf8');
1977
2092
  }
1978
2093
  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]');
2094
+ // This is an embedded-contract verb: an embedder that pipes empty/no input
2095
+ // must still get a parseable, versioned envelope — not a bare exit. Emit a
2096
+ // typed error envelope on stdout so the contract holds, and exit non-zero so
2097
+ // shell callers still see failure.
2098
+ const { wrapResponse } = await import('../lib/embedded-contract/envelope.mjs');
2099
+ const envelope = wrapResponse({
2100
+ surface: 'cli',
2101
+ warnings: ['No artifact provided — pass --text "...", --file <path>, or pipe content on stdin.'],
2102
+ data: { error: { code: 'missing_input', message: 'intake classify requires an artifact to classify; received empty input.' } },
2103
+ });
2104
+ println(JSON.stringify(envelope, null, 2));
1980
2105
  process.exit(1);
1981
2106
  }
1982
2107
  const request = {
@@ -3364,6 +3489,47 @@ async function cmdOrchestrate(args) {
3364
3489
  const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
3365
3490
  const wantsJson = rest.includes('--json') || args.includes('--json');
3366
3491
 
3492
+ // Thin-client mode: drive the local orchestration daemon over HTTP instead of
3493
+ // running in-process, proving the engine-as-service contract editors/CI use.
3494
+ if (rest.includes('--remote')) {
3495
+ const remoteVal = flag('--remote');
3496
+ const base = (remoteVal && /^https?:\/\//.test(remoteVal)) ? remoteVal.replace(/\/$/, '') : `http://${process.env.BIND_HOST || '127.0.0.1'}:${process.env.PORT || '4242'}`;
3497
+ const { getDashboardToken } = await import('../lib/server/auth.mjs');
3498
+ const token = getDashboardToken();
3499
+ const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
3500
+ const positional = rest.filter((a) => !a.startsWith('--'));
3501
+ try {
3502
+ if (sub === 'run') {
3503
+ const text = flag('--text') || positional.join(' ').trim();
3504
+ if (!text) { errorln('Usage: construct orchestrate run "<request>" --remote [--strategy S] [--worker-backend provider] [--json]'); process.exit(1); }
3505
+ 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) }) });
3506
+ const env = await res.json();
3507
+ if (!res.ok) { errorln(`Daemon error (${res.status}): ${env.error || 'unknown'}`); process.exit(1); }
3508
+ if (wantsJson) { println(JSON.stringify(env.data, null, 2)); return; }
3509
+ println(`Run ${env.data.runId} started on ${base} — ${env.data.status} · executionMode=${env.data.execution?.executionMode}. Poll: construct orchestrate status ${env.data.runId} --remote`);
3510
+ return;
3511
+ }
3512
+ if (sub === 'status') {
3513
+ const runId = positional[0];
3514
+ const path = runId ? `/api/orchestration/runs/${encodeURIComponent(runId)}` : '/api/orchestration/runs';
3515
+ const res = await fetch(`${base}${path}`, { headers });
3516
+ const env = await res.json();
3517
+ if (!res.ok) { errorln(`Daemon error (${res.status}): ${env.error || 'unknown'}`); process.exit(1); }
3518
+ if (wantsJson) { println(JSON.stringify(env.data, null, 2)); return; }
3519
+ if (runId) {
3520
+ println(`Run ${env.data.runId} — ${env.data.status} · executionMode=${env.data.execution?.executionMode}`);
3521
+ for (const t of env.data.tasks || []) println(` ${t.id} ${t.role} — ${t.status}${t.output ? ` (${t.output.length} chars)` : ''}`);
3522
+ } else {
3523
+ for (const r of env.data.runs || []) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
3524
+ }
3525
+ return;
3526
+ }
3527
+ } catch (err) {
3528
+ errorln(`Could not reach the orchestration daemon at ${base}: ${err.message}. Start it with \`construct dashboard\`.`);
3529
+ process.exit(1);
3530
+ }
3531
+ }
3532
+
3367
3533
  if (sub === 'run') {
3368
3534
  const { runOrchestration, planRun, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3369
3535
  const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--') && !['--no-construct', '--no-execute', '--json'].includes(rest[i - 1])));
@@ -3386,6 +3552,9 @@ async function cmdOrchestrate(args) {
3386
3552
  if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
3387
3553
  if (run.tasks.length) println(` tasks: ${run.tasks.map((t) => `${t.role}(${t.status})`).join(' → ')}`);
3388
3554
  else println(' tasks: none (no Construct-owned specialist sequence for this execution mode)');
3555
+ for (const t of run.tasks) {
3556
+ if (t.reasoning) println(` ↳ ${t.role} reasoning: ${t.reasoning.length > 300 ? `${t.reasoning.slice(0, 300)}…` : t.reasoning}`);
3557
+ }
3389
3558
  return;
3390
3559
  }
3391
3560
 
@@ -3397,7 +3566,10 @@ async function cmdOrchestrate(args) {
3397
3566
  if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
3398
3567
  if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3399
3568
  println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
3400
- for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
3569
+ for (const t of run.tasks) {
3570
+ println(` ${t.id} ${t.role} — ${t.status}`);
3571
+ if (t.reasoning) println(` reasoning: ${t.reasoning.length > 300 ? `${t.reasoning.slice(0, 300)}…` : t.reasoning}`);
3572
+ }
3401
3573
  return;
3402
3574
  }
3403
3575
  const runs = await getRuns(process.cwd());
@@ -5059,6 +5231,7 @@ async function readStdin() {
5059
5231
  const handlers = new Map([
5060
5232
  // Core
5061
5233
  ['dev', cmdUp],
5234
+ ['dashboard', cmdServe],
5062
5235
  ['stop', cmdDown],
5063
5236
  ['status', cmdStatus],
5064
5237
  ['install', cmdSetup],
@@ -5080,6 +5253,7 @@ const handlers = new Map([
5080
5253
  ['diff', cmdDiff],
5081
5254
  ['distill', cmdDistill],
5082
5255
  ['ingest', cmdIngest],
5256
+ ['export', cmdExport],
5083
5257
  ['infer', cmdInfer],
5084
5258
  ['search', cmdSearch],
5085
5259
  ['storage', cmdStorage],
@@ -5119,6 +5293,10 @@ const handlers = new Map([
5119
5293
  ['models', cmdModels],
5120
5294
  ['execution', cmdExecution],
5121
5295
  ['orchestrate', cmdOrchestrate],
5296
+ ['acp', async () => {
5297
+ const { runAcpServer } = await import('../lib/acp/server.mjs');
5298
+ runAcpServer({ input: process.stdin, output: process.stdout, env: process.env, defaultCwd: process.cwd() });
5299
+ }],
5122
5300
  ['beads:stats', async (args) => {
5123
5301
  const { getContentionStats, getHumanStatus } = await import('../lib/beads-optimistic.mjs');
5124
5302
  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
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/acp/server.mjs — Construct as an Agent Client Protocol (ACP) server.
3
+ *
4
+ * ACP (agentclientprotocol.com) is the editor↔agent standard that lets Zed,
5
+ * JetBrains, and the VS Code ACP client drive any agent over JSON-RPC 2.0 on
6
+ * stdio. This makes Construct a first-class native agent in those editors: an
7
+ * ACP `session/prompt` runs a real multi-specialist orchestration via the same
8
+ * engine the daemon and the MCP tool use (ADR-0023) — the editor is a thin
9
+ * client, Construct owns the loop.
10
+ *
11
+ * Framing is newline-delimited JSON (one JSON-RPC message per line). Supported
12
+ * methods: `initialize`, `session/new`, `session/prompt`, `session/cancel`;
13
+ * progress streams as `session/update` notifications. The prompt bridges to
14
+ * planRun/executeRun and forwards run-lifecycle events as agent message chunks.
15
+ */
16
+
17
+ import { createInterface } from 'node:readline';
18
+
19
+ const PROTOCOL_VERSION = 1;
20
+
21
+ function describeEvent(event) {
22
+ if (event.type === 'planned') return `Planned ${event.tasks ?? ''} specialist task(s).`;
23
+ if (event.type === 'running') return 'Running orchestration…';
24
+ if (event.type === 'task') return `· ${event.role ?? 'task'} — ${event.status ?? ''}`;
25
+ if (event.type === 'completed') return `Completed: ${event.status ?? 'done'}.`;
26
+ if (event.type === 'error') return `Error: ${event.error?.message ?? 'run failed'}.`;
27
+ return null;
28
+ }
29
+
30
+ function summarize(run) {
31
+ const tasks = (run.tasks || []).map((t) => `- ${t.role} (${t.status})`).join('\n');
32
+ return `Orchestration ${run.status} — executionMode=${run.execution?.executionMode}.\n${tasks}`;
33
+ }
34
+
35
+ function textFromPrompt(prompt) {
36
+ if (!Array.isArray(prompt)) return '';
37
+ return prompt.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
38
+ }
39
+
40
+ export function runAcpServer({ input, output, env = process.env, defaultCwd = process.cwd() } = {}) {
41
+ const sessions = new Map();
42
+ let sessionCounter = 0;
43
+
44
+ const write = (msg) => output.write(`${JSON.stringify(msg)}\n`);
45
+ const respond = (id, result) => write({ jsonrpc: '2.0', id, result });
46
+ const fail = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } });
47
+ const notify = (method, params) => write({ jsonrpc: '2.0', method, params });
48
+ const update = (sessionId, text) => notify('session/update', { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } });
49
+
50
+ async function handlePrompt(id, params) {
51
+ const sessionId = params?.sessionId;
52
+ const session = sessions.get(sessionId);
53
+ if (!session) return fail(id, -32602, `Unknown sessionId: ${sessionId}`);
54
+ const text = textFromPrompt(params?.prompt);
55
+ if (!text) return fail(id, -32602, 'prompt has no text content');
56
+
57
+ const { planRun, executeRun } = await import('../orchestration/runtime.mjs');
58
+ const { onRunEvent, isCancelRequested } = await import('../orchestration/events.mjs');
59
+ try {
60
+ const planned = await planRun({ request: text, requestedStrategy: 'orchestrated' }, { env, cwd: session.cwd });
61
+ session.runId = planned.runId;
62
+ const off = onRunEvent(planned.runId, (event) => {
63
+ const line = describeEvent(event);
64
+ if (line) update(sessionId, line);
65
+ });
66
+ const run = await executeRun(session.cwd, planned.runId, { env, workerBackend: 'inline' });
67
+ off();
68
+ update(sessionId, summarize(run));
69
+ respond(id, { stopReason: isCancelRequested(planned.runId) ? 'cancelled' : 'end_turn' });
70
+ } catch (err) {
71
+ update(sessionId, `Error: ${err.message}`);
72
+ respond(id, { stopReason: 'refusal' });
73
+ }
74
+ }
75
+
76
+ async function dispatch(msg) {
77
+ const { id, method, params } = msg;
78
+ if (method === 'initialize') {
79
+ respond(id, {
80
+ protocolVersion: typeof params?.protocolVersion === 'number' ? params.protocolVersion : PROTOCOL_VERSION,
81
+ agentCapabilities: { promptCapabilities: { image: false, audio: false, embeddedContext: true } },
82
+ authMethods: [],
83
+ });
84
+ return;
85
+ }
86
+ if (method === 'session/new') {
87
+ const sessionId = `acp-${++sessionCounter}`;
88
+ sessions.set(sessionId, { cwd: params?.cwd || defaultCwd, runId: null });
89
+ respond(id, { sessionId });
90
+ return;
91
+ }
92
+ if (method === 'session/prompt') { await handlePrompt(id, params); return; }
93
+ if (method === 'session/cancel') {
94
+ const session = sessions.get(params?.sessionId);
95
+ if (session?.runId) { const { requestCancel } = await import('../orchestration/events.mjs'); requestCancel(session.runId); }
96
+ return;
97
+ }
98
+ if (id !== undefined) fail(id, -32601, `Method not found: ${method}`);
99
+ }
100
+
101
+ const rl = createInterface({ input, crlfDelay: Infinity });
102
+ rl.on('line', (line) => {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) return;
105
+ let msg;
106
+ try { msg = JSON.parse(trimmed); } catch { return; }
107
+ Promise.resolve(dispatch(msg)).catch((err) => {
108
+ if (msg?.id !== undefined) fail(msg.id, -32603, err.message);
109
+ });
110
+ });
111
+
112
+ return { close: () => rl.close() };
113
+ }