@geraldmaron/construct 1.0.12 → 1.0.15

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 (337) hide show
  1. package/README.md +14 -2
  2. package/bin/construct +262 -14
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/commands/build/feature.md +0 -6
  5. package/commands/build/fix.md +0 -6
  6. package/commands/design/access.md +0 -6
  7. package/commands/design/flow.md +0 -6
  8. package/commands/design/ui.md +0 -6
  9. package/commands/measure/experiment.md +0 -6
  10. package/commands/measure/metrics.md +0 -6
  11. package/commands/measure/results.md +0 -6
  12. package/commands/plan/api.md +0 -6
  13. package/commands/plan/challenge.md +0 -6
  14. package/commands/plan/decide.md +0 -6
  15. package/commands/plan/feature.md +0 -7
  16. package/commands/plan/requirements.md +0 -6
  17. package/commands/remember/context.md +0 -6
  18. package/commands/remember/handoff.md +0 -6
  19. package/commands/remember/runbook.md +0 -6
  20. package/commands/review/code.md +0 -6
  21. package/commands/review/quality.md +0 -6
  22. package/commands/review/security.md +0 -6
  23. package/commands/ship/ready.md +0 -6
  24. package/commands/ship/release.md +0 -6
  25. package/commands/ship/status.md +0 -6
  26. package/commands/understand/docs.md +0 -6
  27. package/commands/understand/research.md +0 -6
  28. package/commands/understand/this.md +0 -6
  29. package/commands/understand/why.md +0 -6
  30. package/commands/work/clean.md +0 -6
  31. package/commands/work/drive.md +0 -6
  32. package/commands/work/optimize-prompts.md +0 -6
  33. package/commands/work/parallel-review.md +0 -6
  34. package/lib/auto-docs.mjs +11 -7
  35. package/lib/beads-client.mjs +19 -1
  36. package/lib/comment-lint.mjs +5 -1
  37. package/lib/context-state.mjs +15 -3
  38. package/lib/contracts/validate.mjs +54 -10
  39. package/lib/contracts/violation-log.mjs +154 -0
  40. package/lib/dashboard-static.mjs +7 -4
  41. package/lib/doc-stamp.mjs +16 -0
  42. package/lib/docs-verify.mjs +1 -8
  43. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  44. package/lib/document-extract/docling-client.mjs +114 -0
  45. package/lib/document-extract/docling-sidecar.py +122 -0
  46. package/lib/document-extract/whisper-client.mjs +79 -0
  47. package/lib/document-extract.mjs +73 -8
  48. package/lib/document-ingest.mjs +26 -4
  49. package/lib/embed/daemon.mjs +26 -1
  50. package/lib/embed/docs-lifecycle.mjs +19 -0
  51. package/lib/embed/inbox.mjs +85 -2
  52. package/lib/embed/recommendation-store.mjs +29 -0
  53. package/lib/flavors/loader.mjs +1 -1
  54. package/lib/gates-audit.mjs +18 -12
  55. package/lib/handoffs/contract.mjs +2 -2
  56. package/lib/hooks/_lib/input.mjs +52 -0
  57. package/lib/hooks/adaptive-lint.mjs +4 -0
  58. package/lib/hooks/agent-tracker.mjs +59 -15
  59. package/lib/hooks/audit-reads.mjs +83 -42
  60. package/lib/hooks/audit-trail.mjs +28 -18
  61. package/lib/hooks/bash-output-logger.mjs +8 -2
  62. package/lib/hooks/block-no-verify.mjs +4 -0
  63. package/lib/hooks/ci-status-check.mjs +4 -0
  64. package/lib/hooks/comment-lint.mjs +6 -4
  65. package/lib/hooks/config-protection.mjs +4 -0
  66. package/lib/hooks/context-watch.mjs +4 -0
  67. package/lib/hooks/context-window-recovery.mjs +4 -0
  68. package/lib/hooks/dep-audit.mjs +12 -5
  69. package/lib/hooks/doc-coupling-check.mjs +5 -1
  70. package/lib/hooks/edit-accumulator.mjs +25 -10
  71. package/lib/hooks/edit-error-recovery.mjs +4 -0
  72. package/lib/hooks/edit-guard.mjs +4 -0
  73. package/lib/hooks/guard-bash.mjs +4 -0
  74. package/lib/hooks/mcp-audit.mjs +4 -0
  75. package/lib/hooks/mcp-health-check.mjs +4 -0
  76. package/lib/hooks/model-fallback.mjs +7 -11
  77. package/lib/hooks/policy-engine.mjs +4 -0
  78. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  79. package/lib/hooks/post-merge-tracking.mjs +82 -0
  80. package/lib/hooks/pre-compact.mjs +4 -0
  81. package/lib/hooks/pre-push-gate.mjs +84 -231
  82. package/lib/hooks/proactive-activation.mjs +5 -2
  83. package/lib/hooks/readme-age-check.mjs +4 -0
  84. package/lib/hooks/registry-sync.mjs +35 -20
  85. package/lib/hooks/rule-verifier.mjs +3 -0
  86. package/lib/hooks/scan-secrets.mjs +4 -0
  87. package/lib/hooks/session-optimize.mjs +4 -0
  88. package/lib/hooks/session-reflect.mjs +4 -0
  89. package/lib/hooks/session-start.mjs +49 -2
  90. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  91. package/lib/hooks/stop-notify.mjs +13 -2
  92. package/lib/hooks/stop-typecheck.mjs +4 -0
  93. package/lib/hooks/test-watch.mjs +8 -4
  94. package/lib/ingest/chunker.mjs +94 -0
  95. package/lib/ingest/pipeline.mjs +53 -0
  96. package/lib/ingest/store.mjs +82 -0
  97. package/lib/init-unified.mjs +90 -23
  98. package/lib/intake/attribution.mjs +77 -0
  99. package/lib/intake/intake-config.mjs +3 -0
  100. package/lib/intake/manifest.mjs +107 -0
  101. package/lib/intake/poll-lock.mjs +136 -0
  102. package/lib/intake/prepare.mjs +42 -4
  103. package/lib/knowledge/search.mjs +12 -0
  104. package/lib/logging/rotate.mjs +394 -0
  105. package/lib/mcp/server.mjs +142 -1
  106. package/lib/mcp/tools/project.mjs +2 -2
  107. package/lib/mcp/tools/skills.mjs +6 -3
  108. package/lib/mcp/tools/telemetry.mjs +1 -1
  109. package/lib/mcp/tools/workflow.mjs +6 -2
  110. package/lib/observation-store.mjs +14 -5
  111. package/lib/ollama-manager.mjs +32 -28
  112. package/lib/opencode-config.mjs +10 -3
  113. package/lib/orchestration/routing-tables.mjs +176 -0
  114. package/lib/orchestration-policy.mjs +18 -106
  115. package/lib/parity.mjs +48 -7
  116. package/lib/profiles/lifecycle.mjs +19 -1
  117. package/lib/project-init-shared.mjs +11 -5
  118. package/lib/project-root.mjs +104 -0
  119. package/lib/prompt-composer.js +11 -1
  120. package/lib/roles/catalog.mjs +1 -1
  121. package/lib/roles/event-bus.mjs +29 -9
  122. package/lib/roles/router.mjs +8 -7
  123. package/lib/runtime/uv-bootstrap.mjs +129 -0
  124. package/lib/runtime/whisper-bootstrap.mjs +102 -0
  125. package/lib/server/index.mjs +53 -10
  126. package/lib/server/static/index.html +1 -15
  127. package/lib/specialists/postconditions.mjs +1 -1
  128. package/lib/status.mjs +1 -1
  129. package/lib/storage/backup.mjs +2 -2
  130. package/lib/sync/skill-frontmatter.mjs +113 -21
  131. package/lib/telemetry/intent-verifications.mjs +16 -3
  132. package/lib/telemetry/skill-calls.mjs +12 -3
  133. package/lib/tracking-surfaces.mjs +377 -0
  134. package/lib/validators/skills.mjs +86 -54
  135. package/lib/worker/trace.mjs +19 -2
  136. package/package.json +10 -6
  137. package/personas/construct.md +4 -7
  138. package/platforms/claude/settings.template.json +29 -28
  139. package/rules/common/beads-hygiene.md +3 -9
  140. package/rules/common/code-review.md +3 -6
  141. package/rules/common/coding-style.md +3 -6
  142. package/rules/common/comments.md +3 -7
  143. package/rules/common/commit-approval.md +3 -6
  144. package/rules/common/cx-agent-routing.md +3 -7
  145. package/rules/common/cx-skill-routing.md +3 -3
  146. package/rules/common/doc-ownership.md +3 -8
  147. package/rules/common/efficiency.md +3 -8
  148. package/rules/common/framing.md +3 -8
  149. package/rules/common/git-workflow.md +3 -5
  150. package/rules/common/no-fabrication.md +4 -12
  151. package/rules/common/patterns.md +3 -5
  152. package/rules/common/release-gates.md +3 -8
  153. package/rules/common/research.md +3 -8
  154. package/rules/common/review-before-change.md +3 -9
  155. package/rules/common/security.md +3 -6
  156. package/rules/common/skill-composition.md +3 -7
  157. package/rules/common/testing.md +3 -6
  158. package/rules/golang/coding-style.md +1 -5
  159. package/rules/golang/hooks.md +1 -5
  160. package/rules/golang/patterns.md +1 -5
  161. package/rules/golang/security.md +1 -5
  162. package/rules/golang/testing.md +1 -5
  163. package/rules/python/coding-style.md +1 -5
  164. package/rules/python/hooks.md +1 -5
  165. package/rules/python/patterns.md +1 -5
  166. package/rules/python/security.md +1 -5
  167. package/rules/python/testing.md +1 -5
  168. package/rules/swift/coding-style.md +1 -5
  169. package/rules/swift/hooks.md +1 -5
  170. package/rules/swift/patterns.md +1 -5
  171. package/rules/swift/security.md +1 -5
  172. package/rules/swift/testing.md +1 -5
  173. package/rules/typescript/coding-style.md +1 -5
  174. package/rules/typescript/hooks.md +1 -5
  175. package/rules/typescript/patterns.md +1 -5
  176. package/rules/typescript/security.md +1 -5
  177. package/rules/typescript/testing.md +1 -5
  178. package/rules/web/coding-style.md +3 -5
  179. package/rules/web/design-quality.md +3 -5
  180. package/rules/web/hooks.md +3 -5
  181. package/rules/web/patterns.md +3 -5
  182. package/rules/web/performance.md +3 -5
  183. package/rules/web/security.md +3 -5
  184. package/rules/web/testing.md +3 -5
  185. package/scripts/sync-specialists.mjs +269 -75
  186. package/skills/ai/agent-dev.md +4 -5
  187. package/skills/ai/llm-security.md +4 -5
  188. package/skills/ai/ml-ops.md +4 -5
  189. package/skills/ai/orchestration-workflow.md +4 -5
  190. package/skills/ai/prompt-and-eval.md +4 -5
  191. package/skills/ai/prompt-optimizer.md +4 -6
  192. package/skills/ai/rag-system.md +4 -5
  193. package/skills/architecture/api-design.md +4 -5
  194. package/skills/architecture/caching.md +4 -5
  195. package/skills/architecture/cloud-native.md +4 -5
  196. package/skills/architecture/message-queue.md +4 -5
  197. package/skills/architecture/security-arch.md +4 -5
  198. package/skills/compliance/ai-disclosure.md +4 -5
  199. package/skills/compliance/data-privacy.md +4 -5
  200. package/skills/compliance/license-audit.md +4 -5
  201. package/skills/compliance/regulatory-review.md +4 -5
  202. package/skills/development/cpp.md +4 -5
  203. package/skills/development/go.md +4 -5
  204. package/skills/development/java.md +4 -5
  205. package/skills/development/kotlin.md +4 -5
  206. package/skills/development/mobile-crossplatform.md +4 -5
  207. package/skills/development/python.md +4 -5
  208. package/skills/development/rust.md +4 -5
  209. package/skills/development/shell.md +4 -5
  210. package/skills/development/swift.md +4 -5
  211. package/skills/development/typescript.md +4 -5
  212. package/skills/devops/ci-cd.md +4 -5
  213. package/skills/devops/containerization.md +4 -5
  214. package/skills/devops/cost-optimization.md +4 -5
  215. package/skills/devops/data-engineering.md +4 -5
  216. package/skills/devops/database.md +4 -5
  217. package/skills/devops/dependency-management.md +4 -5
  218. package/skills/devops/devsecops.md +4 -5
  219. package/skills/devops/git-workflow.md +4 -5
  220. package/skills/devops/incident-response.md +4 -5
  221. package/skills/devops/monorepo.md +4 -5
  222. package/skills/devops/observability.md +4 -5
  223. package/skills/devops/performance.md +4 -5
  224. package/skills/devops/testing.md +4 -5
  225. package/skills/docs/adr-workflow.md +4 -7
  226. package/skills/docs/backlog-proposal-workflow.md +4 -3
  227. package/skills/docs/customer-profile-workflow.md +4 -3
  228. package/skills/docs/document-ingest-workflow.md +4 -3
  229. package/skills/docs/evidence-ingest-workflow.md +4 -3
  230. package/skills/docs/init-docs.md +4 -5
  231. package/skills/docs/init-project.md +4 -5
  232. package/skills/docs/prd-workflow.md +4 -7
  233. package/skills/docs/prfaq-workflow.md +4 -3
  234. package/skills/docs/product-intelligence-review.md +4 -3
  235. package/skills/docs/product-intelligence-workflow.md +4 -6
  236. package/skills/docs/product-signal-workflow.md +4 -5
  237. package/skills/docs/research-workflow.md +4 -5
  238. package/skills/docs/runbook-workflow.md +4 -5
  239. package/skills/docs/strategy-workflow.md +4 -5
  240. package/skills/exploration/dependency-graph-reading.md +4 -7
  241. package/skills/exploration/repo-map.md +4 -5
  242. package/skills/exploration/tracer-bullet-method.md +4 -7
  243. package/skills/exploration/unknown-codebase-onboarding.md +4 -7
  244. package/skills/frameworks/django.md +4 -5
  245. package/skills/frameworks/nextjs.md +4 -5
  246. package/skills/frameworks/react.md +4 -5
  247. package/skills/frameworks/spring-boot.md +4 -5
  248. package/skills/frontend-design/accessibility.md +4 -5
  249. package/skills/frontend-design/component-patterns.md +4 -5
  250. package/skills/frontend-design/engineering.md +4 -5
  251. package/skills/frontend-design/state-management.md +4 -5
  252. package/skills/frontend-design/ui-aesthetics.md +4 -5
  253. package/skills/frontend-design/ux-principles.md +4 -5
  254. package/skills/operating/change-management.md +4 -8
  255. package/skills/operating/incident-response.md +4 -8
  256. package/skills/operating/oncall-rotation.md +4 -7
  257. package/skills/operating/orchestration-reference.md +4 -10
  258. package/skills/quality-gates/review-work.md +4 -5
  259. package/skills/quality-gates/verify-change.md +4 -5
  260. package/skills/quality-gates/verify-module.md +4 -5
  261. package/skills/quality-gates/verify-quality.md +4 -5
  262. package/skills/quality-gates/verify-security.md +4 -5
  263. package/skills/roles/architect.ai-systems.md +6 -9
  264. package/skills/roles/architect.data.md +6 -9
  265. package/skills/roles/architect.enterprise.md +6 -9
  266. package/skills/roles/architect.integration.md +6 -9
  267. package/skills/roles/architect.md +7 -14
  268. package/skills/roles/architect.platform.md +6 -9
  269. package/skills/roles/data-analyst.experiment.md +6 -9
  270. package/skills/roles/data-analyst.md +6 -9
  271. package/skills/roles/data-analyst.product-intelligence.md +7 -9
  272. package/skills/roles/data-analyst.product.md +6 -9
  273. package/skills/roles/data-analyst.telemetry.md +7 -9
  274. package/skills/roles/data-engineer.pipeline.md +6 -9
  275. package/skills/roles/data-engineer.vector-retrieval.md +7 -9
  276. package/skills/roles/data-engineer.warehouse.md +6 -9
  277. package/skills/roles/debugger.md +6 -9
  278. package/skills/roles/designer.accessibility.md +6 -9
  279. package/skills/roles/designer.md +7 -9
  280. package/skills/roles/engineer.ai.md +6 -9
  281. package/skills/roles/engineer.data.md +6 -9
  282. package/skills/roles/engineer.md +9 -9
  283. package/skills/roles/engineer.platform.md +6 -9
  284. package/skills/roles/operator.docs.md +6 -9
  285. package/skills/roles/operator.md +9 -9
  286. package/skills/roles/operator.release.md +6 -9
  287. package/skills/roles/operator.sre.md +6 -9
  288. package/skills/roles/orchestrator.md +6 -9
  289. package/skills/roles/product-manager.ai-product.md +6 -9
  290. package/skills/roles/product-manager.business-strategy.md +6 -9
  291. package/skills/roles/product-manager.enterprise.md +6 -9
  292. package/skills/roles/product-manager.growth.md +7 -9
  293. package/skills/roles/product-manager.md +7 -9
  294. package/skills/roles/product-manager.platform.md +6 -9
  295. package/skills/roles/product-manager.product.md +6 -9
  296. package/skills/roles/qa.ai-eval.md +8 -9
  297. package/skills/roles/qa.api-contract.md +7 -9
  298. package/skills/roles/qa.data-pipeline.md +7 -9
  299. package/skills/roles/qa.md +7 -9
  300. package/skills/roles/qa.test-automation.md +6 -9
  301. package/skills/roles/qa.web-ui.md +7 -9
  302. package/skills/roles/researcher.explorer.md +6 -9
  303. package/skills/roles/researcher.md +8 -9
  304. package/skills/roles/researcher.ux.md +6 -9
  305. package/skills/roles/reviewer.devil-advocate.md +6 -9
  306. package/skills/roles/reviewer.evaluator.md +6 -9
  307. package/skills/roles/reviewer.md +9 -9
  308. package/skills/roles/reviewer.trace.md +6 -9
  309. package/skills/roles/security.ai.md +7 -9
  310. package/skills/roles/security.appsec.md +6 -9
  311. package/skills/roles/security.cloud.md +6 -9
  312. package/skills/roles/security.legal-compliance.md +6 -9
  313. package/skills/roles/security.md +7 -9
  314. package/skills/roles/security.privacy.md +7 -9
  315. package/skills/roles/security.supply-chain.md +7 -9
  316. package/skills/security/blue-team.md +4 -5
  317. package/skills/security/code-audit.md +4 -5
  318. package/skills/security/pentest.md +4 -5
  319. package/skills/security/red-team.md +4 -5
  320. package/skills/security/threat-intel.md +4 -5
  321. package/skills/security/vuln-research.md +4 -5
  322. package/skills/strategy/competitive-landscape.md +4 -8
  323. package/skills/strategy/market-research-methods.md +4 -8
  324. package/skills/strategy/narrative-arc.md +4 -8
  325. package/skills/strategy/pricing-positioning.md +4 -8
  326. package/skills/utility/clean-code.md +4 -5
  327. package/specialists/policy-inventory.json +14 -0
  328. package/specialists/registry.json +158 -13
  329. package/lib/hooks/env-check.mjs +0 -83
  330. package/lib/hooks/read-tracker.mjs +0 -61
  331. package/lib/policy/unified-gates.mjs +0 -96
  332. package/lib/server/static/assets/index-ab25c707.js +0 -70
  333. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
  334. package/lib/specialist-contracts-enforce.mjs +0 -158
  335. package/rules/common/agents.md +0 -28
  336. package/rules/common/development-workflow.md +0 -32
  337. package/rules/common/performance.md +0 -55
@@ -112,10 +112,12 @@ function logCapDrop(rootDir, kind, dropped, total) {
112
112
  function writeIndex(rootDir, entries) {
113
113
  ensureDir(obsDir(rootDir), rootDir);
114
114
  const trimmed = entries.slice(0, MAX_INDEX);
115
- if (entries.length > MAX_INDEX) {
116
- logCapDrop(rootDir, 'observation-index', entries.length - MAX_INDEX, entries.length);
115
+ const dropped = Math.max(0, entries.length - MAX_INDEX);
116
+ if (dropped > 0) {
117
+ logCapDrop(rootDir, 'observation-index', dropped, entries.length);
117
118
  }
118
119
  fs.writeFileSync(indexPath(rootDir), JSON.stringify(trimmed, null, 2) + '\n');
120
+ return { dropped };
119
121
  }
120
122
 
121
123
  function readVectors(rootDir) {
@@ -130,10 +132,12 @@ function readVectors(rootDir) {
130
132
 
131
133
  function writeVectors(rootDir, records) {
132
134
  ensureDir(obsDir(rootDir), rootDir);
133
- if (records.length > MAX_INDEX) {
134
- logCapDrop(rootDir, 'observation-vectors', records.length - MAX_INDEX, records.length);
135
+ const dropped = Math.max(0, records.length - MAX_INDEX);
136
+ if (dropped > 0) {
137
+ logCapDrop(rootDir, 'observation-vectors', dropped, records.length);
135
138
  }
136
139
  fs.writeFileSync(vectorsPath(rootDir), JSON.stringify(records, null, 2) + '\n');
140
+ return { dropped };
137
141
  }
138
142
 
139
143
  /**
@@ -188,6 +192,7 @@ export async function addObservation(rootDir, {
188
192
  JSON.stringify(record, null, 2) + '\n',
189
193
  );
190
194
 
195
+ let indexDropped = 0;
191
196
  withFileLockSync(indexPath(rootDir), () => {
192
197
  const index = readIndex(rootDir);
193
198
  index.unshift({
@@ -198,8 +203,12 @@ export async function addObservation(rootDir, {
198
203
  project: record.project,
199
204
  createdAt: now,
200
205
  });
201
- writeIndex(rootDir, index);
206
+ indexDropped = writeIndex(rootDir, index).dropped;
202
207
  });
208
+ if (indexDropped > 0) {
209
+ record.capDropped = indexDropped;
210
+ process.stderr.write(`[observation-store] observation cap reached: ${indexDropped} oldest entries evicted (cap=${MAX_INDEX})\n`);
211
+ }
203
212
 
204
213
  // Try SQL storage with neural embeddings
205
214
  try {
@@ -6,10 +6,26 @@
6
6
  * pulling new models, and configuring Ollama as a provider.
7
7
  */
8
8
 
9
- import { execSync } from 'node:child_process';
9
+ import { execFileSync } from 'node:child_process';
10
10
  import { join } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
 
13
+ // argv-array form for every shell-out: no shell interpolation, model names and
14
+ // JSON bodies are passed as discrete arguments / stdin so user-supplied values
15
+ // cannot break out of the command.
16
+
17
+ function curlGet(url, { timeout } = {}) {
18
+ return execFileSync('curl', ['-s', url], { encoding: 'utf8', timeout });
19
+ }
20
+
21
+ function curlPost(url, body, { timeout } = {}) {
22
+ return execFileSync('curl', ['-s', url, '--data-binary', '@-'], {
23
+ encoding: 'utf8',
24
+ timeout,
25
+ input: typeof body === 'string' ? body : JSON.stringify(body),
26
+ });
27
+ }
28
+
13
29
  const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || process.env.OLLAMA_HOST || 'http://localhost:11434';
14
30
 
15
31
  /**
@@ -17,14 +33,11 @@ const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || process.env.OLLAMA_HOST |
17
33
  */
18
34
  export function checkOllamaStatus() {
19
35
  try {
20
- execSync('which ollama', { stdio: 'pipe' });
36
+ execFileSync('which', ['ollama'], { stdio: 'pipe' });
21
37
  const installed = true;
22
-
38
+
23
39
  try {
24
- const response = execSync(`curl -s ${OLLAMA_BASE_URL}/api/tags`, {
25
- encoding: 'utf8',
26
- timeout: 3000
27
- });
40
+ const response = curlGet(`${OLLAMA_BASE_URL}/api/tags`, { timeout: 3000 });
28
41
  const data = JSON.parse(response);
29
42
  return {
30
43
  installed,
@@ -54,10 +67,7 @@ export function checkOllamaStatus() {
54
67
  */
55
68
  export function listModels() {
56
69
  try {
57
- const response = execSync(`curl -s ${OLLAMA_BASE_URL}/api/tags`, {
58
- encoding: 'utf8',
59
- timeout: 5000
60
- });
70
+ const response = curlGet(`${OLLAMA_BASE_URL}/api/tags`, { timeout: 5000 });
61
71
  const data = JSON.parse(response);
62
72
  return data.models || [];
63
73
  } catch (e) {
@@ -71,7 +81,7 @@ export function listModels() {
71
81
  export function pullModel(modelName, { verbose = false } = {}) {
72
82
  console.log(`Pulling ${modelName}...`);
73
83
  try {
74
- const output = execSync(`ollama pull ${modelName}`, {
84
+ const output = execFileSync('ollama', ['pull', modelName], {
75
85
  encoding: 'utf8',
76
86
  stdio: verbose ? 'inherit' : 'pipe'
77
87
  });
@@ -86,7 +96,7 @@ export function pullModel(modelName, { verbose = false } = {}) {
86
96
  */
87
97
  export function removeModel(modelName) {
88
98
  try {
89
- execSync(`ollama rm ${modelName}`, { encoding: 'utf8' });
99
+ execFileSync('ollama', ['rm', modelName], { encoding: 'utf8' });
90
100
  return { success: true };
91
101
  } catch (e) {
92
102
  return { success: false, error: e.message };
@@ -98,10 +108,7 @@ export function removeModel(modelName) {
98
108
  */
99
109
  export function showModel(modelName) {
100
110
  try {
101
- const response = execSync(`curl -s ${OLLAMA_BASE_URL}/api/show -d '{"name":"${modelName}"}'`, {
102
- encoding: 'utf8',
103
- timeout: 5000
104
- });
111
+ const response = curlPost(`${OLLAMA_BASE_URL}/api/show`, { name: modelName }, { timeout: 5000 });
105
112
  return JSON.parse(response);
106
113
  } catch (e) {
107
114
  throw new Error(`Failed to fetch model details: ${e.message}`);
@@ -113,14 +120,11 @@ export function showModel(modelName) {
113
120
  */
114
121
  export function testModel(modelName, prompt = 'Say hello in one sentence.') {
115
122
  try {
116
- const response = execSync(`curl -s ${OLLAMA_BASE_URL}/api/generate -d '${JSON.stringify({
117
- model: modelName,
118
- prompt: prompt,
119
- stream: false
120
- })}'`, {
121
- encoding: 'utf8',
122
- timeout: 30000
123
- });
123
+ const response = curlPost(
124
+ `${OLLAMA_BASE_URL}/api/generate`,
125
+ { model: modelName, prompt, stream: false },
126
+ { timeout: 30000 },
127
+ );
124
128
  const data = JSON.parse(response);
125
129
  return {
126
130
  success: true,
@@ -371,7 +375,7 @@ async function cmdOllamaSetup(args) {
371
375
  if (autoYes || process.platform === 'darwin') {
372
376
  console.log('Installing via Homebrew...');
373
377
  try {
374
- execSync('brew install ollama', { stdio: 'inherit' });
378
+ execFileSync('brew', ['install', 'ollama'], { stdio: 'inherit' });
375
379
  console.log('✅ Ollama installed');
376
380
  } catch (e) {
377
381
  console.error('❌ Installation failed. Please install manually: https://ollama.com');
@@ -390,11 +394,11 @@ async function cmdOllamaSetup(args) {
390
394
  console.log('⚠️ Ollama is not running');
391
395
  console.log('Starting Ollama service...');
392
396
  try {
393
- execSync('brew services start ollama', { stdio: 'pipe' });
397
+ execFileSync('brew', ['services', 'start', 'ollama'], { stdio: 'pipe' });
394
398
  console.log('✅ Ollama service started');
395
399
  // Wait for it to be ready
396
400
  console.log('Waiting for Ollama to be ready...');
397
- execSync('sleep 3');
401
+ execFileSync('sleep', ['3']);
398
402
  } catch (e) {
399
403
  console.log('You can start it manually with: ollama serve');
400
404
  }
@@ -47,8 +47,15 @@ export function sanitizeOpenCodeConfig(config) {
47
47
  }
48
48
 
49
49
  export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
50
+ // Treat the canonical home config as the default target; any explicit path
51
+ // outside the canonical home dir (e.g. project-scoped .opencode/config.json)
52
+ // is honored as-is so per-project sync writes to the right tree.
53
+
50
54
  const canonical = getCanonicalOpenCodeConfigPath();
51
- fs.mkdirSync(path.dirname(canonical), { recursive: true });
52
- fs.writeFileSync(canonical, `${JSON.stringify(sanitizeOpenCodeConfig(config), null, 2)}\n`, "utf8");
53
- return canonical;
55
+ const target = file && path.resolve(file) !== path.resolve(findOpenCodeConfigPath())
56
+ ? file
57
+ : canonical;
58
+ fs.mkdirSync(path.dirname(target), { recursive: true });
59
+ fs.writeFileSync(target, `${JSON.stringify(sanitizeOpenCodeConfig(config), null, 2)}\n`, "utf8");
60
+ return target;
54
61
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * lib/orchestration/routing-tables.mjs — declarative routing resolver.
3
+ *
4
+ * Reads specialist subscriptions, doc-artifact ownership, and watch-condition
5
+ * references from specialists/registry.json (with optional .cx/specialists/
6
+ * overlays) and exposes the forward lookups the orchestration layer needs.
7
+ *
8
+ * The resolver is the single source of truth for event/doc/watch routing.
9
+ * Projects override routing without patching library code by dropping JSON
10
+ * files into .cx/specialists/ — each overlay's fields apply over the
11
+ * canonical registry entry for the same specialist.
12
+ *
13
+ * Watch conditions are referenced by name. The implementations live in
14
+ * WATCHERS below — small predicates over the requestSignals shape. The
15
+ * registry decides WHICH specialist a named watcher routes to; this file
16
+ * decides WHAT condition triggers it. Project overlays can re-bind a
17
+ * watcher's specialist; adding a brand-new watcher requires code.
18
+ */
19
+
20
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { findProjectRoot } from '../project-root.mjs';
24
+
25
+ const REGISTRY_PATH = fileURLToPath(new URL('../../specialists/registry.json', import.meta.url));
26
+
27
+ // Watch-condition predicates. Each is a pure function from requestSignals
28
+ // (see orchestration-policy.requestSignals) to boolean. Registry entries
29
+ // reference these by key. Keep predicates small and side-effect free.
30
+
31
+ const WATCHERS = {
32
+ 'high-ambiguity-deep-work': (s) =>
33
+ s.ambiguityScore > 0.5 && s.workCategory === 'deep',
34
+ 'visual-or-ui-risk': (s) =>
35
+ Boolean(s.visualDeliverable) || Boolean(s.riskFlags?.ui),
36
+ 'auth-payments-non-narrow': (s) =>
37
+ Boolean(s.authOrPayments) && s.blastRadius !== 'narrow',
38
+ 'architecture-without-metric': (s) =>
39
+ Boolean(s.riskFlags?.architecture) && !s.hasSuccessMetric,
40
+ 'wide-blast-radius': (s) => s.blastRadius === 'wide',
41
+ };
42
+
43
+ let cache = null;
44
+
45
+ function readJsonSafe(path) {
46
+ try {
47
+ return JSON.parse(readFileSync(path, 'utf8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function loadOverlays() {
54
+ const root = findProjectRoot();
55
+ if (!root) return [];
56
+ const overlayDir = join(root, '.cx', 'specialists');
57
+ if (!existsSync(overlayDir)) return [];
58
+ const out = [];
59
+ for (const name of readdirSync(overlayDir)) {
60
+ if (!name.endsWith('.json')) continue;
61
+ const data = readJsonSafe(join(overlayDir, name));
62
+ if (data && typeof data === 'object') out.push(data);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function buildTables() {
68
+ const registry = readJsonSafe(REGISTRY_PATH);
69
+ if (!registry || !Array.isArray(registry.specialists)) {
70
+ throw new Error('routing-tables: registry.json missing or malformed');
71
+ }
72
+
73
+ // Forward maps. Last writer wins for overlays; the resolver validates
74
+ // duplicate ownership inside the canonical registry, so a project overlay
75
+ // is the only way to re-bind a single event/doc/watcher.
76
+
77
+ const eventToOwner = new Map();
78
+ const docToOwner = new Map();
79
+ const watcherToOwner = new Map();
80
+ const watcherToReason = new Map();
81
+
82
+ const errors = [];
83
+
84
+ function apply(entry, source) {
85
+ const cxId = entry.name?.startsWith('cx-') ? entry.name : `cx-${entry.name}`;
86
+ if (Array.isArray(entry.subscriptions)) {
87
+ for (const event of entry.subscriptions) {
88
+ if (eventToOwner.has(event) && eventToOwner.get(event) !== cxId && source === 'registry') {
89
+ errors.push(`duplicate event ownership: ${event} → ${eventToOwner.get(event)} vs ${cxId}`);
90
+ }
91
+ eventToOwner.set(event, cxId);
92
+ }
93
+ }
94
+ if (Array.isArray(entry.docArtifacts)) {
95
+ for (const docType of entry.docArtifacts) {
96
+ if (docToOwner.has(docType) && docToOwner.get(docType) !== cxId && source === 'registry') {
97
+ errors.push(`duplicate doc ownership: ${docType} → ${docToOwner.get(docType)} vs ${cxId}`);
98
+ }
99
+ docToOwner.set(docType, cxId);
100
+ }
101
+ }
102
+ if (Array.isArray(entry.watchConditions)) {
103
+ for (const watch of entry.watchConditions) {
104
+ const name = typeof watch === 'string' ? watch : watch.watcher;
105
+ const reason = typeof watch === 'string' ? null : watch.reason;
106
+ if (!WATCHERS[name]) {
107
+ errors.push(`unknown watchCondition: ${name} (referenced by ${cxId})`);
108
+ continue;
109
+ }
110
+ watcherToOwner.set(name, cxId);
111
+ if (reason) watcherToReason.set(name, reason);
112
+ }
113
+ }
114
+ }
115
+
116
+ for (const entry of registry.specialists) apply(entry, 'registry');
117
+ for (const overlay of loadOverlays()) apply(overlay, 'overlay');
118
+
119
+ if (errors.length > 0) {
120
+ throw new Error(`routing-tables: ${errors.join('; ')}`);
121
+ }
122
+
123
+ return {
124
+ eventToOwner,
125
+ docToOwner,
126
+ watcherToOwner,
127
+ watcherToReason,
128
+ };
129
+ }
130
+
131
+ function tables() {
132
+ if (!cache) cache = buildTables();
133
+ return cache;
134
+ }
135
+
136
+ export function ownerForEvent(eventType) {
137
+ if (!eventType) return null;
138
+ return tables().eventToOwner.get(eventType) ?? null;
139
+ }
140
+
141
+ export function ownerForDoc(docType) {
142
+ if (!docType) return null;
143
+ return tables().docToOwner.get(docType) ?? null;
144
+ }
145
+
146
+ export function evaluateWatchConditions(signals) {
147
+ const triggers = [];
148
+ const t = tables();
149
+ for (const [name, predicate] of Object.entries(WATCHERS)) {
150
+ const owner = t.watcherToOwner.get(name);
151
+ if (!owner) continue;
152
+ if (!predicate(signals)) continue;
153
+ triggers.push({
154
+ specialist: owner,
155
+ reason: t.watcherToReason.get(name) ?? name,
156
+ watcher: name,
157
+ });
158
+ }
159
+ return triggers;
160
+ }
161
+
162
+ export function knownEventTypes() {
163
+ return Array.from(tables().eventToOwner.keys());
164
+ }
165
+
166
+ export function knownDocTypes() {
167
+ return Array.from(tables().docToOwner.keys());
168
+ }
169
+
170
+ export function knownWatchers() {
171
+ return Object.keys(WATCHERS);
172
+ }
173
+
174
+ export function _resetCache() {
175
+ cache = null;
176
+ }
@@ -7,14 +7,17 @@
7
7
  * 3. contract chain (resolveContractChain) (what the typed handoffs are)
8
8
  *
9
9
  * Agent-to-agent contracts are defined in specialists/contracts.json and loaded via
10
- * lib/specialist-contracts.mjs. That file is the single source of truth for
11
- * producer→consumer expectations, replacing the scattered DOC_OWNERSHIP map,
12
- * SPECIALIST_MAP, and informal "collaborators" lists for anything contract-
13
- * shaped. The older maps remain for quick lookups but defer to contracts.json
14
- * for authoritative semantics.
10
+ * lib/specialist-contracts.mjs. That file is the source of truth for
11
+ * producer→consumer typed handoffs.
12
+ *
13
+ * Event ownership, doc-artifact ownership, and watch-condition routing live
14
+ * declaratively on specialist entries in specialists/registry.json and are
15
+ * resolved by lib/orchestration/routing-tables.mjs. Hardcoded maps here
16
+ * would create a second source of truth.
15
17
  */
16
18
  import { resolveContractChain } from './specialist-contracts.mjs';
17
19
  import { verifyRoute } from './intent-classifier.mjs';
20
+ import { ownerForEvent, ownerForDoc, evaluateWatchConditions, knownDocTypes } from './orchestration/routing-tables.mjs';
18
21
 
19
22
  export const EXECUTION_TRACKS = {
20
23
  immediate: 'immediate',
@@ -40,86 +43,11 @@ export const WORK_CATEGORIES = {
40
43
 
41
44
  export const TERMINAL_STATES = ['DONE', 'BLOCKED', 'NEEDS_MAIN_INPUT'];
42
45
 
43
- /**
44
- * Maps document types to the specialist that owns authoring them.
45
- * Construct (or any general persona) must route to the owner rather than
46
- * authoring these directly — authoring is how the owner's domain checks
47
- * (framing, research, trade-off analysis) actually fire.
48
- *
49
- * Keep in sync with rules/common/doc-ownership.md.
50
- */
51
- export const DOC_OWNERSHIP = {
52
- prd: 'cx-product-manager',
53
- 'meta-prd': 'cx-product-manager',
54
- 'prd-platform': 'cx-product-manager',
55
- 'prd-business': 'cx-product-manager',
56
- prfaq: 'cx-product-manager',
57
- 'one-pager': 'cx-product-manager',
58
- 'backlog-proposal': 'cx-product-manager',
59
- 'customer-profile': 'cx-product-manager',
60
- adr: 'cx-architect',
61
- rfc: 'cx-architect',
62
- 'rfc-platform': 'cx-architect',
63
- 'architecture-overview': 'cx-architect',
64
- 'system-design': 'cx-architect',
65
- 'research-brief': 'cx-researcher',
66
- 'evidence-brief': 'cx-researcher',
67
- 'signal-brief': 'cx-researcher',
68
- 'product-intelligence-report': 'cx-researcher',
69
- runbook: 'cx-sre',
70
- 'incident-report': 'cx-sre',
71
- postmortem: 'cx-sre',
72
- 'test-plan': 'cx-qa',
73
- 'qa-strategy': 'cx-qa',
74
- 'security-review': 'cx-security',
75
- 'threat-model': 'cx-security',
76
- memo: 'cx-docs-keeper',
77
- changelog: 'cx-docs-keeper',
78
- };
46
+ // Event and doc-artifact ownership are resolved from specialists/registry.json
47
+ // via routing-tables.mjs. Re-exported here so callers that historically
48
+ // imported from this module keep working.
79
49
 
80
- // Event ownership for the role framework (lib/roles/**). When a hook emits
81
- // an event of one of these types, the gateway routes the invocation to the
82
- // listed persona. Empty owners mean the event is recorded but no persona
83
- // fires until onboarded. Keep in sync with specialists/role-manifests.json.
84
-
85
- export const EVENT_OWNERSHIP = {
86
- 'push_gate.fail': 'cx-sre',
87
- 'service.down': 'cx-sre',
88
- 'mcp.unhealthy.persistent': 'cx-sre',
89
- 'edit_loop.stuck': 'cx-sre',
90
- 'test.fail': 'cx-qa',
91
- 'test.flake': 'cx-qa',
92
- 'coverage.drop': 'cx-qa',
93
- 'dep.cve': 'cx-security',
94
- 'secrets.detected': 'cx-security',
95
- 'config.protection.violation': 'cx-security',
96
- 'pr.merged.no-docs': 'cx-docs-keeper',
97
- 'changelog.missing': 'cx-docs-keeper',
98
- 'readme.stale': 'cx-docs-keeper',
99
- 'adr.requested': 'cx-architect',
100
- 'arch.boundary.violated': 'cx-architect',
101
- 'regression.detected': 'cx-debugger',
102
- 'hang.detected': 'cx-debugger',
103
- 'release.candidate': 'cx-release-manager',
104
- 'version.bump.needed': 'cx-release-manager',
105
- 'backlog.stale': 'cx-product-manager',
106
- 'prd.requested': 'cx-product-manager',
107
- 'pr.opened': 'cx-reviewer',
108
- 'pr.ready-for-review': 'cx-reviewer',
109
- 'infra.change.requested': 'cx-platform-engineer',
110
- 'service.scale.event': 'cx-platform-engineer',
111
- 'design.requested': 'cx-designer',
112
- 'a11y.violation': 'cx-accessibility',
113
- 'research.requested': 'cx-researcher',
114
- 'evidence.requested': 'cx-researcher',
115
- 'eval.regression': 'cx-evaluator',
116
- 'trace.anomaly': 'cx-trace-reviewer',
117
- 'dep.license': 'cx-legal-compliance',
118
- 'privacy-policy.review': 'cx-legal-compliance',
119
- 'strategy.required': 'cx-business-strategist',
120
- 'plan.requested': 'cx-operations',
121
- 'research.gate.required': 'cx-rd-lead',
122
- };
50
+ export { ownerForEvent, ownerForDoc } from './orchestration/routing-tables.mjs';
123
51
 
124
52
  const DOC_AUTHORING_PATTERNS = [
125
53
  { pattern: /\b(adr|architecture decision record)s?\b/i, docType: 'adr' },
@@ -160,7 +88,7 @@ export function detectDocAuthoringIntent(request = '') {
160
88
  if (!AUTHORING_VERBS.test(text)) return null;
161
89
  for (const { pattern, docType } of DOC_AUTHORING_PATTERNS) {
162
90
  if (pattern.test(text)) {
163
- return { docType, owner: DOC_OWNERSHIP[docType] ?? null };
91
+ return { docType, owner: ownerForDoc(docType) };
164
92
  }
165
93
  }
166
94
  return null;
@@ -617,29 +545,13 @@ export function requestSignals(request = '', context = {}) {
617
545
 
618
546
  // Signal-driven proactive triggers. Returns a list of { specialist, reason }
619
547
  // pairs for specialists that should engage PRE-DISPATCH based on signals —
620
- // separate from the keyword-only paths in selectSpecialists. Empty when no
621
- // signal trips.
548
+ // separate from the keyword-only paths in selectSpecialists. The watch
549
+ // predicates and their specialist owners are declared in
550
+ // specialists/registry.json (watchConditions) and evaluated by
551
+ // orchestration/routing-tables.mjs.
622
552
 
623
553
  export function proactiveTriggers(signals) {
624
- const triggers = [];
625
- const { ambiguityScore, workCategory, authOrPayments, riskFlags, blastRadius, hasSuccessMetric, visualDeliverable } = signals;
626
-
627
- if (ambiguityScore > 0.5 && workCategory === WORK_CATEGORIES.deep) {
628
- triggers.push({ specialist: 'cx-product-manager', reason: 'clarify acceptance criteria (high ambiguity)' });
629
- }
630
- if (visualDeliverable || riskFlags.ui) {
631
- triggers.push({ specialist: 'cx-designer', reason: 'visual deliverable / UI risk surface' });
632
- }
633
- if (authOrPayments && blastRadius !== 'narrow') {
634
- triggers.push({ specialist: 'cx-security', reason: 'pre-dispatch threat model (auth/payments/PII + non-narrow blast radius)' });
635
- }
636
- if (riskFlags.architecture && !hasSuccessMetric) {
637
- triggers.push({ specialist: 'cx-devil-advocate', reason: 'architecture change without stated success metric' });
638
- }
639
- if (blastRadius === 'wide') {
640
- triggers.push({ specialist: 'cx-sre', reason: 'wide blast radius — operational readiness review' });
641
- }
642
- return triggers;
554
+ return evaluateWatchConditions(signals).map(({ specialist, reason }) => ({ specialist, reason }));
643
555
  }
644
556
 
645
557
  // User-visible dispatch summary: one line per specialist with the reason it
package/lib/parity.mjs CHANGED
@@ -39,12 +39,22 @@ function adapterName(entry, prefix) {
39
39
  return entry.isPersona ? entry.name : `${prefix}-${entry.name}`;
40
40
  }
41
41
 
42
- function entriesForSurface(registry, surface) {
42
+ function entriesForSurface(registry, surface, { scope = 'global' } = {}) {
43
43
  const prefix = registry.prefix || 'cx';
44
- const entries = [
45
- ...(registry.orchestrator ? [{ ...registry.orchestrator, isPersona: true }] : []),
46
- ...(registry.specialists || []).map((s) => ({ ...s, isPersona: false })),
47
- ];
44
+
45
+ // User-scope expects only the `construct` front-door agent. Specialists
46
+ // live with each Construct-managed project (apps/dashboard, apps/docs,
47
+ // and every other repo that runs `construct init`) — see the two-tier
48
+ // sync contract in scripts/sync-specialists.mjs.
49
+
50
+ const entries = scope === 'project'
51
+ ? [
52
+ ...(registry.orchestrator ? [{ ...registry.orchestrator, isPersona: true }] : []),
53
+ ...(registry.specialists || []).map((s) => ({ ...s, isPersona: false })),
54
+ ]
55
+ : registry.orchestrator
56
+ ? [{ ...registry.orchestrator, isPersona: true }]
57
+ : [];
48
58
 
49
59
  return entries
50
60
  .filter((e) => {
@@ -217,12 +227,31 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
217
227
  };
218
228
  }
219
229
 
230
+ // Names a v1.0.10 install would have populated at user scope (claude, codex,
231
+ // copilot) before the two-tier sync contract moved specialists to project
232
+ // scope. Pulled from the live registry so the roster auto-updates as new
233
+ // specialists land — anything in this set is "expected legacy state during
234
+ // an upgrade," not real drift.
235
+ function legacyUserScopeRoster(registry) {
236
+ const prefix = registry.prefix || 'cx';
237
+ const specialists = (registry.specialists || []).map((s) => `${prefix}-${s.name}`);
238
+ return new Set(specialists);
239
+ }
240
+
220
241
  /**
221
242
  * Run parity checks across every supported surface. Never throws — returns a
222
243
  * structured report so callers can render it however they like.
244
+ *
245
+ * A surface that reports `drift` is reclassified to `legacy-install` when the
246
+ * only divergence is extras that all match the v1.0.10 user-scope roster — a
247
+ * dev box mid-upgrade from v1.0.10 (which populated cx-* specialists at user
248
+ * scope) to v1.0.13+ (project scope only). `legacy-install` rolls up to the
249
+ * same overall-ok bucket as `absent` so it doesn't hard-fail the gate; the
250
+ * summary still surfaces the `--fix-legacy-agents` hint.
223
251
  */
224
252
  export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {}) {
225
253
  const registry = loadRegistry(rootDir);
254
+ const legacyRoster = legacyUserScopeRoster(registry);
226
255
  const surfaces = [
227
256
  checkClaude(registry, { homeDir }),
228
257
  checkOpenCode(registry, { homeDir }),
@@ -230,13 +259,16 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
230
259
  checkCopilot(registry, { homeDir }),
231
260
  checkVSCode(registry, { homeDir }),
232
261
  checkCursor(registry, { homeDir }),
233
- ];
262
+ ].map((s) => reclassifyLegacy(s, legacyRoster));
234
263
 
235
- const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent');
264
+ const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent' || s.status === 'legacy-install');
236
265
  const summary = surfaces.map((s) => {
237
266
  if (s.status === 'absent') return `${s.surface}: not installed`;
238
267
  if (s.status === 'unreadable') return `${s.surface}: unreadable (${s.error})`;
239
268
  if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount} ${s.kind})`;
269
+ if (s.status === 'legacy-install') {
270
+ return `${s.surface}: legacy v1.0.10 install — ${s.extra.length} stale ${s.kind} (run \`construct doctor --fix-legacy-agents\`)`;
271
+ }
240
272
  const parts = [];
241
273
  if (s.missing.length) parts.push(`missing: ${s.missing.join(', ')}`);
242
274
  if (s.extra.length) parts.push(`extra: ${s.extra.join(', ')}`);
@@ -245,3 +277,12 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
245
277
 
246
278
  return { ok, surfaces, summary };
247
279
  }
280
+
281
+ function reclassifyLegacy(surface, legacyRoster) {
282
+ if (surface.status !== 'drift') return surface;
283
+ if ((surface.missing?.length ?? 0) !== 0) return surface;
284
+ if (!surface.extra?.length) return surface;
285
+ const allLegacy = surface.extra.every((name) => legacyRoster.has(name));
286
+ if (!allLegacy) return surface;
287
+ return { ...surface, status: 'legacy-install' };
288
+ }