@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
@@ -0,0 +1,142 @@
1
+ /**
2
+ * lib/reconcile/mcp-entry-reconcile.mjs — strip Codex `[mcp_servers.<id>]`
3
+ * tables whose `bearer_token_env_var` references an unset env var from configs
4
+ * written before the sync-time fix (ADR-0027 / construct-n6h7).
5
+ *
6
+ * Codex aborts at startup when an MCP server's bearer_token_env_var names an
7
+ * env var that is unset. The sync writer now omits such entries, but a config
8
+ * written by an earlier version still carries them. This repair removes exactly
9
+ * those construct-managed tables from the global `~/.codex/config.toml` and a
10
+ * project `.codex/config.toml` when present, preserving every other table.
11
+ *
12
+ * Detection mirrors the sync-time gate (codexMcpEnvResolves): an entry whose
13
+ * token env var resolves is kept; only unresolved-credential tables are pruned.
14
+ * Tables with no credential requirement are never touched. Removal is scoped to
15
+ * registry-managed MCP ids by exact table name, so user-authored mcp_servers
16
+ * tables are out of scope. Safety: `auto`. detect() reads only; apply() is
17
+ * idempotent because removeTomlTables drains the matched tables to none.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import { homeDir } from '../paths.mjs';
25
+ import {
26
+ getCodexConfigPath,
27
+ buildCodexMcpEntry,
28
+ removeTomlTables,
29
+ tomlString,
30
+ } from '../codex-config.mjs';
31
+
32
+ function escapeRegExp(value) {
33
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ }
35
+
36
+ function registryMcpServers() {
37
+ const installed = path.join(homeDir(), '.construct', 'specialists', 'registry.json');
38
+ const bundled = fileURLToPath(new URL('../../specialists/registry.json', import.meta.url));
39
+ for (const src of [installed, bundled]) {
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(src, 'utf8'));
42
+ if (parsed && parsed.mcpServers) return parsed.mcpServers;
43
+ } catch {
44
+ continue;
45
+ }
46
+ }
47
+ return {};
48
+ }
49
+
50
+ function hasMcpTable(text, id) {
51
+ return new RegExp(`^\\[mcp_servers\\.(?:${escapeRegExp(id)}|${escapeRegExp(tomlString(id))})\\]`, 'm').test(text);
52
+ }
53
+
54
+ // An entry with no bearer_token_env_var has no credential requirement and is
55
+ // always valid. An entry whose token env var is set resolves; an unset value
56
+ // (undefined or empty string) is the abort condition Codex hits at startup.
57
+
58
+ function tokenResolves(id, def, env) {
59
+ const entry = buildCodexMcpEntry(id, def, env);
60
+ const tokenVar = entry?.bearer_token_env_var;
61
+ if (!tokenVar) return true;
62
+ const val = env[tokenVar];
63
+ return val !== undefined && val !== '';
64
+ }
65
+
66
+ function configTargets() {
67
+ const targets = [{ scope: 'global', file: getCodexConfigPath(homeDir()) }];
68
+ const projectConfig = path.join(process.cwd(), '.codex', 'config.toml');
69
+ if (fs.existsSync(projectConfig)) targets.push({ scope: 'project', file: projectConfig });
70
+ return targets;
71
+ }
72
+
73
+ function unresolvedTables(text, registryMcp, env) {
74
+ const ids = [];
75
+ for (const id of Object.keys(registryMcp)) {
76
+ if (!hasMcpTable(text, id)) continue;
77
+ if (tokenResolves(id, registryMcp[id], env)) continue;
78
+ ids.push(id);
79
+ }
80
+ return ids;
81
+ }
82
+
83
+ async function detect() {
84
+ const registryMcp = registryMcpServers();
85
+ if (Object.keys(registryMcp).length === 0) {
86
+ return { needsRepair: false, summary: 'No registry MCP servers to reconcile.' };
87
+ }
88
+ const env = process.env;
89
+ const offenders = [];
90
+ for (const target of configTargets()) {
91
+ let text = '';
92
+ try {
93
+ if (!fs.existsSync(target.file)) continue;
94
+ text = fs.readFileSync(target.file, 'utf8');
95
+ } catch {
96
+ continue;
97
+ }
98
+ const ids = unresolvedTables(text, registryMcp, env);
99
+ if (ids.length > 0) offenders.push({ ...target, ids });
100
+ }
101
+ if (offenders.length === 0) {
102
+ return { needsRepair: false, summary: 'No Codex MCP tables reference an unset token env var.' };
103
+ }
104
+ const total = offenders.reduce((acc, o) => acc + o.ids.length, 0);
105
+ return {
106
+ needsRepair: true,
107
+ summary: `${total} Codex MCP table${total === 1 ? '' : 's'} reference an unset token env var (${offenders.map((o) => `${o.scope}: ${o.ids.join(', ')}`).join('; ')}).`,
108
+ details: { offenders: offenders.map((o) => ({ scope: o.scope, ids: o.ids })) },
109
+ };
110
+ }
111
+
112
+ async function apply() {
113
+ const registryMcp = registryMcpServers();
114
+ const env = process.env;
115
+ let removed = 0;
116
+ const scopes = [];
117
+ for (const target of configTargets()) {
118
+ if (!fs.existsSync(target.file)) continue;
119
+ const text = fs.readFileSync(target.file, 'utf8');
120
+ const ids = unresolvedTables(text, registryMcp, env);
121
+ if (ids.length === 0) continue;
122
+
123
+ // Strip both the bare and quoted table forms per id, matching the names
124
+ // the sync writer emits, so a table written either way is removed.
125
+
126
+ const tableNames = ids.flatMap((id) => [`mcp_servers.${id}`, `mcp_servers.${tomlString(id)}`]);
127
+ const cleaned = removeTomlTables(text, tableNames);
128
+ fs.writeFileSync(target.file, `${cleaned.trimEnd()}\n`, 'utf8');
129
+ removed += ids.length;
130
+ scopes.push(`${target.scope}: ${ids.join(', ')}`);
131
+ }
132
+ if (removed === 0) return { summary: 'Already clean.' };
133
+ return { summary: `Removed ${removed} unresolved Codex MCP table${removed === 1 ? '' : 's'} (${scopes.join('; ')}).` };
134
+ }
135
+
136
+ export default {
137
+ id: 'mcp-entry-reconcile',
138
+ description: 'Strip Codex MCP tables whose bearer_token_env_var references an unset env var from existing configs.',
139
+ safety: 'auto',
140
+ detect,
141
+ apply,
142
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * lib/reconcile/postgres-namespace.mjs — migrate a local Postgres compose that
3
+ * predates per-home identifier derivation (ADR-0027 / construct-lb7b).
4
+ *
5
+ * Earlier versions wrote a singular `construct-postgres` container on the fixed
6
+ * 54329 port. Two isolated HOMEs sharing that container clobber each other's
7
+ * data and fight over the port. The derivation namespaces both per home; the
8
+ * repair rewrites a legacy compose file to the namespaced form.
9
+ *
10
+ * apply() is strictly data-safe: it only rewrites the compose via
11
+ * writeLocalPostgresCompose(home). It runs no docker command, removes no
12
+ * container, and deletes no volume — the existing volume and any data inside it
13
+ * are left intact. The returned summary instructs the operator to run
14
+ * `construct down && construct up` to recreate the container against the
15
+ * namespaced compose.
16
+ *
17
+ * Safety: `ask`. The rewrite changes the container/port a running stack binds
18
+ * to, so it runs only on explicit consent. detect() reads only; apply() is
19
+ * idempotent because the namespaced compose fails to match the legacy markers.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+
24
+ import { homeDir } from '../paths.mjs';
25
+ import {
26
+ LEGACY_PG_CONTAINER,
27
+ LEGACY_PG_PORT,
28
+ postgresContainerName,
29
+ postgresPort,
30
+ } from '../home-namespace.mjs';
31
+ import { localPostgresComposePath, writeLocalPostgresCompose } from '../setup.mjs';
32
+
33
+ // The container marker anchors to end-of-line so the namespaced form
34
+ // `construct-postgres-<suffix>` does not match the singular legacy name as a
35
+ // prefix (which would defeat idempotency).
36
+
37
+ const LEGACY_CONTAINER_RE = new RegExp(`container_name:\\s*${LEGACY_PG_CONTAINER}\\s*$`, 'm');
38
+ const LEGACY_PORT_RE = new RegExp(`:${LEGACY_PG_PORT}:`);
39
+
40
+ function legacyMarkers(content, home) {
41
+ const derivedContainer = postgresContainerName(process.env, home);
42
+ const derivedPort = postgresPort(process.env, home);
43
+
44
+ // The legacy singular container only counts when the derivation would name it
45
+ // differently; an explicit CONSTRUCT_PG_CONTAINER override pinned to the
46
+ // singular name is the intended state, not drift.
47
+
48
+ const hasLegacyContainer = LEGACY_CONTAINER_RE.test(content) && derivedContainer !== LEGACY_PG_CONTAINER;
49
+ const hasLegacyPort = LEGACY_PORT_RE.test(content) && derivedPort !== LEGACY_PG_PORT;
50
+ return { hasLegacyContainer, hasLegacyPort, derivedContainer, derivedPort };
51
+ }
52
+
53
+ async function detect() {
54
+ const home = homeDir();
55
+ const composePath = localPostgresComposePath(home);
56
+ if (!fs.existsSync(composePath)) {
57
+ return { needsRepair: false, summary: 'No local Postgres compose file present.' };
58
+ }
59
+ let content = '';
60
+ try {
61
+ content = fs.readFileSync(composePath, 'utf8');
62
+ } catch (err) {
63
+ return { needsRepair: false, summary: `Could not read compose file: ${err.message}` };
64
+ }
65
+ const markers = legacyMarkers(content, home);
66
+ if (!markers.hasLegacyContainer && !markers.hasLegacyPort) {
67
+ return { needsRepair: false, summary: 'Local Postgres compose already uses per-home identifiers.' };
68
+ }
69
+ const reasons = [];
70
+ if (markers.hasLegacyContainer) reasons.push(`container ${LEGACY_PG_CONTAINER} → ${markers.derivedContainer}`);
71
+ if (markers.hasLegacyPort) reasons.push(`port ${LEGACY_PG_PORT} → ${markers.derivedPort}`);
72
+ return {
73
+ needsRepair: true,
74
+ summary: `Local Postgres compose predates per-home derivation (${reasons.join(', ')}).`,
75
+ details: { composePath, ...markers },
76
+ };
77
+ }
78
+
79
+ async function apply() {
80
+ const home = homeDir();
81
+ const composePath = localPostgresComposePath(home);
82
+ if (!fs.existsSync(composePath)) return { summary: 'No compose file to migrate.' };
83
+
84
+ // Rewrite the compose to the per-home form only. No docker invocation, no
85
+ // container removal, no volume deletion — the operator recreates the
86
+ // container themselves so data handling stays under their control.
87
+
88
+ writeLocalPostgresCompose(home);
89
+ const container = postgresContainerName(process.env, home);
90
+ const port = postgresPort(process.env, home);
91
+ return {
92
+ summary: `Rewrote local Postgres compose to per-home identifiers (container ${container}, port ${port}). Run \`construct down && construct up\` to recreate the container; existing data and volume are untouched.`,
93
+ };
94
+ }
95
+
96
+ export default {
97
+ id: 'postgres-namespace',
98
+ description: 'Rewrite a legacy local Postgres compose to per-home container/port identifiers (data-safe).',
99
+ safety: 'ask',
100
+ detect,
101
+ apply,
102
+ };
@@ -37,12 +37,32 @@ function which(bin) {
37
37
  return result.stdout.trim().split('\n')[0] || null;
38
38
  }
39
39
 
40
+ // First-run docling provisioning blocks for minutes (uv install, venv, then a
41
+ // large `uv pip install docling` that pulls ML deps). Without a heartbeat it
42
+ // reads as a hang. Progress goes to stderr only, so JSON consumers on stdout are
43
+ // unaffected; it is the long-operation's own channel, not a suppressible notice.
44
+
45
+ function progress(message) {
46
+ try { process.stderr.write(`[docling setup] ${message}\n`); } catch { /* stderr closed */ }
47
+ }
48
+
49
+ // spawnSync sets status=null and error.code=ETIMEDOUT on timeout; distinguish a
50
+ // timeout from a real non-zero exit so the message tells the user what to do.
51
+
52
+ function describeStepFailure(label, result, timeoutMs) {
53
+ if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
54
+ return `${label} timed out after ${Math.round(timeoutMs / 1000)}s — docling's one-time download did not complete. Check network/proxy and re-run; or set ingest.fallback to "adapter" to skip docling.`;
55
+ }
56
+ return `${label} failed (${result.status}): ${result.stderr || result.stdout || result.error?.message || 'unknown error'}`;
57
+ }
58
+
40
59
  function ensureUv(installDir) {
41
60
  const fromPath = which('uv');
42
61
  if (fromPath) return fromPath;
43
62
  const cachedUv = path.join(installDir, 'bin', 'uv');
44
63
  if (existsSync(cachedUv)) return cachedUv;
45
64
  mkdirSync(installDir, { recursive: true });
65
+ progress('Installing uv (Python toolchain) — first run only…');
46
66
  const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
47
67
  const sh = spawnSync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], {
48
68
  env,
@@ -50,7 +70,7 @@ function ensureUv(installDir) {
50
70
  encoding: 'utf8',
51
71
  });
52
72
  if (sh.status !== 0) {
53
- throw new Error(`uv install failed (${sh.status}): ${sh.stderr || sh.stdout}`);
73
+ throw new Error(describeStepFailure('uv install', sh, UV_TIMEOUT_MS));
54
74
  }
55
75
  if (!existsSync(cachedUv)) {
56
76
  throw new Error(`uv install reported success but binary not found at ${cachedUv}`);
@@ -86,23 +106,27 @@ export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = fa
86
106
  return { pythonBin: existingPython, venvDir, runtimeDir, fresh: false };
87
107
  }
88
108
 
109
+ progress('Provisioning the docling document extractor (first run). This downloads a Python runtime and ML dependencies and can take several minutes; later runs are instant.');
89
110
  const uv = ensureUv(runtimeDir);
90
111
 
112
+ progress('Creating Python 3.11 virtualenv…');
91
113
  const venvResult = spawnSync(uv, ['venv', venvDir, '--python', '3.11'], {
92
114
  timeout: VENV_TIMEOUT_MS,
93
115
  encoding: 'utf8',
94
116
  });
95
117
  if (venvResult.status !== 0) {
96
- throw new Error(`uv venv failed (${venvResult.status}): ${venvResult.stderr}`);
118
+ throw new Error(describeStepFailure('uv venv', venvResult, VENV_TIMEOUT_MS));
97
119
  }
98
120
 
121
+ progress(`Installing docling ${DOCLING_PIN} and its dependencies — the slow step (large download)…`);
99
122
  const installResult = spawnSync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], {
100
123
  timeout: INSTALL_TIMEOUT_MS,
101
124
  encoding: 'utf8',
102
125
  });
103
126
  if (installResult.status !== 0) {
104
- throw new Error(`docling install failed (${installResult.status}): ${installResult.stderr}`);
127
+ throw new Error(describeStepFailure('docling install', installResult, INSTALL_TIMEOUT_MS));
105
128
  }
129
+ progress('docling extractor ready.');
106
130
 
107
131
  const pythonBin = pythonBinFor(venvDir);
108
132
  writeMarker(markerPath, {
@@ -210,9 +210,23 @@ async function callModel(userContent, { traceId } = {}) {
210
210
  }
211
211
 
212
212
  // If only Anthropic key available, use configured fast tier directly.
213
+ // Construct ships with no implicit model defaults (ADR-0027,
214
+ // tests/model-router-no-defaults.test.mjs), so an unconfigured fast tier
215
+ // resolves to null. Surface a clear configuration error instead of letting
216
+ // null leak into the candidate loop where /^anthropic\//.test(null) throws.
217
+
213
218
  if (candidates.length === 0 && anthropicKey) {
214
219
  const envPath = getUserEnvPath();
215
- candidates = [readCurrentModels(envPath, {}).fast];
220
+ const fast = readCurrentModels(envPath, {}).fast;
221
+ if (!fast) {
222
+ throw new Error(
223
+ "Schema inference requires a fast-tier model. " +
224
+ "Either set OPENROUTER_API_KEY (to use free model fallbacks) or " +
225
+ "configure a fast tier with 'construct models --apply' or " +
226
+ "'export CX_MODEL_FAST=<model-id>'."
227
+ );
228
+ }
229
+ candidates = [fast];
216
230
  }
217
231
 
218
232
  let lastError;
@@ -261,7 +275,7 @@ async function callModel(userContent, { traceId } = {}) {
261
275
  headers: {
262
276
  Authorization: `Bearer ${orKey}`,
263
277
  'content-type': 'application/json',
264
- 'HTTP-Referer': 'https://github.com/construct',
278
+ 'HTTP-Referer': 'https://github.com/geraldmaron/construct',
265
279
  },
266
280
  body: JSON.stringify({
267
281
  model: openRouterModelId,
@@ -66,6 +66,11 @@ export function ensureCsrfCookie(req, res) {
66
66
  */
67
67
  export function verifyCsrf(req, { skip } = {}) {
68
68
  if (SAFE_METHODS.has(req.method)) return true;
69
+ // A request authenticated by an Authorization header (bearer token) is not
70
+ // cookie-driven, so it cannot be forged cross-origin — CSRF defends ambient
71
+ // cookie auth, not header tokens. This is what lets programmatic API clients
72
+ // (editor adapters, CLI, CI) POST without the browser CSRF dance.
73
+ if (req.headers && req.headers.authorization) return true;
69
74
  if (typeof skip === 'function' && skip(req.url || '')) return true;
70
75
  const headerVal = req.headers[HEADER_NAME];
71
76
  const cookieVal = parseCookies(req)[COOKIE_NAME];
@@ -78,13 +83,20 @@ export function verifyCsrf(req, { skip } = {}) {
78
83
  }
79
84
 
80
85
  /**
81
- * Default skip predicate — webhooks and auth/login may legitimately accept
82
- * cross-origin POSTs without the CSRF dance.
86
+ * Default skip predicate — webhooks, auth/login, and the orchestration API
87
+ * legitimately accept POSTs without the CSRF dance. CSRF defends a browser
88
+ * cookie session; the `/api/orchestration/*` endpoints are consumed only by
89
+ * programmatic clients (the `--remote` CLI, the MCP tool, editor adapters) that
90
+ * carry a bearer token (or nothing in no-token mode), never the dashboard
91
+ * cookie — so CSRF is the wrong control there, and the auth gate still protects
92
+ * them in token mode. Without this, a no-token daemon rejects every orchestration
93
+ * run with a 403.
83
94
  */
84
95
  export function defaultSkip(url) {
85
96
  if (!url) return false;
86
97
  return (
87
98
  url.startsWith('/api/webhooks/') ||
99
+ url.startsWith('/api/orchestration/') ||
88
100
  url === '/api/auth/login' ||
89
101
  url === '/api/slack/commands'
90
102
  );
@@ -32,6 +32,14 @@ import { onEmbedNotification } from '../embed/notifications.mjs';
32
32
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
33
33
  const ROOT_DIR = join(__dirname, '..', '..');
34
34
  const HOME = homedir();
35
+
36
+ // The daemon is a long-lived, detached service not bound to one project, so its
37
+ // orchestration runs persist under the user data root (HOME → ~/.cx/runtime/
38
+ // orchestration via the run store's relative path) rather than the install
39
+ // directory. A global install dir can be shared or read-only and would mix
40
+ // every project's runs together; HOME is always writable and survives reinstalls.
41
+
42
+ const ORCHESTRATION_RUN_ROOT = HOME;
35
43
  const PORT = parseInt(process.env.PORT ?? '4242', 10);
36
44
 
37
45
  // Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
@@ -464,6 +472,88 @@ async function handleApprovals(req, res) {
464
472
  res.end('Method Not Allowed');
465
473
  }
466
474
 
475
+ // Orchestration daemon surface: start a run (background execution), inspect runs,
476
+ // and stream a single run's lifecycle over SSE. Construct's runtime owns the
477
+ // orchestration; this lets any thin client (editor, TUI, CI) reach the outcome.
478
+ // Responses are wrapped in the versioned, secret-free embedded-contract envelope.
479
+
480
+ async function handleOrchestration(req, res) {
481
+ const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
482
+ const { startRun, getRun, getRuns } = await import('../orchestration/runtime.mjs');
483
+ const { onRunEvent, requestCancel } = await import('../orchestration/events.mjs');
484
+ const { wrapResponse } = await import('../embedded-contract/envelope.mjs');
485
+
486
+ const send = (code, data) => {
487
+ res.writeHead(code, { 'Content-Type': 'application/json' });
488
+ res.end(JSON.stringify(wrapResponse({ data, surface: 'sdk' })));
489
+ };
490
+ const fail = (code, message) => {
491
+ res.writeHead(code, { 'Content-Type': 'application/json' });
492
+ res.end(JSON.stringify({ error: message }));
493
+ };
494
+
495
+ if (url.pathname === '/api/orchestration/runs' && req.method === 'POST') {
496
+ let body = '';
497
+ req.on('data', (c) => { body += c; });
498
+ req.on('end', async () => {
499
+ try {
500
+ const d = JSON.parse(body || '{}');
501
+ if (!d.request || typeof d.request !== 'string') throw new Error('Missing "request" string');
502
+ const run = await startRun(
503
+ { request: d.request, workflowType: d.workflowType, requestedStrategy: d.requestedStrategy, host: d.host, hostModel: d.hostModel, hostProvider: d.hostProvider, fileCount: d.fileCount, moduleCount: d.moduleCount },
504
+ { cwd: ORCHESTRATION_RUN_ROOT, workerBackend: d.workerBackend || null },
505
+ );
506
+ send(202, run);
507
+ } catch (err) {
508
+ fail(400, err.message);
509
+ }
510
+ });
511
+ return;
512
+ }
513
+
514
+ if (url.pathname === '/api/orchestration/runs' && req.method === 'GET') {
515
+ const limit = Number(url.searchParams.get('limit') || 20);
516
+ send(200, { runs: await getRuns(ORCHESTRATION_RUN_ROOT, { limit }) });
517
+ return;
518
+ }
519
+
520
+ const match = url.pathname.match(/^\/api\/orchestration\/runs\/([^/]+)(\/events|\/cancel)?$/);
521
+ if (match) {
522
+ const runId = decodeURIComponent(match[1]);
523
+ const sub = match[2];
524
+
525
+ if (!sub && req.method === 'GET') {
526
+ const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
527
+ if (!run) { fail(404, `Run not found: ${runId}`); return; }
528
+ send(200, run);
529
+ return;
530
+ }
531
+
532
+ if (sub === '/cancel' && req.method === 'POST') {
533
+ requestCancel(runId);
534
+ send(202, { runId, cancelRequested: true });
535
+ return;
536
+ }
537
+
538
+ if (sub === '/events' && req.method === 'GET') {
539
+ const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
540
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
541
+ res.write(': connected\n\n');
542
+ if (run) {
543
+ const snapshot = { type: 'snapshot', runId, status: run.status, chainOfThought: run.chainOfThought ?? null, tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })) };
544
+ res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
545
+ }
546
+ const off = onRunEvent(runId, (event) => {
547
+ try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { off(); }
548
+ });
549
+ req.on('close', off);
550
+ return;
551
+ }
552
+ }
553
+
554
+ fail(404, 'Not found');
555
+ }
556
+
467
557
  // ── Config handler ────────────────────────────────────────────────────────
468
558
 
469
559
  function handleConfig(req, res) {
@@ -701,6 +791,11 @@ const server = createServer(async (req, res) => {
701
791
  return;
702
792
  }
703
793
 
794
+ if (url.pathname.startsWith('/api/orchestration/')) {
795
+ await handleOrchestration(req, res);
796
+ return;
797
+ }
798
+
704
799
  if (url.pathname === '/api/registry' && req.method === 'GET') {
705
800
  try {
706
801
  const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
@@ -10,6 +10,7 @@ import fs from 'node:fs';
10
10
  import net from 'node:net';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
13
14
 
14
15
  import { findAvailablePort } from './host-capabilities.mjs';
15
16
  import { getUserEnvPath, loadConstructEnv, parseEnvFile, writeEnvValues } from './env-config.mjs';
@@ -22,15 +23,24 @@ import {
22
23
  isRemoteTelemetry,
23
24
  } from './services/local-postgres.mjs';
24
25
  import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
26
+ import { postgresPort, postgresContainerName, memoryPort as derivedMemoryPort } from './home-namespace.mjs';
25
27
 
26
28
  const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
27
- const CONSTRUCT_PG_CONTAINER = 'construct-postgres';
28
- const CONSTRUCT_PG_PORT = 54329;
29
+ const CONSTRUCT_PG_CONTAINER = postgresContainerName();
30
+ const CONSTRUCT_PG_PORT = postgresPort();
29
31
  const CONSTRUCT_PG_HEALTH_RETRIES = 12;
30
32
  const CONSTRUCT_PG_HEALTH_INTERVAL_MS = 2000;
31
33
 
32
34
  const DASHBOARD_STATE_FILE = 'dashboard.json';
33
35
 
36
+ // The caller's `rootDir` is the project (for compose/context/traces), but the
37
+ // dashboard and doctor entrypoints ship with the install, not the project. They
38
+ // must resolve against the install root — derived from this module's own
39
+ // location — or `construct init` spawns `<project>/lib/server/index.mjs` and the
40
+ // process dies with MODULE_NOT_FOUND while still advertising the URL.
41
+
42
+ const INSTALL_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+
34
44
  // pruneStashDir and verifyTelemetryKeys now live in lib/services/telemetry-backend.mjs.
35
45
  // Re-exported below at module bottom for tests that already import from here.
36
46
 
@@ -260,7 +270,7 @@ export function startDoctor({ rootDir, homeDir = os.homedir() } = {}) {
260
270
  }
261
271
  const existing = readDoctorState(homeDir);
262
272
  if (existing) return { started: false, reused: true, pid: existing.pid };
263
- const indexPath = path.join(rootDir, 'lib', 'doctor', 'index.mjs');
273
+ const indexPath = path.join(INSTALL_ROOT, 'lib', 'doctor', 'index.mjs');
264
274
  if (!fs.existsSync(indexPath)) return { started: false, reason: 'missing-binary' };
265
275
  const { logPath } = spawnDetached('node', [indexPath], homeDir, 'doctor.log', { cwd: rootDir, env: { ...process.env } });
266
276
  return { started: true, logPath };
@@ -279,13 +289,30 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
279
289
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
280
290
  const out = fs.openSync(outPath, 'a');
281
291
  const err = fs.openSync(outPath, 'a');
282
- const child = spawn(process.execPath, [path.join(rootDir, 'lib', 'server', 'index.mjs')], {
292
+ const child = spawn(process.execPath, [path.join(INSTALL_ROOT, 'lib', 'server', 'index.mjs')], {
283
293
  detached: true,
284
294
  stdio: ['ignore', out, err],
285
295
  env: { ...process.env, PORT: String(port) },
286
296
  });
287
297
  child.unref();
288
298
 
299
+ // Confirm the process actually bound the port before persisting state or
300
+ // advertising the URL. Without this the caller reports a dead PID/URL when the
301
+ // server crashes on boot — the readiness gate turns a silent failure into an
302
+ // honest, actionable one (the log tail names the cause).
303
+
304
+ let ready = false;
305
+ for (let i = 0; i < 30; i += 1) {
306
+ if (await probeRuntimePort(port)) { ready = true; break; }
307
+ await new Promise((resolve) => setTimeout(resolve, 200));
308
+ }
309
+
310
+ if (!ready) {
311
+ let logTail = '';
312
+ try { logTail = fs.readFileSync(outPath, 'utf8').split('\n').slice(-12).join('\n'); } catch { /* log unreadable */ }
313
+ return { started: false, failed: true, port, logPath: outPath, error: `dashboard did not bind 127.0.0.1:${port} within 6s`, logTail };
314
+ }
315
+
289
316
  const state = {
290
317
  pid: child.pid,
291
318
  port,
@@ -320,7 +347,7 @@ export async function getRuntimePorts(homeDir = os.homedir(), {
320
347
 
321
348
  return {
322
349
  dashboard: dashboard?.port ?? await resolvePort('DASHBOARD_PORT', 4242, dashboardProbeFn),
323
- memory: await resolvePort('MEMORY_PORT', 8765, memoryProbeFn),
350
+ memory: await resolvePort('MEMORY_PORT', derivedMemoryPort(), memoryProbeFn),
324
351
  bridge: await resolvePort('BRIDGE_PORT', 5173, openCodeProbeFn),
325
352
  };
326
353
  }
@@ -375,13 +402,13 @@ async function waitForConstructPostgresHealthy({
375
402
  }
376
403
 
377
404
  function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
378
- const composeRunner = detectDockerComposeFn();
405
+ const composeRunner = detectDockerComposeFn({ autoStart: true });
379
406
  if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
380
407
 
381
408
  const composeFile = constructPgComposePath(rootDir);
382
409
  if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct init first' };
383
410
 
384
- const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'up', '-d'];
411
+ const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'up', '-d'];
385
412
  const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
386
413
  if (r.status === 0) return { status: 'started' };
387
414
  return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose up failed' };
@@ -394,7 +421,7 @@ function stopConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn =
394
421
  const composeFile = constructPgComposePath(rootDir);
395
422
  if (!fs.existsSync(composeFile)) return { status: 'skipped', note: 'no compose file' };
396
423
 
397
- const args = [...composeRunner.argsPrefix, '-p', 'construct-postgres', '-f', composeFile, 'down'];
424
+ const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'down'];
398
425
  const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
399
426
  if (r.status === 0) return { status: 'stopped' };
400
427
  return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose down failed' };
@@ -540,11 +567,9 @@ export async function startServices({
540
567
  // Dashboard
541
568
  if (wants('dashboard')) {
542
569
  const dashboard = await startDashboardFn({ rootDir, homeDir, preferredPort: ports.dashboard });
543
- results.push({
544
- name: 'Dashboard',
545
- url: dashboard.url,
546
- status: dashboard.reused ? 'reused' : 'started',
547
- });
570
+ results.push(dashboard.failed
571
+ ? { name: 'Dashboard', status: 'failed', note: `${dashboard.error} — see ${dashboard.logPath}` }
572
+ : { name: 'Dashboard', url: dashboard.url, status: dashboard.reused ? 'reused' : 'started' });
548
573
  }
549
574
 
550
575
  const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
@@ -705,7 +730,7 @@ export async function stopServices({
705
730
  // Memory (cm) — find port from config.env or fall back to default
706
731
  const envPath = getUserEnvPath(homeDir);
707
732
  const envValues = parseEnvFile(envPath);
708
- const memoryPort = Number(envValues.MEMORY_PORT) || 8765;
733
+ const memoryPort = Number(envValues.MEMORY_PORT) || derivedMemoryPort();
709
734
  const cmKilled = killPortOwners(memoryPort, spawnSyncFn);
710
735
  results.push({ name: 'Memory (cm)', status: cmKilled ? 'stopped' : 'not-running' });
711
736
 
@@ -29,6 +29,7 @@ export async function consentToInstall({
29
29
  alreadyConfigured = false,
30
30
  alreadyConfiguredNote = '',
31
31
  envPath,
32
+ question = '',
32
33
  defaultYes = true,
33
34
  readlineModule = readline,
34
35
  stdin = process.stdin,
@@ -64,7 +65,7 @@ export async function consentToInstall({
64
65
  }
65
66
 
66
67
  const answer = await promptYesNo({
67
- question: `Install local ${name} via Docker?`,
68
+ question: question || `Install local ${name} via Docker?`,
68
69
  defaultYes,
69
70
  readlineModule,
70
71
  stdin,