@geraldmaron/construct 1.0.15 → 1.0.17

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 (198) hide show
  1. package/README.md +19 -3
  2. package/bin/construct +376 -98
  3. package/bin/construct-postinstall.mjs +17 -2
  4. package/db/schema/010_cx_scores.sql +51 -0
  5. package/lib/cli-commands.mjs +333 -147
  6. package/lib/contracts/validate.mjs +20 -10
  7. package/lib/contracts/violation-log.mjs +27 -12
  8. package/lib/embedded-contract/audit.mjs +52 -0
  9. package/lib/embedded-contract/capability.mjs +179 -0
  10. package/lib/embedded-contract/contract-version.mjs +39 -0
  11. package/lib/embedded-contract/envelope.mjs +70 -0
  12. package/lib/embedded-contract/index.mjs +71 -0
  13. package/lib/embedded-contract/ingest.mjs +77 -0
  14. package/lib/embedded-contract/model-resolve.mjs +186 -0
  15. package/lib/embedded-contract/redaction.mjs +91 -0
  16. package/lib/embedded-contract/role-facts.mjs +66 -0
  17. package/lib/embedded-contract/triage.mjs +134 -0
  18. package/lib/embedded-contract/workflow-defs.mjs +125 -0
  19. package/lib/embedded-contract/workflow-invoke.mjs +218 -0
  20. package/lib/hooks/config-protection.mjs +12 -5
  21. package/lib/hooks/stop-notify.mjs +7 -0
  22. package/lib/init-unified.mjs +36 -26
  23. package/lib/install/first-invocation.mjs +5 -1
  24. package/lib/intake/classify.mjs +6 -0
  25. package/lib/intake/prepare.mjs +22 -4
  26. package/lib/intake/tables/rnd.mjs +33 -0
  27. package/lib/integrations/intake-integrations.mjs +28 -2
  28. package/lib/mcp/server.mjs +69 -0
  29. package/lib/mcp/tools/embedded-contract.mjs +77 -0
  30. package/lib/mcp/tools/telemetry.mjs +30 -0
  31. package/lib/model-router.mjs +40 -0
  32. package/lib/op-log.mjs +61 -0
  33. package/lib/orchestration-policy.mjs +15 -0
  34. package/lib/roles/catalog.mjs +26 -95
  35. package/lib/roles/gateway.mjs +30 -1
  36. package/lib/scheduler/index.mjs +24 -4
  37. package/lib/server/insights.mjs +12 -0
  38. package/lib/service-manager.mjs +33 -11
  39. package/lib/setup.mjs +73 -10
  40. package/lib/update.mjs +31 -3
  41. package/lib/upgrade.mjs +31 -4
  42. package/lib/validators/skills.mjs +21 -0
  43. package/package.json +9 -3
  44. package/personas/construct.md +2 -0
  45. package/platforms/claude/CLAUDE.md +43 -15
  46. package/scripts/sync-specialists.mjs +32 -5
  47. package/skills/ai/agent-dev.md +2 -0
  48. package/skills/ai/llm-security.md +2 -0
  49. package/skills/ai/ml-ops.md +2 -0
  50. package/skills/ai/orchestration-workflow.md +2 -0
  51. package/skills/ai/prompt-and-eval.md +2 -0
  52. package/skills/ai/prompt-optimizer.md +2 -0
  53. package/skills/ai/rag-system.md +2 -0
  54. package/skills/ai/trace-triage.md +36 -0
  55. package/skills/architecture/api-design.md +2 -0
  56. package/skills/architecture/caching.md +2 -0
  57. package/skills/architecture/cloud-native.md +2 -0
  58. package/skills/architecture/message-queue.md +2 -0
  59. package/skills/architecture/security-arch.md +2 -0
  60. package/skills/compliance/ai-disclosure.md +2 -0
  61. package/skills/compliance/data-privacy.md +2 -0
  62. package/skills/compliance/license-audit.md +2 -0
  63. package/skills/compliance/regulatory-review.md +2 -0
  64. package/skills/development/cpp.md +2 -0
  65. package/skills/development/go.md +2 -0
  66. package/skills/development/java.md +2 -0
  67. package/skills/development/kotlin.md +2 -0
  68. package/skills/development/mobile-crossplatform.md +2 -0
  69. package/skills/development/python.md +2 -0
  70. package/skills/development/rust.md +2 -0
  71. package/skills/development/shell.md +2 -0
  72. package/skills/development/swift.md +2 -0
  73. package/skills/development/typescript.md +2 -0
  74. package/skills/devops/ci-cd.md +2 -0
  75. package/skills/devops/containerization.md +2 -0
  76. package/skills/devops/cost-optimization.md +2 -0
  77. package/skills/devops/data-engineering.md +2 -0
  78. package/skills/devops/database.md +2 -0
  79. package/skills/devops/dependency-management.md +2 -0
  80. package/skills/devops/devsecops.md +2 -0
  81. package/skills/devops/git-workflow.md +2 -0
  82. package/skills/devops/incident-response.md +2 -0
  83. package/skills/devops/monorepo.md +2 -0
  84. package/skills/devops/observability.md +2 -0
  85. package/skills/devops/performance.md +2 -0
  86. package/skills/devops/testing.md +2 -0
  87. package/skills/docs/adr-workflow.md +2 -0
  88. package/skills/docs/backlog-proposal-workflow.md +2 -0
  89. package/skills/docs/customer-profile-workflow.md +2 -0
  90. package/skills/docs/document-ingest-workflow.md +2 -0
  91. package/skills/docs/evidence-ingest-workflow.md +2 -0
  92. package/skills/docs/init-docs.md +2 -0
  93. package/skills/docs/init-project.md +2 -0
  94. package/skills/docs/memo-and-decision-capture.md +45 -0
  95. package/skills/docs/prd-workflow.md +2 -0
  96. package/skills/docs/prfaq-workflow.md +2 -0
  97. package/skills/docs/product-intelligence-review.md +2 -0
  98. package/skills/docs/product-intelligence-workflow.md +2 -0
  99. package/skills/docs/product-signal-workflow.md +2 -0
  100. package/skills/docs/research-workflow.md +2 -0
  101. package/skills/docs/runbook-workflow.md +2 -0
  102. package/skills/docs/strategy-workflow.md +2 -0
  103. package/skills/docs/transcript-synthesis.md +43 -0
  104. package/skills/exploration/dependency-graph-reading.md +2 -0
  105. package/skills/exploration/repo-map.md +2 -0
  106. package/skills/exploration/tracer-bullet-method.md +2 -0
  107. package/skills/exploration/unknown-codebase-onboarding.md +2 -0
  108. package/skills/frameworks/django.md +2 -0
  109. package/skills/frameworks/nextjs.md +2 -0
  110. package/skills/frameworks/react.md +2 -0
  111. package/skills/frameworks/spring-boot.md +2 -0
  112. package/skills/frontend-design/accessibility.md +2 -0
  113. package/skills/frontend-design/component-patterns.md +2 -0
  114. package/skills/frontend-design/engineering.md +2 -0
  115. package/skills/frontend-design/screen-reader-testing.md +34 -0
  116. package/skills/frontend-design/state-management.md +2 -0
  117. package/skills/frontend-design/ui-aesthetics.md +2 -0
  118. package/skills/frontend-design/ux-principles.md +2 -0
  119. package/skills/operating/change-management.md +2 -0
  120. package/skills/operating/incident-response.md +2 -0
  121. package/skills/operating/oncall-rotation.md +2 -0
  122. package/skills/operating/orchestration-reference.md +2 -0
  123. package/skills/operating/raw-data-structuring.md +44 -0
  124. package/skills/operating/unstructured-triage.md +45 -0
  125. package/skills/quality-gates/premortem.md +37 -0
  126. package/skills/quality-gates/review-work.md +2 -0
  127. package/skills/quality-gates/verify-change.md +2 -0
  128. package/skills/quality-gates/verify-module.md +2 -0
  129. package/skills/quality-gates/verify-quality.md +2 -0
  130. package/skills/quality-gates/verify-security.md +2 -0
  131. package/skills/roles/architect.ai-systems.md +2 -0
  132. package/skills/roles/architect.data.md +2 -0
  133. package/skills/roles/architect.enterprise.md +2 -0
  134. package/skills/roles/architect.integration.md +2 -0
  135. package/skills/roles/architect.md +2 -0
  136. package/skills/roles/architect.platform.md +2 -0
  137. package/skills/roles/data-analyst.experiment.md +2 -0
  138. package/skills/roles/data-analyst.md +2 -0
  139. package/skills/roles/data-analyst.product-intelligence.md +2 -0
  140. package/skills/roles/data-analyst.product.md +2 -0
  141. package/skills/roles/data-analyst.telemetry.md +2 -0
  142. package/skills/roles/data-engineer.pipeline.md +2 -0
  143. package/skills/roles/data-engineer.vector-retrieval.md +2 -0
  144. package/skills/roles/data-engineer.warehouse.md +2 -0
  145. package/skills/roles/debugger.md +2 -0
  146. package/skills/roles/designer.accessibility.md +2 -0
  147. package/skills/roles/designer.md +2 -0
  148. package/skills/roles/engineer.ai.md +2 -0
  149. package/skills/roles/engineer.data.md +2 -0
  150. package/skills/roles/engineer.md +2 -0
  151. package/skills/roles/engineer.platform.md +2 -0
  152. package/skills/roles/operator.docs.md +2 -0
  153. package/skills/roles/operator.md +2 -0
  154. package/skills/roles/operator.release.md +2 -0
  155. package/skills/roles/operator.sre.md +2 -0
  156. package/skills/roles/orchestrator.md +2 -0
  157. package/skills/roles/product-manager.ai-product.md +2 -0
  158. package/skills/roles/product-manager.business-strategy.md +2 -0
  159. package/skills/roles/product-manager.enterprise.md +2 -0
  160. package/skills/roles/product-manager.growth.md +2 -0
  161. package/skills/roles/product-manager.md +2 -0
  162. package/skills/roles/product-manager.platform.md +2 -0
  163. package/skills/roles/product-manager.product.md +2 -0
  164. package/skills/roles/qa.ai-eval.md +2 -0
  165. package/skills/roles/qa.api-contract.md +2 -0
  166. package/skills/roles/qa.data-pipeline.md +2 -0
  167. package/skills/roles/qa.md +2 -0
  168. package/skills/roles/qa.test-automation.md +2 -0
  169. package/skills/roles/qa.web-ui.md +2 -0
  170. package/skills/roles/researcher.explorer.md +2 -0
  171. package/skills/roles/researcher.md +2 -0
  172. package/skills/roles/researcher.ux.md +2 -0
  173. package/skills/roles/reviewer.devil-advocate.md +2 -0
  174. package/skills/roles/reviewer.evaluator.md +2 -0
  175. package/skills/roles/reviewer.md +2 -0
  176. package/skills/roles/reviewer.trace.md +2 -0
  177. package/skills/roles/security.ai.md +2 -0
  178. package/skills/roles/security.appsec.md +2 -0
  179. package/skills/roles/security.cloud.md +2 -0
  180. package/skills/roles/security.legal-compliance.md +2 -0
  181. package/skills/roles/security.md +2 -0
  182. package/skills/roles/security.privacy.md +2 -0
  183. package/skills/roles/security.supply-chain.md +2 -0
  184. package/skills/security/blue-team.md +2 -0
  185. package/skills/security/code-audit.md +2 -0
  186. package/skills/security/pentest.md +2 -0
  187. package/skills/security/red-team.md +2 -0
  188. package/skills/security/threat-intel.md +2 -0
  189. package/skills/security/vuln-research.md +2 -0
  190. package/skills/strategy/competitive-landscape.md +2 -0
  191. package/skills/strategy/jobs-to-be-done.md +38 -0
  192. package/skills/strategy/market-research-methods.md +2 -0
  193. package/skills/strategy/narrative-arc.md +2 -0
  194. package/skills/strategy/pricing-positioning.md +2 -0
  195. package/skills/utility/clean-code.md +2 -0
  196. package/specialists/prompts/cx-engineer.md +1 -1
  197. package/specialists/registry.json +18 -9
  198. package/specialists/role-manifests.json +1 -1
package/bin/construct CHANGED
@@ -12,7 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { spawnSync } from 'node:child_process';
14
14
 
15
- import { CLI_COMMANDS, CLI_COMMANDS_BY_CATEGORY, CATEGORY_ORDER } from '../lib/cli-commands.mjs';
15
+ import { CLI_COMMANDS, CLI_COMMANDS_BY_CATEGORY, CATEGORY_ORDER, formatCommandHelp, isInternalCommand } from '../lib/cli-commands.mjs';
16
16
  import { buildStatus, formatStatusReport } from '../lib/status.mjs';
17
17
  import { validateRegistry } from '../lib/validator.mjs';
18
18
  import { readCurrentModels, readOpenRouterApiKeyFromOpenCodeConfig, applyToEnv, resetEnv, setTierModel, setModelWithTierInference } from '../lib/model-router.mjs';
@@ -104,83 +104,75 @@ function iconColumn(emoji) {
104
104
  return emoji + trailingSpaces;
105
105
  }
106
106
 
107
- function usage() {
108
- const showAll = restArgsCache.includes('--all') || restArgsCache.includes('-a');
109
- const commands = showAll ? CLI_COMMANDS : CLI_COMMANDS.filter(c => c.core);
110
-
107
+ function usage(opts = {}) {
108
+ const showAll = opts.all ?? (restArgsCache.includes('--all') || restArgsCache.includes('-a'));
109
+ // Always filter out internal commands; users can still run them directly,
110
+ // but --all is for "everything humans use" not "every handler in the map".
111
+ const visible = CLI_COMMANDS.filter((c) => !c.internal);
112
+ const commands = showAll ? visible : visible.filter((c) => c.core);
113
+
111
114
  if (showAll) {
112
115
  println(`${COLORS.bold}construct${COLORS.reset} — AI agent harness (all commands)`);
113
116
  } else {
114
117
  println(`${COLORS.bold}construct${COLORS.reset} — AI agent harness`);
115
- println(`${COLORS.dim}Tip: Run ${COLORS.reset}construct --all${COLORS.dim} to see all commands${COLORS.reset}`);
118
+ println(`${COLORS.dim}Tip: Run ${COLORS.reset}construct --all${COLORS.dim} to see all commands · ${COLORS.reset}construct <cmd> --help${COLORS.dim} for details${COLORS.reset}`);
116
119
  }
117
-
120
+
118
121
  println(`\n${COLORS.dim}Usage:${COLORS.reset} construct <command> [options]\n`);
119
-
122
+
120
123
  for (const category of CATEGORY_ORDER) {
121
- const categoryCommands = commands.filter(c => c.category === category);
124
+ const categoryCommands = commands.filter((c) => c.category === category);
122
125
  if (!categoryCommands.length) continue;
123
126
  println(`${COLORS.bold}${category}${COLORS.reset}`);
124
127
  for (const command of categoryCommands) {
125
- println(` ${iconColumn(command.emoji)}${command.name.padEnd(14)} ${command.description}`);
128
+ const emoji = command.emoji ?? ' ';
129
+ println(` ${iconColumn(emoji)}${command.name.padEnd(14)} ${command.description}`);
126
130
  }
127
131
  println('');
128
132
  }
129
133
  }
130
134
 
131
- /** Show interactive context-aware menu when construct is run without arguments. */
135
+ // Bare-command landing show the same core-command listing as `construct
136
+ // --help`, prefixed with a context line so users know where they are, and
137
+ // suffixed with the one next-step suggestion that applies to their state.
138
+
132
139
  async function showInteractiveMenu() {
133
- const { loadProjectConfig } = await import('../lib/config/project-config.mjs');
134
140
  const { existsSync } = await import('node:fs');
135
141
  const { join } = await import('node:path');
136
-
142
+
137
143
  const projectRoot = process.cwd();
138
144
  const isConstructProject = existsSync(join(projectRoot, 'construct.config.json')) ||
139
145
  existsSync(join(projectRoot, '.cx'));
140
-
141
- println(`${COLORS.bold}construct${COLORS.reset} — AI agent harness`);
142
- println('');
143
-
146
+
144
147
  if (isConstructProject) {
145
148
  const projectName = process.env.CX_PROJECT_NAME || path.basename(projectRoot);
146
149
  println(`${COLORS.dim}Project:${COLORS.reset} ${projectName}`);
147
150
  println('');
148
151
  }
149
-
150
- println(`${COLORS.bold}Core Commands:${COLORS.reset}`);
151
- println(` ${COLORS.green}🚀 dev${COLORS.reset} Start services for development`);
152
- println(` ${COLORS.green}⏹ stop${COLORS.reset} Stop all running services`);
153
- println(` ${COLORS.green}📡 status${COLORS.reset} Show system health and credentials`);
154
- println(` ${COLORS.green}🏗️ init${COLORS.reset} Initialize project and start services`);
155
- println(` ${COLORS.green}🔄 sync${COLORS.reset} Sync agent adapters to AI tools`);
156
- println('');
157
- println(`${COLORS.dim}Run 'construct <command> --help' for command details${COLORS.reset}`);
158
- println(`${COLORS.dim}Run 'construct --all' to see all commands${COLORS.reset}`);
159
- println('');
160
-
161
- // Show context-aware suggestions
152
+
153
+ usage({ all: false });
154
+
162
155
  if (isConstructProject) {
163
156
  const { readDashboardState } = await import('../lib/service-manager.mjs');
164
157
  const dashboard = readDashboardState(HOME);
165
-
166
158
  if (!dashboard) {
167
- println(`${COLORS.yellow}💡 Services not running. Start with:${COLORS.reset}`);
168
- println(` ${COLORS.green}construct dev${COLORS.reset}`);
159
+ println(`${COLORS.yellow}💡 Services not running. Start with:${COLORS.reset} ${COLORS.green}construct dev${COLORS.reset}`);
169
160
  println('');
170
161
  }
171
162
  } else {
172
- println(`${COLORS.yellow}💡 Not a Construct project. Initialize with:${COLORS.reset}`);
173
- println(` ${COLORS.green}construct init${COLORS.reset}`);
163
+ println(`${COLORS.yellow}💡 Not a Construct project. Initialize with:${COLORS.reset} ${COLORS.green}construct init${COLORS.reset}`);
174
164
  println('');
175
165
  }
176
166
  }
177
167
 
178
- function runNodeScript(scriptPath, args = [], extraEnv = {}) {
168
+ function runNodeScript(scriptPath, args = [], extraEnv = {}, { exitOnError = true } = {}) {
179
169
  const result = spawnSync(process.execPath, [scriptPath, ...args], {
180
170
  stdio: 'inherit',
181
171
  env: { ...process.env, ...extraEnv },
182
172
  });
183
- if (result.status !== 0) process.exit(result.status ?? 1);
173
+ const status = result.status ?? 1;
174
+ if (status !== 0 && exitOnError) process.exit(status);
175
+ return status;
184
176
  }
185
177
 
186
178
  async function cmdStatus() {
@@ -248,12 +240,24 @@ async function cmdShow() {
248
240
  }
249
241
 
250
242
  async function cmdSync(args) {
243
+ const { startOpLog } = await import('../lib/op-log.mjs');
244
+ const opLog = startOpLog('sync', { homeDir: HOME });
245
+ opLog.event('args', { args });
246
+
251
247
  generateCompletions();
252
- runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs'), args);
253
- if (args.includes('--no-docs')) return;
248
+ const syncStatus = runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs'), args, {}, { exitOnError: false });
249
+ if (syncStatus !== 0) {
250
+ opLog.close('failed', { stage: 'sync-specialists', status: syncStatus });
251
+ process.exit(syncStatus);
252
+ }
253
+ if (args.includes('--no-docs')) {
254
+ opLog.close('ok', { docs: 'skipped' });
255
+ return;
256
+ }
254
257
  const { regenerateDocs } = await import('../lib/auto-docs.mjs');
255
258
  const { changed } = await regenerateDocs({ rootDir: ROOT_DIR });
256
259
  for (const f of changed) ok(`Updated ${path.relative(ROOT_DIR, f)}`);
260
+ opLog.close('ok', { docsChanged: changed.length });
257
261
  }
258
262
 
259
263
  async function cmdCompletions(args) {
@@ -506,35 +510,12 @@ async function cmdDoctor() {
506
510
  );
507
511
  }
508
512
 
509
- // Pre-push bypass frequency. Every honored CONSTRUCT_SKIP_PREPUSH /
510
- // CONSTRUCT_ALLOW_CLAUDE_PUSH / CONSTRUCT_SKIP_PR_LINT writes a JSONL
511
- // entry to ~/.construct/audit/prepush-bypass.log. Surfacing frequent
512
- // usage here is the early warning that the gate is the wrong shape —
513
- // the alternative is letting the bypass quietly become the default.
514
-
515
- const bypassLogPath = path.join(userHome, '.construct', 'audit', 'prepush-bypass.log');
516
- if (fs.existsSync(bypassLogPath)) {
517
- try {
518
- const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
519
- const recent = fs.readFileSync(bypassLogPath, 'utf8')
520
- .split('\n')
521
- .filter(Boolean)
522
- .map((line) => { try { return JSON.parse(line); } catch { return null; } })
523
- .filter((e) => e && Date.parse(e.ts) >= cutoff);
524
- const BYPASS_WARN_THRESHOLD = Number(process.env.CONSTRUCT_DOCTOR_BYPASS_WARN) || 3;
525
- if (recent.length >= BYPASS_WARN_THRESHOLD) {
526
- add(
527
- `Pre-push bypasses in last 7 days: ${recent.length} (gate may be wrong-sized — see ${bypassLogPath})`,
528
- false,
529
- true,
530
- );
531
- } else {
532
- add(`Pre-push bypasses in last 7 days: ${recent.length}`, true);
533
- }
534
- } catch { /* audit log read is best-effort */ }
535
- } else {
536
- add('Pre-push bypass log not present (no bypasses honored yet)', true);
537
- }
513
+ // (Pre-push bypass doctor check removed in v1.0.16.) v1.0.15 deleted the
514
+ // entire skip-var infrastructure (CONSTRUCT_SKIP_PREPUSH, _COMMENT_LINT,
515
+ // _PR_LINT, _DOCS, CONSTRUCT_ALLOW_CLAUDE_PUSH). Nothing writes to
516
+ // ~/.construct/audit/prepush-bypass.log, so any entries there are stale
517
+ // and would otherwise surface the warning indefinitely. The file itself
518
+ // is harmless — next cleanup tick may sweep it.
538
519
 
539
520
  // Embed daemon log size. The daemon's own scheduler rotates every
540
521
  // minute at 50MB, but a freshly-upgraded install with a 34GB legacy
@@ -671,16 +652,12 @@ async function cmdDoctor() {
671
652
  path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code - Insiders'),
672
653
  ];
673
654
  if (vscodeAppSupportRoots.some((r) => fs.existsSync(r))) {
674
- // VS Code is installed but has no settings.json the user needs to create
675
- // one manually or run `construct sync` after VS Code sync support lands.
655
+ // VS Code installed check settings.json presence as a green/info signal.
656
+ // Absence is not a doctor finding VS Code adapter support hasn't landed
657
+ // yet, so "create one or wait for support" isn't actionable. The line is
658
+ // suppressed entirely when missing; doctor surfaces facts, not roadmap.
676
659
  const vscodeSettingsOk = vscodeSettingsPaths.some((candidate) => fs.existsSync(candidate));
677
- add(
678
- vscodeSettingsOk
679
- ? 'VS Code settings file'
680
- : 'VS Code settings missing — create settings.json or wait for `construct sync` VS Code support',
681
- vscodeSettingsOk,
682
- true,
683
- );
660
+ if (vscodeSettingsOk) add('VS Code settings file', true, true);
684
661
  }
685
662
  add('User config ready', fs.existsSync(getUserEnvPath(HOME)) || fs.existsSync(path.join(HOME, '.construct')), true);
686
663
  add('skills/ directory', fs.existsSync(path.join(ROOT_DIR, 'skills')));
@@ -917,11 +894,15 @@ async function cmdDoctor() {
917
894
  }
918
895
 
919
896
  try {
920
- const { recentViolations } = await import('../lib/contracts/violation-log.mjs');
897
+ const { recentViolations, violationLogPath } = await import('../lib/contracts/violation-log.mjs');
921
898
  const violations = recentViolations({ windowMs: 24 * 60 * 60 * 1000 });
899
+ // Path is project-scoped (.cx/contract-violations.jsonl in cwd when
900
+ // inside a project, ~/.cx/ otherwise). Print the resolved one so
901
+ // users find the real file, not a stale ~/.cx/ reference.
902
+ const violationPath = violationLogPath();
922
903
  const label = violations.length === 0
923
904
  ? 'Contract violations (none in last 24h)'
924
- : `Contract violations (${violations.length} in last 24h — see ~/.cx/contract-violations.jsonl)`;
905
+ : `Contract violations (${violations.length} in last 24h — see ${violationPath})`;
925
906
  add(label, violations.length === 0, true);
926
907
  } catch {
927
908
  add('Contract violations', false, true);
@@ -1104,15 +1085,77 @@ async function cmdDoctor() {
1104
1085
  if (failCount > 0) process.exit(1);
1105
1086
  }
1106
1087
 
1107
- async function cmdUp() {
1088
+ // Resolve which services `construct dev` should start. Returns null (start
1089
+ // everything) unless --only= or --select narrows it. Unknown --only keys fail
1090
+ // loud rather than silently starting the wrong set; --select on a non-TTY is an
1091
+ // error pointing at --only so scripts get a deterministic path.
1092
+ async function resolveServiceSelection(args, selectable) {
1093
+ const validKeys = new Set(selectable.map((s) => s.key));
1094
+ const onlyArg = args.find((a) => a.startsWith('--only='));
1095
+
1096
+ if (onlyArg) {
1097
+ const keys = onlyArg.slice('--only='.length).split(',').map((k) => k.trim()).filter(Boolean);
1098
+ const unknown = keys.filter((k) => !validKeys.has(k));
1099
+ if (unknown.length) {
1100
+ errorln(`Unknown service(s) for --only: ${unknown.join(', ')}`);
1101
+ errorln(`Valid services: ${[...validKeys].join(', ')}`);
1102
+ process.exit(1);
1103
+ }
1104
+ if (keys.length === 0) {
1105
+ errorln('--only requires at least one service.');
1106
+ process.exit(1);
1107
+ }
1108
+ return new Set(keys);
1109
+ }
1110
+
1111
+ if (args.includes('--select')) {
1112
+ if (!process.stdin.isTTY) {
1113
+ errorln('--select needs an interactive terminal. Use --only=<a,b,c> in scripts.');
1114
+ errorln(`Valid services: ${[...validKeys].join(', ')}`);
1115
+ process.exit(1);
1116
+ }
1117
+ const { multiSelect } = await import('../lib/tty-prompts.mjs');
1118
+ try {
1119
+ const chosen = await multiSelect({
1120
+ title: 'Start which services?',
1121
+ instructions: 'Pressure Guard and Doctor always run. Space toggles, Enter confirms.',
1122
+ options: selectable.map((s) => ({ label: s.label, value: s.key, description: s.description, checked: true })),
1123
+ });
1124
+ if (!chosen.length) {
1125
+ errorln('No services selected — nothing to start.');
1126
+ process.exit(1);
1127
+ }
1128
+ return new Set(chosen);
1129
+ } catch {
1130
+ errorln('Service selection canceled.');
1131
+ process.exit(1);
1132
+ }
1133
+ }
1134
+
1135
+ return null;
1136
+ }
1137
+
1138
+ async function cmdUp(args = []) {
1139
+ const { SELECTABLE_SERVICES } = await import('../lib/service-manager.mjs');
1140
+
1141
+ // --only=a,b,c picks services non-interactively; --select opens a checklist.
1142
+ // Both resolve to a Set of service keys (null = start everything).
1143
+
1144
+ const selected = await resolveServiceSelection(args, SELECTABLE_SERVICES);
1145
+
1146
+ const { startOpLog } = await import('../lib/op-log.mjs');
1147
+ const opLog = startOpLog('dev', { homeDir: HOME });
1148
+ opLog.event('selection', { services: selected ? [...selected] : 'all' });
1149
+
1108
1150
  // Auto-magic health check — catch missing prerequisites before starting services
1109
1151
  const { quickHealthCheck, detectCredentials, resolveCredentials } = await import('../lib/health-check.mjs');
1110
-
1152
+
1111
1153
  // Resolve credentials from env, .env files, and 1Password before starting services
1112
1154
  const resolvedCreds = await resolveCredentials({ homeDir: HOME });
1113
-
1155
+
1114
1156
  const health = await quickHealthCheck({ homeDir: HOME, showCredentials: true });
1115
-
1157
+ opLog.event('prereq', { ok: health.ok, missing: health.missing ?? [] });
1158
+
1116
1159
  if (!health.ok) {
1117
1160
  errorln('Prerequisites missing for starting services:');
1118
1161
  for (const item of health.missing) {
@@ -1120,10 +1163,12 @@ async function cmdUp() {
1120
1163
  }
1121
1164
  println('');
1122
1165
  info('Run `construct install --yes` to install missing dependencies.');
1166
+ opLog.close('failed', { reason: 'prerequisites-missing' });
1123
1167
  process.exit(1);
1124
1168
  }
1125
-
1126
- const { results, recovery } = await startServices({ rootDir: ROOT_DIR, homeDir: HOME });
1169
+
1170
+ const { results, recovery } = await startServices({ rootDir: ROOT_DIR, homeDir: HOME, selected });
1171
+ opLog.event('services', { results: results.map((r) => ({ name: r.name, status: r.status })) });
1127
1172
  for (const svc of results) {
1128
1173
  const label = svc.url ? `${svc.name} → ${svc.url}` : svc.name;
1129
1174
  if (svc.status === 'started') ok(svc.note ? `${label} (${svc.note})` : label);
@@ -1161,8 +1206,10 @@ async function cmdUp() {
1161
1206
  try {
1162
1207
  const { runEmbedCli } = await import('../lib/embed/cli.mjs');
1163
1208
  await runEmbedCli(['start'], { rootDir: ROOT_DIR });
1209
+ opLog.event('embed', { status: 'started' });
1164
1210
  } catch (err) {
1165
1211
  warn(`embed start failed: ${err.message} — check ~/.cx/runtime/embed-daemon.log`);
1212
+ opLog.warn('embed', { status: 'failed', error: err.message });
1166
1213
  }
1167
1214
  }
1168
1215
 
@@ -1209,6 +1256,9 @@ async function cmdUp() {
1209
1256
  println('Dashboard: http://127.0.0.1:4242');
1210
1257
  println('Run \x1b[32mconstruct\x1b[0m for commands');
1211
1258
  println('');
1259
+
1260
+ const failed = results.filter((r) => r.status === 'error' || r.status === 'failed');
1261
+ opLog.close(failed.length ? 'degraded' : 'ok', { failed: failed.map((r) => r.name) });
1212
1262
  }
1213
1263
 
1214
1264
  async function cmdDown() {
@@ -1337,7 +1387,14 @@ async function cmdReview(args) {
1337
1387
  process.exit(1);
1338
1388
  }
1339
1389
 
1340
- async function cmdInit(args) { runNodeScript(path.join(ROOT_DIR, 'lib', 'init-unified.mjs'), args); }
1390
+ async function cmdInit(args) {
1391
+ const { startOpLog } = await import('../lib/op-log.mjs');
1392
+ const opLog = startOpLog('init', { homeDir: HOME });
1393
+ opLog.event('args', { args });
1394
+ const status = runNodeScript(path.join(ROOT_DIR, 'lib', 'init-unified.mjs'), args, {}, { exitOnError: false });
1395
+ opLog.close(status === 0 ? 'ok' : 'failed', { status });
1396
+ if (status !== 0) process.exit(status);
1397
+ }
1341
1398
  async function cmdDocsVerify(args) { runNodeScript(path.join(ROOT_DIR, 'lib', 'docs-verify.mjs'), args); }
1342
1399
  async function cmdInitUpdate(args) {
1343
1400
  runNodeScript(path.join(ROOT_DIR, 'lib', 'init-update.mjs'), args);
@@ -1780,7 +1837,22 @@ async function cmdHosts() {
1780
1837
  printHostCapabilities();
1781
1838
  }
1782
1839
  }
1783
- async function cmdSetup(args) { await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME }); }
1840
+ async function cmdSetup(args) {
1841
+ const { startOpLog } = await import('../lib/op-log.mjs');
1842
+ const opLog = startOpLog('install', { homeDir: HOME });
1843
+ opLog.event('args', { args });
1844
+ try {
1845
+ await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME });
1846
+ opLog.close('ok');
1847
+ } catch (err) {
1848
+ opLog.error('setup', { error: err.message });
1849
+ opLog.close('failed');
1850
+ errorln(`\nSetup did not complete: ${err.message}`);
1851
+ errorln(' -> Diagnose with: construct doctor');
1852
+ errorln(' -> Re-run setup: construct install (add --reconfigure to re-prompt for services)');
1853
+ process.exit(1);
1854
+ }
1855
+ }
1784
1856
 
1785
1857
  function printConfigModeHelp() {
1786
1858
  println('Construct config — inspect and set deployment posture.');
@@ -1874,6 +1946,45 @@ function formatIntakeTable(rows, { intakeQueueLabel, signalNoun } = { intakeQueu
1874
1946
  for (const row of rows) println(fmt(row));
1875
1947
  }
1876
1948
 
1949
+ // Embedded triage/planning surface — classifies an artifact and returns a
1950
+ // role-aware plan with no enqueue and no durable write. Shared by
1951
+ // `intake classify` and `graph recommend`. Input comes from --text, --file, or
1952
+ // stdin; --no-enqueue is accepted for explicitness (this path never enqueues).
1953
+
1954
+ async function cmdEmbeddedTriage(args) {
1955
+ const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
1956
+ const fileArg = flag('--file');
1957
+ let input = flag('--text') || '';
1958
+ let ingestion = null;
1959
+ if (!input && fileArg) {
1960
+ const { resolveInput } = await import('../lib/embedded-contract/ingest.mjs');
1961
+ const resolved = await resolveInput({ filePath: fileArg });
1962
+ input = resolved.text;
1963
+ ingestion = resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion;
1964
+ }
1965
+ if (!input && !process.stdin.isTTY) {
1966
+ input = fs.readFileSync(0, 'utf8');
1967
+ }
1968
+ if (!input.trim() && !ingestion?.error) {
1969
+ errorln('Usage: construct intake classify --json [--text "..." | --file <path> | <stdin>] [--source <path>] [--artifact-type <t>] [--available-roles a,b]');
1970
+ process.exit(1);
1971
+ }
1972
+ const request = {
1973
+ input,
1974
+ sourcePath: flag('--source') || (fileArg || ''),
1975
+ ingestion,
1976
+ artifactType: flag('--artifact-type'),
1977
+ domain: flag('--domain'),
1978
+ desiredOutcome: flag('--desired-outcome'),
1979
+ constraints: flag('--constraints')?.split(',').map((s) => s.trim()).filter(Boolean),
1980
+ availableRoles: flag('--available-roles')?.split(',').map((s) => s.trim()).filter(Boolean),
1981
+ };
1982
+ const { recommendPlan } = await import('../lib/embedded-contract/triage.mjs');
1983
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
1984
+ const envelope = wrapContractResult(recommendPlan(request), { surface: 'cli' });
1985
+ println(JSON.stringify(envelope, null, 2));
1986
+ }
1987
+
1877
1988
  async function cmdIntake(args) {
1878
1989
  const sub = args[0];
1879
1990
 
@@ -1882,6 +1993,10 @@ async function cmdIntake(args) {
1882
1993
  return;
1883
1994
  }
1884
1995
 
1996
+ if (sub === 'classify') {
1997
+ return await cmdEmbeddedTriage(args.slice(1));
1998
+ }
1999
+
1885
2000
  const { createIntakeQueue } = await import('../lib/intake/queue.mjs');
1886
2001
  const { getRebrand } = await import('../lib/profiles/rebrand.mjs');
1887
2002
  const cwd = process.cwd();
@@ -2456,9 +2571,49 @@ async function cmdSkillsUsage(sub, args) {
2456
2571
  }
2457
2572
 
2458
2573
  if (sub === 'correlate-quality') {
2459
- println('Quality correlation requires Postgres (CONSTRUCT_DB_URL).');
2460
- println('Local JSONL does not carry cx_score data.');
2461
- println('Run with Postgres configured for full correlation output.');
2574
+ // Queries the construct_skill_quality_correlation view (db/schema/010_cx_scores.sql)
2575
+ // which aggregates cx_score values by session_id × skill_id over the past 90 days.
2576
+ // Falls back to a clear "no data" message when the DB is unreachable or the
2577
+ // view is empty rather than printing an empty table.
2578
+
2579
+ const sqlClient = createSqlClient(process.env);
2580
+ if (!sqlClient) {
2581
+ println('Quality correlation requires Postgres (DATABASE_URL or CONSTRUCT_DB_URL).');
2582
+ println('Local JSONL does not carry cx_score data.');
2583
+ println('Run with Postgres configured for full correlation output.');
2584
+ return;
2585
+ }
2586
+ try {
2587
+ const rows = await sqlClient`
2588
+ select skill_id, sessions, score_count, mean_score, median_score, p10_score, p90_score, last_scored_at
2589
+ from construct_skill_quality_correlation
2590
+ order by sessions desc, mean_score desc nulls last
2591
+ limit 50
2592
+ `;
2593
+ if (!rows || rows.length === 0) {
2594
+ println('No correlation data yet. The view aggregates cx_score values from the past 90 days against');
2595
+ println('skill invocations from the same sessions. Once cxScore() runs in a session that also invoked');
2596
+ println('a skill, rows appear here. Verify scoring with: construct telemetry query latency --limit=5');
2597
+ return;
2598
+ }
2599
+ const w = Math.max(15, ...rows.map((r) => String(r.skill_id || '').length));
2600
+ println(`${'skill_id'.padEnd(w)} sessions scores mean median p10 p90 last_scored_at`);
2601
+ println('-'.repeat(w + 70));
2602
+ for (const r of rows) {
2603
+ const pad = (v, n) => String(v ?? '').padStart(n);
2604
+ println(
2605
+ `${String(r.skill_id || '').padEnd(w)} ${pad(r.sessions, 8)} ${pad(r.score_count, 6)} ` +
2606
+ `${pad(r.mean_score, 5)} ${pad(r.median_score, 6)} ${pad(r.p10_score, 5)} ${pad(r.p90_score, 5)} ` +
2607
+ `${r.last_scored_at ? new Date(r.last_scored_at).toISOString().slice(0, 19) : ''}`,
2608
+ );
2609
+ }
2610
+ } catch (err) {
2611
+ errorln(`Quality correlation query failed: ${err.message}`);
2612
+ errorln('If the construct_skill_quality_correlation view is missing, apply db/schema/010_cx_scores.sql.');
2613
+ process.exit(1);
2614
+ } finally {
2615
+ await closeSqlClient(sqlClient);
2616
+ }
2462
2617
  return;
2463
2618
  }
2464
2619
 
@@ -2594,14 +2749,18 @@ async function cmdIntakeMetrics() {
2594
2749
  }
2595
2750
 
2596
2751
  async function cmdIntakeIntegrate(id, args, cwd) {
2597
- if (!id) { errorln('Usage: construct intake integrate <id> <github|jira|confluence>'); process.exit(1); }
2752
+ if (!id) { errorln('Usage: construct intake integrate <id> <github|jira|confluence> [--publish-issues]'); process.exit(1); }
2598
2753
 
2599
2754
  const system = args[0];
2600
2755
  if (!system || !['github', 'jira', 'confluence'].includes(system)) {
2601
- errorln('Usage: construct intake integrate <id> <github|jira|confluence>');
2756
+ errorln('Usage: construct intake integrate <id> <github|jira|confluence> [--publish-issues]');
2602
2757
  errorln('Available systems: github, jira, confluence');
2603
2758
  process.exit(1);
2604
2759
  }
2760
+ // --publish-issues unlocks the demo-source gate (CONSTRUCT_DEMO env or
2761
+ // packet sourcePath under tests/fixtures/ or .cx/intake/demo/). Required
2762
+ // before any demo/fixture intake can publish a real external ticket.
2763
+ const publishDemo = args.includes('--publish-issues');
2605
2764
 
2606
2765
  const { createIntakeQueue } = await import('../lib/intake/queue.mjs');
2607
2766
  const queue = createIntakeQueue(cwd, process.env);
@@ -2613,10 +2772,13 @@ async function cmdIntakeIntegrate(id, args, cwd) {
2613
2772
  if (system === 'github') {
2614
2773
  const { createGitHubIssue, tagIntakeWithExternalRef } = await import('../lib/integrations/intake-integrations.mjs');
2615
2774
  info(`Creating GitHub issue from intake ${id}...`);
2616
- result = await createGitHubIssue(entry);
2775
+ result = await createGitHubIssue(entry, { publishDemo });
2617
2776
  if (result.ok) {
2618
2777
  ok(`GitHub issue created: ${result.externalUrl}`);
2619
2778
  tagIntakeWithExternalRef(cwd, id, 'github', result.externalUrl, result.externalId);
2779
+ } else if (result.skipped === 'demo-source') {
2780
+ warn(`Skipped: ${result.error}`);
2781
+ process.exit(0);
2620
2782
  } else {
2621
2783
  errorln(`Failed: ${result.error}`);
2622
2784
  process.exit(1);
@@ -2927,6 +3089,10 @@ async function cmdGraph(args) {
2927
3089
  const sub = args[0];
2928
3090
  if (!sub || sub === '--help' || sub === '-h') { printGraphHelp(); return; }
2929
3091
 
3092
+ if (sub === 'recommend') {
3093
+ return await cmdEmbeddedTriage(args.slice(1));
3094
+ }
3095
+
2930
3096
  const { FilesystemTaskGraphStore } = await import('../lib/task-graph/store.mjs');
2931
3097
  const { generateTaskGraphFromTriage } = await import('../lib/task-graph/generate.mjs');
2932
3098
  const cwd = process.cwd();
@@ -3160,6 +3326,23 @@ async function cmdValidate() {
3160
3326
  }
3161
3327
 
3162
3328
  async function cmdModels(args) {
3329
+ if (args[0] === 'resolve') {
3330
+ const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
3331
+ const { resolveEmbeddedModel } = await import('../lib/embedded-contract/model-resolve.mjs');
3332
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
3333
+ const request = {
3334
+ workflowType: flag('--workflow-type'),
3335
+ requestedTier: flag('--tier'),
3336
+ host: flag('--host'),
3337
+ hostModel: flag('--host-model'),
3338
+ hostProvider: flag('--host-provider'),
3339
+ capabilities: flag('--capabilities')?.split(',').map((s) => s.trim()).filter(Boolean),
3340
+ allowCrossProviderFallback: args.includes('--allow-cross-provider'),
3341
+ };
3342
+ const envelope = wrapContractResult(resolveEmbeddedModel(request), { surface: 'cli' });
3343
+ println(JSON.stringify(envelope, null, 2));
3344
+ return;
3345
+ }
3163
3346
  const tier = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1] ?? '';
3164
3347
  const setModel = args.find((arg) => arg.startsWith('--set='))?.split('=')[1] ?? '';
3165
3348
  const envPath = getUserEnvPath(HOME);
@@ -4557,9 +4740,56 @@ async function cmdProviders(args) {
4557
4740
  process.exit(1);
4558
4741
  }
4559
4742
 
4743
+ // Capability discovery surface — a read-only, secret-free description of the
4744
+ // installed Construct's capabilities, for embedding applications.
4745
+
4746
+ async function cmdCapability(args) {
4747
+ const sub = args[0];
4748
+ if (sub && sub !== 'describe' && !sub.startsWith('--')) {
4749
+ errorln(`Unknown capability subcommand: ${sub}. Available: describe`);
4750
+ process.exit(1);
4751
+ }
4752
+ const { buildCapabilityContract } = await import('../lib/embedded-contract/capability.mjs');
4753
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
4754
+ const envelope = wrapContractResult(buildCapabilityContract({ rootDir: ROOT_DIR }), { surface: 'cli' });
4755
+ println(JSON.stringify(envelope, null, 2));
4756
+ }
4757
+
4560
4758
  async function cmdWorkflow(args) {
4561
4759
  const sub = args[0] || 'list';
4562
4760
 
4761
+ if (sub === 'invoke') {
4762
+ const rest = args.slice(1);
4763
+ const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
4764
+ let input = flag('--text') || '';
4765
+ const fileArg = flag('--file');
4766
+ let ingestion = null;
4767
+ if (!input && fileArg) {
4768
+ const { resolveInput } = await import('../lib/embedded-contract/ingest.mjs');
4769
+ const resolved = await resolveInput({ filePath: fileArg });
4770
+ input = resolved.text;
4771
+ ingestion = resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion;
4772
+ }
4773
+ if (!input && !process.stdin.isTTY) input = fs.readFileSync(0, 'utf8');
4774
+ const request = {
4775
+ workflowType: flag('--workflow-type'),
4776
+ input,
4777
+ ingestion,
4778
+ roleStrategy: flag('--role-strategy'),
4779
+ requestedRoles: flag('--roles')?.split(',').map((s) => s.trim()).filter(Boolean),
4780
+ approvalMode: flag('--approval-mode'),
4781
+ trace: !rest.includes('--no-trace'),
4782
+ host: flag('--host'),
4783
+ hostModel: flag('--host-model'),
4784
+ hostProvider: flag('--host-provider'),
4785
+ };
4786
+ const { invokeWorkflow } = await import('../lib/embedded-contract/workflow-invoke.mjs');
4787
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
4788
+ const envelope = wrapContractResult(await invokeWorkflow(request), { surface: 'cli' });
4789
+ println(JSON.stringify(envelope, null, 2));
4790
+ return;
4791
+ }
4792
+
4563
4793
  if (sub === 'list') {
4564
4794
  const { existsSync, readdirSync } = await import('node:fs');
4565
4795
  const { join } = await import('node:path');
@@ -4937,6 +5167,7 @@ const handlers = new Map([
4937
5167
  ['creds', cmdCreds],
4938
5168
  ['providers', cmdProviders],
4939
5169
  ['workflow', cmdWorkflow],
5170
+ ['capability', cmdCapability],
4940
5171
  ['tags', cmdTags],
4941
5172
  ['scheduler', cmdScheduler],
4942
5173
  ['lint:research', cmdLintResearch],
@@ -4980,13 +5211,11 @@ const handlers = new Map([
4980
5211
  ['roles:list', async (args) => {
4981
5212
  const { listRoles, formatRoleList } = await import('../lib/roles/catalog.mjs');
4982
5213
  const json = args.includes('--json');
4983
- const departments = args.includes('--departments');
4984
- const consolidated = args.includes('--consolidated');
4985
-
5214
+
4986
5215
  if (json) {
4987
- println(JSON.stringify(listRoles({ departments, consolidated }), null, 2));
5216
+ println(JSON.stringify(listRoles(), null, 2));
4988
5217
  } else {
4989
- process.stdout.write(formatRoleList({ departments, consolidated }));
5218
+ process.stdout.write(formatRoleList());
4990
5219
  }
4991
5220
  }],
4992
5221
  ['roles:set', async (args) => {
@@ -5850,6 +6079,13 @@ if (command === '--help' || command === '-h' || command === 'help') {
5850
6079
  process.exit(0);
5851
6080
  }
5852
6081
 
6082
+ // --all / -a as a standalone flag — equivalent to `help --all`. Without
6083
+ // this, `construct --all` (which the help footer advertises) 404s.
6084
+ if (command === '--all' || command === '-a') {
6085
+ usage({ all: true });
6086
+ process.exit(0);
6087
+ }
6088
+
5853
6089
  // Version flags → print version and exit
5854
6090
  if (command === '--version' || command === '-V') {
5855
6091
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8'));
@@ -5860,11 +6096,53 @@ if (command === '--version' || command === '-V') {
5860
6096
  const handler = handlers.get(command);
5861
6097
  if (!handler) {
5862
6098
  errorln(`Unknown command: ${command}`);
6099
+ // Three-tier suggestion: prefix match → substring match → Levenshtein
6100
+ // distance ≤ 2. Catches single-letter typos, missing-letter typos, and
6101
+ // transpositions ('sycn' → 'sync') without dragging in a full fuzzy lib.
6102
+ const known = CLI_COMMANDS.filter((c) => !c.internal).map((c) => c.name);
6103
+ const editDistance = (a, b) => {
6104
+ if (a === b) return 0;
6105
+ if (!a.length || !b.length) return Math.max(a.length, b.length);
6106
+ const row = Array(b.length + 1).fill(0).map((_, i) => i);
6107
+ for (let i = 1; i <= a.length; i++) {
6108
+ let prev = i;
6109
+ for (let j = 1; j <= b.length; j++) {
6110
+ const cur = a[i - 1] === b[j - 1]
6111
+ ? row[j - 1]
6112
+ : 1 + Math.min(row[j - 1], row[j], prev);
6113
+ row[j - 1] = prev;
6114
+ prev = cur;
6115
+ }
6116
+ row[b.length] = prev;
6117
+ }
6118
+ return row[b.length];
6119
+ };
6120
+ let suggestion = known.find((n) => n.startsWith(command)) ||
6121
+ known.find((n) => n.includes(command));
6122
+ if (!suggestion) {
6123
+ let bestDist = 3;
6124
+ for (const name of known) {
6125
+ const d = editDistance(command, name);
6126
+ if (d < bestDist) { bestDist = d; suggestion = name; }
6127
+ }
6128
+ }
6129
+ if (suggestion) errorln(`Did you mean: construct ${suggestion}?`);
5863
6130
  println('');
5864
6131
  println(`${COLORS.dim}Run 'construct --help' for available commands${COLORS.reset}`);
5865
6132
  process.exit(1);
5866
6133
  }
5867
6134
 
6135
+ // Per-command --help / -h intercept. Critical safety: without this guard,
6136
+ // commands like `dev`, `stop`, `init`, `uninstall` silently ignore --help
6137
+ // and execute (running `construct stop --help` actually stops services).
6138
+ // Every command — including internal ones — gets the same shape via
6139
+ // formatCommandHelp(); per-command handlers need no opt-in.
6140
+
6141
+ if (rest.includes('--help') || rest.includes('-h')) {
6142
+ process.stdout.write(formatCommandHelp(command, { colors: true }));
6143
+ process.exit(0);
6144
+ }
6145
+
5868
6146
  // Self-maintenance on version change. Skipped for high-frequency hook calls
5869
6147
  // (the harness invokes `construct hook <name>` many times per session and
5870
6148
  // must stay fast). When the installed version differs from the stamp at