@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
@@ -56,13 +56,13 @@ export async function memorySearch(args) {
56
56
  };
57
57
  }
58
58
 
59
- export function memoryAddObservations(args) {
59
+ export async function memoryAddObservations(args) {
60
60
  const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
61
61
  const project = args.project || cwd.split('/').pop() || 'unknown';
62
62
  const observations = (args.observations || []).slice(0, 10);
63
63
  const created = [];
64
64
  for (const obs of observations) {
65
- const record = addObservation(cwd, {
65
+ const record = await addObservation(cwd, {
66
66
  role: obs.role || 'unknown',
67
67
  category: obs.category || 'insight',
68
68
  summary: obs.summary || '',
@@ -0,0 +1,125 @@
1
+ /**
2
+ * lib/mcp/tools/orchestration-run.mjs — MCP client for the local orchestration daemon.
3
+ *
4
+ * Executes a real multi-specialist run by driving the daemon's HTTP API
5
+ * (ADR-0022), so MCP hosts with no subagent primitive — VS Code/Copilot, Cursor —
6
+ * reach orchestrated outcomes through a tool they actually expose: the engine
7
+ * owns orchestration; this tool is the thin client. The daemon URL resolves from
8
+ * dashboard state (else the default loopback port) and the bearer token from
9
+ * ~/.construct/config.env. When the daemon is unreachable the tool fails fast
10
+ * with an actionable error rather than silently degrading to a single-persona
11
+ * pass — the absence of an executable path is made loud, not hidden.
12
+ */
13
+
14
+ import { readDashboardState } from '../../service-manager.mjs';
15
+ import { getDashboardToken } from '../../server/auth.mjs';
16
+
17
+ const TERMINAL = ['completed', 'completed-with-failures', 'cancelled', 'error'];
18
+
19
+ function resolveDaemon(env = process.env) {
20
+ const state = readDashboardState();
21
+ const base = (env.CONSTRUCT_ORCHESTRATION_URL || state?.url || `http://${env.BIND_HOST || '127.0.0.1'}:${env.PORT || '4242'}`).replace(/\/$/, '');
22
+ return { base, token: getDashboardToken(), daemonRunning: Boolean(state) };
23
+ }
24
+
25
+ // A reachability failure is the fail-fast signal MCP hosts need: report how to
26
+ // start the engine instead of letting the host fall back to a manual pass.
27
+
28
+ function unreachable(base, detail) {
29
+ return {
30
+ error: `Orchestration daemon not reachable at ${base}: ${detail}. Start it with \`construct dashboard\`, then retry. The engine runs orchestration; this host is a thin client (ADR-0022).`,
31
+ daemon: base,
32
+ failFast: true,
33
+ };
34
+ }
35
+
36
+ export async function orchestrationRun(args = {}, { fetchImpl = fetch, env = process.env } = {}) {
37
+ const {
38
+ request,
39
+ workflow_type,
40
+ requested_strategy = 'auto',
41
+ worker_backend,
42
+ host,
43
+ host_model,
44
+ host_provider,
45
+ file_count,
46
+ module_count,
47
+ wait = true,
48
+ timeout_ms = 120000,
49
+ } = args;
50
+
51
+ if (!request || typeof request !== 'string') return { error: 'Missing "request" string describing the work to orchestrate.' };
52
+
53
+ const { base, token } = resolveDaemon(env);
54
+ const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
55
+ const body = JSON.stringify({
56
+ request,
57
+ workflowType: workflow_type,
58
+ requestedStrategy: requested_strategy,
59
+ workerBackend: worker_backend,
60
+ host,
61
+ hostModel: host_model,
62
+ hostProvider: host_provider,
63
+ fileCount: file_count,
64
+ moduleCount: module_count,
65
+ });
66
+
67
+ let started;
68
+ try {
69
+ const res = await fetchImpl(`${base}/api/orchestration/runs`, { method: 'POST', headers, body });
70
+ if (res.status === 401 || res.status === 403) {
71
+ return { error: `Daemon rejected the request (HTTP ${res.status}). The dashboard token is missing or wrong — check CONSTRUCT_DASHBOARD_TOKEN in ~/.construct/config.env.`, daemon: base, failFast: true };
72
+ }
73
+ const envelope = await res.json();
74
+ if (!res.ok) return { error: `Daemon error (HTTP ${res.status}): ${envelope.error || 'unknown'}`, daemon: base };
75
+ started = envelope.data;
76
+ } catch (err) {
77
+ return unreachable(base, err.message);
78
+ }
79
+
80
+ if (!wait) {
81
+ return { runId: started.runId, status: started.status, executionMode: started.execution?.executionMode, daemon: base, poll: 'orchestration_status with run_id' };
82
+ }
83
+
84
+ const deadline = Date.now() + timeout_ms;
85
+ let run = started;
86
+ while (!TERMINAL.includes(run.status)) {
87
+ if (Date.now() > deadline) {
88
+ return { runId: started.runId, status: run.status, timedOut: true, daemon: base, note: 'Run is still executing; poll it with orchestration_status.' };
89
+ }
90
+ await new Promise((r) => setTimeout(r, 250));
91
+ try {
92
+ const res = await fetchImpl(`${base}/api/orchestration/runs/${encodeURIComponent(started.runId)}`, { headers });
93
+ const envelope = await res.json();
94
+ if (!res.ok) return { error: `Daemon error while polling (HTTP ${res.status}): ${envelope.error || 'unknown'}`, runId: started.runId, daemon: base };
95
+ run = envelope.data;
96
+ } catch (err) {
97
+ return { error: `Lost connection to the daemon while polling run ${started.runId}: ${err.message}`, runId: started.runId, daemon: base, failFast: true };
98
+ }
99
+ }
100
+
101
+ return {
102
+ runId: run.runId,
103
+ status: run.status,
104
+ executionMode: run.execution?.executionMode,
105
+ degraded: run.execution?.degraded ?? false,
106
+ degradationReason: run.execution?.degradationReason ?? null,
107
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null })),
108
+ daemon: base,
109
+ };
110
+ }
111
+
112
+ export async function orchestrationStatus(args = {}, { fetchImpl = fetch, env = process.env } = {}) {
113
+ const { run_id, limit = 20 } = args;
114
+ const { base, token } = resolveDaemon(env);
115
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
116
+ const path = run_id ? `/api/orchestration/runs/${encodeURIComponent(run_id)}` : `/api/orchestration/runs?limit=${encodeURIComponent(limit)}`;
117
+ try {
118
+ const res = await fetchImpl(`${base}${path}`, { headers });
119
+ const envelope = await res.json();
120
+ if (!res.ok) return { error: `Daemon error (HTTP ${res.status}): ${envelope.error || 'unknown'}`, daemon: base };
121
+ return envelope.data;
122
+ } catch (err) {
123
+ return unreachable(base, err.message);
124
+ }
125
+ }
@@ -37,14 +37,13 @@ export async function storageReset(args) {
37
37
  return resetStorage(cwd, {
38
38
  env: process.env,
39
39
  project,
40
- resetSql: args.reset_sql !== false,
41
40
  resetVector: args.reset_vector !== false,
42
41
  resetIngested: args.reset_ingested === true,
43
42
  confirm: true,
44
43
  });
45
44
  }
46
45
 
47
- export function deleteIngestedArtifactsTool(args) {
46
+ export async function deleteIngestedArtifactsTool(args) {
48
47
  const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
49
48
  if (args.confirm !== true) {
50
49
  return { error: 'delete_ingested_artifacts requires confirm=true' };
@@ -53,7 +52,7 @@ export function deleteIngestedArtifactsTool(args) {
53
52
  ? args.files.map((value) => String(value))
54
53
  : [];
55
54
  try {
56
- return deleteIngestedArtifacts(cwd, { files, confirm: true });
55
+ return await deleteIngestedArtifacts(cwd, { files, confirm: true });
57
56
  } catch (error) {
58
57
  return { error: error.message ?? String(error) };
59
58
  }
@@ -85,12 +85,12 @@
85
85
  "id": "playwright",
86
86
  "name": "Playwright",
87
87
  "category": "optional",
88
- "description": "Browser automation for E2E testing and web research.",
89
- "package": "@playwright/mcp@latest",
88
+ "description": "Browser automation for E2E testing and web research. Opt-in via `construct mcp add playwright` — not synced by default (downloads browser binaries on first use).",
89
+ "package": "@playwright/mcp@0.0.75",
90
90
  "command": "npx",
91
91
  "args": [
92
92
  "-y",
93
- "@playwright/mcp@latest"
93
+ "@playwright/mcp@0.0.75"
94
94
  ],
95
95
  "env": {},
96
96
  "requiredEnv": [],
@@ -33,6 +33,56 @@ function getClaudeSettingsPath() {
33
33
  return join(homedir(), '.claude', 'settings.json');
34
34
  }
35
35
 
36
+ // VS Code's "MCP: Open User Configuration" edits a per-profile mcp.json keyed
37
+ // on top-level `servers`; Cursor uses ~/.cursor/mcp.json keyed on `mcpServers`.
38
+ // construct sync writes both surfaces, so remove must clean them too or a
39
+ // removed MCP lingers there.
40
+
41
+ function getVSCodeUserMcpPaths(home = homedir()) {
42
+ const plat = platform();
43
+ if (plat === 'darwin') {
44
+ return [
45
+ join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
46
+ join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
47
+ ];
48
+ }
49
+ if (plat === 'win32') {
50
+ const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
51
+ return [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')];
52
+ }
53
+ return [
54
+ join(home, '.config', 'Code', 'User', 'mcp.json'),
55
+ join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
56
+ ];
57
+ }
58
+
59
+ function getCursorMcpPath(home = homedir()) {
60
+ return join(home, '.cursor', 'mcp.json');
61
+ }
62
+
63
+ function mcpFileHasEntry(file, key, id) {
64
+ if (!existsSync(file)) return false;
65
+ try {
66
+ return Boolean(JSON.parse(readFileSync(file, 'utf8'))?.[key]?.[id]);
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function removeMcpFromJsonFile(file, key, id) {
73
+ if (!existsSync(file)) return false;
74
+ let config;
75
+ try {
76
+ config = JSON.parse(readFileSync(file, 'utf8'));
77
+ } catch {
78
+ return false;
79
+ }
80
+ if (!config?.[key] || !(id in config[key])) return false;
81
+ delete config[key][id];
82
+ writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
83
+ return true;
84
+ }
85
+
36
86
  function openUrl(url) {
37
87
  try {
38
88
  const opener = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
@@ -426,10 +476,14 @@ export function cmdMcpRemove(id) {
426
476
  const openCodeState = readOpenCodeConfig();
427
477
  const oc = openCodeState.config ?? {};
428
478
  const openCodeId = getOpenCodeMcpId(id);
479
+ const vscodePaths = getVSCodeUserMcpPaths();
480
+ const cursorPath = getCursorMcpPath();
429
481
  const hasClaudeEntry = Boolean(settings.mcpServers?.[id]);
430
482
  const hasOpenCodeEntry = Boolean(oc.mcp?.[openCodeId] || oc.mcp?.[id] || (id === 'memory' && oc.mcp?.cass));
431
483
  const hasCodexEntry = readCodexConfig().includes(`[mcp_servers.${id}]`) || readCodexConfig().includes(`[mcp_servers.${tomlString(id)}]`);
432
- if (!hasClaudeEntry && !hasOpenCodeEntry && !hasCodexEntry) {
484
+ const hasVscodeEntry = vscodePaths.some((p) => mcpFileHasEntry(p, 'servers', id));
485
+ const hasCursorEntry = mcpFileHasEntry(cursorPath, 'mcpServers', id);
486
+ if (!hasClaudeEntry && !hasOpenCodeEntry && !hasCodexEntry && !hasVscodeEntry && !hasCursorEntry) {
433
487
  console.log(`${id} is not installed. Nothing to remove.`);
434
488
  return;
435
489
  }
@@ -448,9 +502,11 @@ export function cmdMcpRemove(id) {
448
502
  if (openCodeState.config || hasOpenCodeEntry) saveOpenCodeConfig(oc);
449
503
  else if (openCodeState.file && existsSync(openCodeState.file)) rmSync(openCodeState.file, { force: true });
450
504
  removeCodexMcp(id);
505
+ for (const p of vscodePaths) removeMcpFromJsonFile(p, 'servers', id);
506
+ removeMcpFromJsonFile(cursorPath, 'mcpServers', id);
451
507
 
452
- console.log(`✓ ${name} removed from Claude Code, OpenCode, and Codex config`);
453
- console.log('Restart Claude Code, OpenCode, or Codex to deactivate it.');
508
+ console.log(`✓ ${name} removed from Claude Code, OpenCode, Codex, VS Code, and Cursor config`);
509
+ console.log('Restart the editor to deactivate it.');
454
510
  }
455
511
 
456
512
  /**
@@ -20,17 +20,14 @@ import path from 'node:path';
20
20
  * 1. Environment variables (CX_MODEL_*): Emergency overrides
21
21
  * 2. User config (~/.construct/config.env): Personal defaults
22
22
  * 3. Registry (specialists/registry.json): Project recommendations
23
- * 4. Built-in defaults: Last resort fallbacks
24
- *
25
- * Primary resolution path. All code must use this function.
23
+ *
24
+ * Construct ships with NO implicit model defaults (ADR-0027,
25
+ * tests/model-router-no-defaults.test.mjs). When none of the three sources
26
+ * resolves a tier, that tier returns null with source 'not configured'.
27
+ * Callers must surface a clear remediation hint rather than silently
28
+ * substituting a vendor-specific default.
26
29
  */
27
30
 
28
- const BUILTIN_DEFAULTS = {
29
- reasoning: 'anthropic/claude-3-5-sonnet',
30
- standard: 'anthropic/claude-3-haiku',
31
- fast: 'anthropic/claude-3-haiku',
32
- };
33
-
34
31
  const ENV_PREFIX = 'CX_MODEL';
35
32
 
36
33
  // ---------------------------------------------------------------------------
@@ -86,28 +83,30 @@ export function resolveModelTiers(options = {}) {
86
83
  }
87
84
  }
88
85
 
89
- // Built-in default
90
- result[tier] = BUILTIN_DEFAULTS[tier];
91
- sources[tier] = 'default';
86
+ // No source resolved this tier — leave null + 'not configured' so callers
87
+ // can surface a clear remediation hint. Aligns with the wired path
88
+ // (lib/model-router.mjs:resolveTierAssignments) and the no-defaults
89
+ // contract locked in by tests/model-router-no-defaults.test.mjs.
90
+
91
+ result[tier] = null;
92
+ sources[tier] = 'not configured';
92
93
  }
93
-
94
- // Validation
94
+
95
95
  const configured = Object.values(result).filter(Boolean).length;
96
-
96
+
97
97
  if (strict && configured < 3) {
98
98
  const missing = Object.entries(result)
99
99
  .filter(([, v]) => !v)
100
100
  .map(([k]) => k);
101
101
  errors.push(`Missing configuration for tiers: ${missing.join(', ')}`);
102
102
  }
103
-
103
+
104
104
  return {
105
105
  models: result,
106
106
  sources,
107
107
  configured,
108
108
  complete: configured === 3,
109
109
  errors: errors.length > 0 ? errors : null,
110
- defaultsUsed: Object.values(sources).filter(s => s === 'default').length,
111
110
  };
112
111
  }
113
112
 
@@ -117,7 +116,7 @@ export function resolveModelTiers(options = {}) {
117
116
 
118
117
  export function getModelForTier(tier, options = {}) {
119
118
  const resolved = resolveModelTiers(options);
120
- return resolved.models[tier] || BUILTIN_DEFAULTS[tier];
119
+ return resolved.models[tier];
121
120
  }
122
121
 
123
122
  export function getModelSource(tier, options = {}) {
@@ -131,12 +130,15 @@ export function getModelSource(tier, options = {}) {
131
130
 
132
131
  export function validateModelTiers(options = {}) {
133
132
  const resolved = resolveModelTiers({ ...options, strict: true });
134
-
133
+ const unconfigured = Object.entries(resolved.sources)
134
+ .filter(([, source]) => source === 'not configured')
135
+ .map(([tier]) => tier);
136
+
135
137
  return {
136
138
  valid: resolved.complete && !resolved.errors,
137
139
  errors: resolved.errors,
138
- warnings: resolved.defaultsUsed > 0
139
- ? [`Using ${resolved.defaultsUsed} built-in default(s). Consider configuring custom models.`]
140
+ warnings: unconfigured.length > 0
141
+ ? [`Unconfigured tier${unconfigured.length === 1 ? '' : 's'}: ${unconfigured.join(', ')}. Run 'construct models --apply' or set CX_MODEL_<TIER>.`]
140
142
  : [],
141
143
  resolution: resolved,
142
144
  };
@@ -144,25 +146,30 @@ export function validateModelTiers(options = {}) {
144
146
 
145
147
  export function formatModelStatus(options = {}) {
146
148
  const resolved = resolveModelTiers(options);
147
-
149
+
148
150
  let output = 'Model Configuration:\n\n';
149
-
151
+
150
152
  for (const tier of ['reasoning', 'standard', 'fast']) {
151
153
  const model = resolved.models[tier];
152
154
  const source = resolved.sources[tier];
153
- const icon = source === 'default' ? '⚠' : '✓';
154
-
155
- output += `${icon} ${tier.padEnd(10)} ${model}\n`;
155
+ const icon = source === 'not configured' ? '⚠' : '✓';
156
+ const display = model ?? '(not configured)';
157
+
158
+ output += `${icon} ${tier.padEnd(10)} ${display}\n`;
156
159
  output += ` Source: ${source}\n\n`;
157
160
  }
158
-
159
- if (resolved.defaultsUsed > 0) {
160
- output += '\nNote: Using built-in defaults. To customize:\n';
161
- output += ' export CX_MODEL_REASONING=your-model\n';
162
- output += ' export CX_MODEL_STANDARD=your-model\n';
163
- output += ' export CX_MODEL_FAST=your-model\n';
161
+
162
+ const unconfigured = Object.entries(resolved.sources)
163
+ .filter(([, source]) => source === 'not configured')
164
+ .map(([tier]) => tier);
165
+ if (unconfigured.length > 0) {
166
+ output += `\nUnconfigured: ${unconfigured.join(', ')}. To configure:\n`;
167
+ output += ' construct models --apply (interactive, picks from available providers)\n';
168
+ output += ' export CX_MODEL_REASONING=... (env override)\n';
169
+ output += ' export CX_MODEL_STANDARD=...\n';
170
+ output += ' export CX_MODEL_FAST=...\n';
164
171
  }
165
-
172
+
166
173
  return output;
167
174
  }
168
175