@geraldmaron/construct 1.4.2 → 1.5.0

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 (519) hide show
  1. package/.env.example +36 -0
  2. package/README.md +41 -7
  3. package/bin/construct +1027 -64
  4. package/examples/distribution/sources/deck-one-pager.md +1 -1
  5. package/lib/acp/server.mjs +21 -7
  6. package/lib/adapters-sync.mjs +2 -1
  7. package/lib/audit-trail.mjs +185 -2
  8. package/lib/beads/auto-close.mjs +2 -1
  9. package/lib/beads/drift.mjs +2 -1
  10. package/lib/bridges/copilot-proxy.mjs +20 -5
  11. package/lib/certification/skill-inventory.mjs +10 -1
  12. package/lib/cli/approvals.mjs +147 -0
  13. package/lib/cli-commands.mjs +105 -14
  14. package/lib/comment-lint.mjs +127 -8
  15. package/lib/config/schema.mjs +6 -29
  16. package/lib/config/source-target-registry.mjs +70 -0
  17. package/lib/config/source-targets.mjs +179 -205
  18. package/lib/contracts/coverage.mjs +77 -0
  19. package/lib/contracts/validate.mjs +102 -13
  20. package/lib/contracts/violation-log.mjs +50 -4
  21. package/lib/db/migrate.mjs +69 -0
  22. package/lib/db/migrations/001_orchestration_runs.sql +9 -0
  23. package/lib/db/migrations/002_queue_provider.sql +50 -0
  24. package/lib/db/migrations/003_worker_registry.sql +17 -0
  25. package/lib/db/migrations/004_trace_events.sql +16 -0
  26. package/lib/db/migrations/005_shared_memory.sql +15 -0
  27. package/lib/db/migrations/006_orchestration_runs_tenant.sql +5 -0
  28. package/lib/decisions/enforced-baseline.json +0 -2
  29. package/lib/decisions/registry.mjs +3 -2
  30. package/lib/deployment/parity-contract.mjs +1 -1
  31. package/lib/deployment-mode.mjs +78 -2
  32. package/lib/diagram-export.mjs +10 -1
  33. package/lib/distill.mjs +5 -2
  34. package/lib/docs-verify.mjs +2 -1
  35. package/lib/doctor/cli.mjs +1 -0
  36. package/lib/doctor/command-on-path.mjs +24 -0
  37. package/lib/doctor/diagnosis.mjs +89 -0
  38. package/lib/doctor/embedding-health.mjs +50 -0
  39. package/lib/doctor/engine-health.mjs +93 -0
  40. package/lib/doctor/graph-validate.mjs +47 -0
  41. package/lib/doctor/index.mjs +18 -4
  42. package/lib/doctor/sidecar-providers.mjs +56 -0
  43. package/lib/doctor/watchers/consistency.mjs +93 -1
  44. package/lib/doctor/watchers/cx-budget.mjs +1 -1
  45. package/lib/doctor/watchers/graph-staleness.mjs +1 -1
  46. package/lib/doctor/watchers/mcp-protocol.mjs +17 -0
  47. package/lib/doctor/watchers/oracle-liveness.mjs +92 -0
  48. package/lib/doctor/watchers/orchestration-runs.mjs +108 -0
  49. package/lib/doctor/watchers/provider-breaker.mjs +78 -0
  50. package/lib/document-export.mjs +52 -27
  51. package/lib/document-extract/docling-client.mjs +9 -5
  52. package/lib/document-extract/docling-sidecar.py +30 -0
  53. package/lib/document-extract.mjs +1 -1
  54. package/lib/document-ingest.mjs +9 -0
  55. package/lib/embed/approval-queue.mjs +184 -88
  56. package/lib/embed/authority-guard.mjs +65 -2
  57. package/lib/embed/capability-jobs.mjs +365 -0
  58. package/lib/embed/capability-lifecycle.mjs +219 -0
  59. package/lib/embed/capability-loader.mjs +303 -0
  60. package/lib/embed/capability-runtime.mjs +58 -0
  61. package/lib/embed/cli.mjs +185 -8
  62. package/lib/embed/config.mjs +4 -0
  63. package/lib/embed/daemon.mjs +125 -6
  64. package/lib/embed/demand-fetch.mjs +190 -54
  65. package/lib/embed/inbox.mjs +12 -10
  66. package/lib/embed/presets/ops-triage.mjs +236 -0
  67. package/lib/embed/presets/pm-feedback.mjs +341 -0
  68. package/lib/embed/presets/tpm.mjs +401 -0
  69. package/lib/embed/providers/jira.mjs +72 -1
  70. package/lib/embed/providers/registry.mjs +140 -33
  71. package/lib/embedded-contract/capability.mjs +4 -0
  72. package/lib/embedded-contract/execution.mjs +1 -1
  73. package/lib/embedded-contract/model-resolve.mjs +53 -3
  74. package/lib/embedded-contract/workflow-defs.mjs +48 -73
  75. package/lib/embedded-contract/workflow-invoke.mjs +144 -4
  76. package/lib/embedded-contract/workflows/architecture-review.manifest.json +15 -0
  77. package/lib/embedded-contract/workflows/data-structure.manifest.json +14 -0
  78. package/lib/embedded-contract/workflows/evidence-ingest.manifest.json +14 -0
  79. package/lib/embedded-contract/workflows/memo-draft.manifest.json +14 -0
  80. package/lib/embedded-contract/workflows/operations-triage.manifest.json +18 -0
  81. package/lib/embedded-contract/workflows/operations.manifest.json +18 -0
  82. package/lib/embedded-contract/workflows/pm-feedback.manifest.json +18 -0
  83. package/lib/embedded-contract/workflows/prd-draft.manifest.json +15 -0
  84. package/lib/embedded-contract/workflows/proposal-review.manifest.json +15 -0
  85. package/lib/embedded-contract/workflows/research-synthesis.manifest.json +14 -0
  86. package/lib/embedded-contract/workflows/risk-review.manifest.json +15 -0
  87. package/lib/embedded-contract/workflows/structure-notes.manifest.json +14 -0
  88. package/lib/embedded-contract/workflows/transcript-process.manifest.json +14 -0
  89. package/lib/embedded-contract/workflows/triage.manifest.json +14 -0
  90. package/lib/env-config.mjs +50 -9
  91. package/lib/extensions/index.mjs +27 -0
  92. package/lib/extensions/loader.mjs +120 -0
  93. package/lib/extensions/manifest-schema.mjs +141 -0
  94. package/lib/extensions/manifests/anthropic.manifest.json +12 -0
  95. package/lib/extensions/manifests/atlassian-confluence.manifest.json +12 -0
  96. package/lib/extensions/manifests/atlassian-jira.manifest.json +53 -0
  97. package/lib/extensions/manifests/directory.manifest.json +12 -0
  98. package/lib/extensions/manifests/docling.manifest.json +37 -0
  99. package/lib/extensions/manifests/echo.manifest.json +8 -0
  100. package/lib/extensions/manifests/feedback.manifest.json +12 -0
  101. package/lib/extensions/manifests/github-copilot.manifest.json +12 -0
  102. package/lib/extensions/manifests/github.manifest.json +52 -0
  103. package/lib/extensions/manifests/linear.manifest.json +50 -0
  104. package/lib/extensions/manifests/local.manifest.json +12 -0
  105. package/lib/extensions/manifests/ollama.manifest.json +12 -0
  106. package/lib/extensions/manifests/openai.manifest.json +12 -0
  107. package/lib/extensions/manifests/openrouter-anthropic.manifest.json +12 -0
  108. package/lib/extensions/manifests/openrouter-deepseek.manifest.json +12 -0
  109. package/lib/extensions/manifests/openrouter-google.manifest.json +12 -0
  110. package/lib/extensions/manifests/openrouter-llama.manifest.json +12 -0
  111. package/lib/extensions/manifests/openrouter-qwen.manifest.json +12 -0
  112. package/lib/extensions/manifests/openrouter.manifest.json +12 -0
  113. package/lib/extensions/manifests/postgres.manifest.json +12 -0
  114. package/lib/extensions/manifests/salesforce.manifest.json +12 -0
  115. package/lib/extensions/manifests/slack.manifest.json +58 -0
  116. package/lib/extensions/manifests/whisper.manifest.json +36 -0
  117. package/lib/extensions/validate.mjs +175 -0
  118. package/lib/features.mjs +13 -9
  119. package/lib/flows/checkpoint.mjs +184 -0
  120. package/lib/flows/constants.mjs +27 -0
  121. package/lib/flows/define.mjs +146 -0
  122. package/lib/flows/engine.mjs +249 -0
  123. package/lib/flows/errors.mjs +19 -0
  124. package/lib/flows/index.mjs +27 -0
  125. package/lib/flows/joins.mjs +18 -0
  126. package/lib/flows/schema.mjs +90 -0
  127. package/lib/flows/state.mjs +31 -0
  128. package/lib/frameworks/loader.mjs +99 -0
  129. package/lib/frameworks/schema.mjs +157 -0
  130. package/lib/graph/build-from-corpus.mjs +67 -0
  131. package/lib/graph/build-from-embed.mjs +82 -0
  132. package/lib/graph/build-from-registry.mjs +164 -3
  133. package/lib/graph/cli.mjs +456 -12
  134. package/lib/graph/gap-queries.mjs +156 -0
  135. package/lib/graph/gaps.mjs +41 -0
  136. package/lib/graph/impacted.mjs +129 -0
  137. package/lib/graph/runtime-evidence.mjs +177 -0
  138. package/lib/graph/security-coverage.mjs +113 -0
  139. package/lib/graph/staleness.mjs +241 -10
  140. package/lib/graph/store.mjs +24 -7
  141. package/lib/graph/validate.mjs +161 -0
  142. package/lib/headhunt.mjs +9 -8
  143. package/lib/health-check.mjs +15 -7
  144. package/lib/hook-health.mjs +1 -1
  145. package/lib/hooks/agent-tracker.mjs +10 -4
  146. package/lib/hooks/edit-guard.mjs +3 -3
  147. package/lib/hooks/graph-impact-advisory.mjs +2 -2
  148. package/lib/hooks/guard-bash.mjs +41 -15
  149. package/lib/hooks/mcp-health-check.mjs +11 -4
  150. package/lib/hooks/model-fallback.mjs +50 -6
  151. package/lib/hooks/orchestration-dispatch-guard.mjs +14 -3
  152. package/lib/hooks/pre-compact.mjs +181 -173
  153. package/lib/hooks/rule-verifier.mjs +2 -1
  154. package/lib/hooks/session-reflect.mjs +2 -1
  155. package/lib/hooks/session-start.mjs +3 -3
  156. package/lib/host-capabilities.mjs +13 -2
  157. package/lib/host-disposition.mjs +19 -7
  158. package/lib/identity.mjs +92 -0
  159. package/lib/ingest/degraded-extract.mjs +96 -0
  160. package/lib/ingest/docling-remote.mjs +2 -1
  161. package/lib/ingest/provider-extract.mjs +7 -19
  162. package/lib/ingest/sidecar-providers.mjs +196 -0
  163. package/lib/ingest-tooling.mjs +11 -5
  164. package/lib/init-unified.mjs +55 -38
  165. package/lib/init-update.mjs +2 -1
  166. package/lib/install/legacy-global-cleanup.mjs +25 -0
  167. package/lib/intake/daemon.mjs +99 -8
  168. package/lib/intake/git-queue.mjs +110 -38
  169. package/lib/intake/queue-registry.mjs +50 -0
  170. package/lib/intake/queue.mjs +133 -17
  171. package/lib/intake/session-prelude.mjs +57 -8
  172. package/lib/integrations/intake-integrations.mjs +61 -111
  173. package/lib/intent-classifier.mjs +43 -5
  174. package/lib/libreoffice-export.mjs +8 -8
  175. package/lib/logging/rotate.mjs +5 -4
  176. package/lib/mcp/broker.mjs +332 -17
  177. package/lib/mcp/denied-store.mjs +95 -0
  178. package/lib/mcp/destructive-gate.mjs +30 -0
  179. package/lib/mcp/dispatch-envelope.mjs +199 -0
  180. package/lib/mcp/memory-bridge.mjs +2 -1
  181. package/lib/mcp/server.mjs +154 -1411
  182. package/lib/mcp/tool-definitions-memory.mjs +376 -0
  183. package/lib/mcp/tool-definitions-project.mjs +271 -0
  184. package/lib/mcp/tool-definitions-skills.mjs +311 -0
  185. package/lib/mcp/tool-definitions-workflow.mjs +398 -0
  186. package/lib/mcp/tool-definitions.mjs +25 -0
  187. package/lib/mcp/tool-registry.mjs +107 -0
  188. package/lib/mcp/tool-safety.mjs +1 -0
  189. package/lib/mcp/tools/orchestration-delegation-next.tool.mjs +68 -0
  190. package/lib/mcp/tools/orchestration-run.mjs +165 -25
  191. package/lib/mcp/tools/orchestration-task-result.tool.mjs +77 -0
  192. package/lib/mcp/tools/provider-write.mjs +187 -0
  193. package/lib/mcp/tools/scope.mjs +0 -3
  194. package/lib/mcp/tools/skills.mjs +1 -1
  195. package/lib/mcp/tools/storage.mjs +0 -8
  196. package/lib/mcp/transport/auth.mjs +238 -0
  197. package/lib/mcp/transport/http.mjs +136 -0
  198. package/lib/mcp/transport/mode.mjs +31 -0
  199. package/lib/mcp/transport/stdio.mjs +22 -0
  200. package/lib/mcp-catalog.json +4 -4
  201. package/lib/mcp-manager.mjs +34 -23
  202. package/lib/mcp-platform-config.mjs +31 -7
  203. package/lib/mode-capabilities.mjs +109 -0
  204. package/lib/model-router.mjs +42 -9
  205. package/lib/models/catalog.mjs +40 -1
  206. package/lib/net-guard.mjs +247 -0
  207. package/lib/observation-store.mjs +6 -2
  208. package/lib/ollama/provision-context.mjs +2 -1
  209. package/lib/opencode-config.mjs +22 -3
  210. package/lib/opencode-runtime-plugin.mjs +120 -2
  211. package/lib/oracle/actions.mjs +204 -10
  212. package/lib/oracle/cli.mjs +24 -3
  213. package/lib/oracle/dispatch.mjs +2 -1
  214. package/lib/oracle/execute.mjs +2 -2
  215. package/lib/oracle/gaps.mjs +3 -3
  216. package/lib/oracle/heartbeat.mjs +53 -0
  217. package/lib/oracle/read-model.mjs +69 -13
  218. package/lib/oracle/reconcile.mjs +3 -46
  219. package/lib/oracle/routing.mjs +37 -31
  220. package/lib/oracle/synthesize.mjs +1 -1
  221. package/lib/orchestration/classification.mjs +434 -0
  222. package/lib/orchestration/delegation-flow.mjs +132 -0
  223. package/lib/orchestration/flow-selection.mjs +418 -0
  224. package/lib/orchestration/gates.mjs +244 -0
  225. package/lib/orchestration/host-sampling.mjs +121 -0
  226. package/lib/orchestration/policy-constants.mjs +48 -0
  227. package/lib/orchestration/provider-outcome.mjs +197 -0
  228. package/lib/orchestration/readiness.mjs +114 -13
  229. package/lib/orchestration/run-store-postgres.mjs +32 -26
  230. package/lib/orchestration/run-store-sqlite.mjs +8 -14
  231. package/lib/orchestration/run-store.mjs +25 -12
  232. package/lib/orchestration/runtime.mjs +446 -39
  233. package/lib/orchestration/store.mjs +87 -12
  234. package/lib/orchestration/trace-store.mjs +125 -0
  235. package/lib/orchestration/web-capability.mjs +3 -2
  236. package/lib/orchestration/worker-runtime.mjs +162 -0
  237. package/lib/orchestration/worker.mjs +528 -89
  238. package/lib/orchestration-policy.mjs +67 -1111
  239. package/lib/packs/cli.mjs +144 -0
  240. package/lib/packs/core-pack.mjs +112 -0
  241. package/lib/packs/enablement.mjs +187 -0
  242. package/lib/packs/index.mjs +23 -0
  243. package/lib/packs/loader.mjs +176 -0
  244. package/lib/packs/manifest-schema.mjs +58 -0
  245. package/lib/packs/prompts.mjs +120 -0
  246. package/lib/packs/validate.mjs +208 -0
  247. package/lib/plugin-registry.mjs +4 -5
  248. package/lib/policy/audit-gate.mjs +42 -0
  249. package/lib/policy/consumption-budget.mjs +149 -0
  250. package/lib/policy/engine.mjs +81 -6
  251. package/lib/policy/role-authority.mjs +89 -0
  252. package/lib/prompt-composer.js +19 -3
  253. package/lib/providers/contract/adapters/confluence/governed-write.mjs +215 -0
  254. package/lib/providers/contract/adapters/confluence/manifest.json +17 -0
  255. package/lib/providers/contract/adapters/confluence/transport.mjs +135 -0
  256. package/lib/providers/contract/adapters/github/governed-write.mjs +121 -0
  257. package/lib/providers/contract/adapters/github/index.mjs +38 -3
  258. package/lib/providers/contract/adapters/jira/adf.mjs +151 -0
  259. package/lib/providers/contract/adapters/jira/createmeta.mjs +143 -0
  260. package/lib/providers/contract/adapters/jira/governed-write.mjs +182 -0
  261. package/lib/providers/contract/adapters/jira/manifest.json +17 -0
  262. package/lib/providers/contract/adapters/jira/transport.mjs +119 -0
  263. package/lib/providers/contract.mjs +207 -0
  264. package/lib/providers/credential-bootstrap.mjs +7 -4
  265. package/lib/providers/credential-sources.mjs +14 -2
  266. package/lib/providers/directory/index.mjs +261 -0
  267. package/lib/providers/feedback/index.mjs +392 -0
  268. package/lib/providers/filter-audit.mjs +68 -0
  269. package/lib/providers/filter-schema.mjs +54 -0
  270. package/lib/providers/github/index.mjs +31 -0
  271. package/lib/providers/instance-config.mjs +215 -0
  272. package/lib/providers/op-locate.mjs +96 -0
  273. package/lib/providers/op-run.mjs +64 -9
  274. package/lib/providers/registry.mjs +26 -3
  275. package/lib/providers/secret-audit-wiring.mjs +6 -0
  276. package/lib/providers/secret-resolver.mjs +240 -23
  277. package/lib/publish-template.mjs +6 -3
  278. package/lib/publish-tooling.mjs +4 -0
  279. package/lib/publish.mjs +32 -4
  280. package/lib/queue/pg-queue.mjs +395 -0
  281. package/lib/reflect.mjs +2 -1
  282. package/lib/registry/assemble.mjs +28 -2
  283. package/lib/registry/consolidation.mjs +8 -1
  284. package/lib/registry/custom-scaffold.mjs +200 -0
  285. package/lib/registry/custom-schema.mjs +137 -0
  286. package/lib/registry/loader.mjs +8 -3
  287. package/lib/registry/manifests/format-engines.default.json +15 -0
  288. package/lib/registry/manifests/surface-map.default.json +46 -0
  289. package/lib/registry/retired-paths.mjs +1 -0
  290. package/lib/registry/surface-map.mjs +100 -50
  291. package/lib/render-visual-check.mjs +240 -0
  292. package/lib/resources/budget.mjs +40 -17
  293. package/lib/roles/flavor-bindings.mjs +36 -14
  294. package/lib/roles/gateway.mjs +15 -8
  295. package/lib/roots.mjs +107 -0
  296. package/lib/runtime/uv-bootstrap.mjs +32 -6
  297. package/lib/runtime/whisper-bootstrap.mjs +11 -3
  298. package/lib/runtime-env.mjs +5 -2
  299. package/lib/scheduler/index.mjs +8 -20
  300. package/lib/schema-infer.mjs +31 -2
  301. package/lib/scopes/lifecycle.mjs +29 -25
  302. package/lib/scopes/loader.mjs +49 -12
  303. package/lib/scopes/teams.mjs +1 -1
  304. package/lib/security/ingest-boundary.mjs +80 -0
  305. package/lib/security/recall-wrapper.mjs +99 -0
  306. package/lib/security/trust.mjs +132 -0
  307. package/lib/service-manager.mjs +38 -19
  308. package/lib/setup.mjs +41 -23
  309. package/lib/skills-apply.mjs +4 -2
  310. package/lib/specialists/postconditions.mjs +2 -2
  311. package/lib/state-root.mjs +147 -0
  312. package/lib/status.mjs +597 -6
  313. package/lib/storage/admin.mjs +77 -5
  314. package/lib/storage/backend-registry.mjs +73 -0
  315. package/lib/storage/backend.mjs +63 -9
  316. package/lib/storage/embeddings-openai.mjs +12 -2
  317. package/lib/storage/hybrid-query.mjs +11 -3
  318. package/lib/storage/retrieval-hardening.mjs +384 -0
  319. package/lib/storage/shared-memory.mjs +158 -0
  320. package/lib/storage/vector-client.mjs +69 -2
  321. package/lib/team/health.mjs +72 -0
  322. package/lib/telemetry/backends/local.mjs +9 -3
  323. package/lib/telemetry/client.mjs +3 -7
  324. package/lib/template-registry.mjs +10 -12
  325. package/lib/tenant/context.mjs +82 -0
  326. package/lib/tenant/isolation.mjs +129 -0
  327. package/lib/test-corpus-inventory.mjs +103 -2
  328. package/lib/uninstall/uninstall.mjs +78 -12
  329. package/lib/validator.mjs +4 -6
  330. package/lib/worker/entrypoint.mjs +2 -1
  331. package/lib/worker/run.mjs +2 -2
  332. package/lib/worker/trace.mjs +5 -11
  333. package/lib/workflow-state.mjs +52 -8
  334. package/lib/workflows/liveness.mjs +203 -0
  335. package/lib/workflows/loader.mjs +177 -0
  336. package/lib/workflows/manifest-schema.mjs +71 -0
  337. package/lib/workflows/surface-parity.mjs +118 -0
  338. package/lib/workflows/validate.mjs +127 -0
  339. package/lib/writes/control-plane.mjs +140 -0
  340. package/lib/writes/envelope.mjs +206 -0
  341. package/lib/writes/sent-log.mjs +86 -0
  342. package/lib/writes/write-intent.mjs +109 -0
  343. package/package.json +16 -8
  344. package/personas/construct.md +12 -3
  345. package/registry/capabilities.json +143 -36
  346. package/rules/common/comments.md +1 -0
  347. package/schemas/project-config.schema.json +0 -12
  348. package/schemas/unified-registry.schema.json +2 -4
  349. package/scripts/sync-specialists.mjs +284 -81
  350. package/skills/docs/adr-workflow.md +1 -1
  351. package/skills/docs/codebase-research-workflow.md +3 -3
  352. package/skills/docs/prd-workflow.md +5 -6
  353. package/skills/docs/runbook-workflow.md +2 -2
  354. package/skills/docs/user-research-workflow.md +3 -3
  355. package/skills/operating/fleet-health-routing.md +77 -0
  356. package/skills/roles/ai-engineer.md +1 -1
  357. package/skills/roles/architect.md +0 -1
  358. package/skills/roles/business-strategist.md +1 -1
  359. package/skills/roles/data-analyst.telemetry.md +1 -1
  360. package/skills/roles/data-engineer.md +1 -1
  361. package/skills/roles/data-engineer.pipeline.md +1 -1
  362. package/skills/roles/data-engineer.vector-retrieval.md +1 -2
  363. package/skills/roles/data-engineer.warehouse.md +1 -1
  364. package/skills/roles/designer.accessibility.md +1 -1
  365. package/skills/roles/designer.md +0 -1
  366. package/skills/roles/devil-advocate.md +1 -1
  367. package/skills/roles/docs-keeper.md +1 -1
  368. package/skills/roles/evaluator.md +1 -1
  369. package/skills/roles/explorer.md +1 -1
  370. package/skills/roles/platform-engineer.md +1 -1
  371. package/skills/roles/qa.ai-eval.md +1 -2
  372. package/skills/roles/qa.api-contract.md +0 -1
  373. package/skills/roles/qa.data-pipeline.md +0 -1
  374. package/skills/roles/qa.md +0 -1
  375. package/skills/roles/qa.web-ui.md +0 -1
  376. package/skills/roles/release-manager.md +1 -1
  377. package/skills/roles/researcher.md +0 -2
  378. package/skills/roles/reviewer.md +0 -3
  379. package/skills/roles/security.ai.md +1 -1
  380. package/skills/roles/security.legal-compliance.md +1 -1
  381. package/skills/roles/security.md +0 -1
  382. package/skills/roles/security.privacy.md +0 -1
  383. package/skills/roles/security.supply-chain.md +1 -1
  384. package/skills/roles/sre.md +1 -1
  385. package/skills/roles/test-automation.md +1 -1
  386. package/skills/roles/trace-reviewer.md +1 -1
  387. package/skills/roles/ux-researcher.md +1 -1
  388. package/specialists/artifact-manifest.json +36 -36
  389. package/specialists/org/contracts/accessibility-to-qa.json +11 -3
  390. package/specialists/org/contracts/any-to-business-strategist.json +19 -7
  391. package/specialists/org/contracts/any-to-debugger.json +7 -1
  392. package/specialists/org/contracts/any-to-designer.json +7 -1
  393. package/specialists/org/contracts/any-to-docs-keeper.json +18 -4
  394. package/specialists/org/contracts/any-to-explorer.json +13 -4
  395. package/specialists/org/contracts/any-to-sre-incident.json +6 -2
  396. package/specialists/org/contracts/any-to-trace-reviewer.json +16 -4
  397. package/specialists/org/contracts/architect-to-ai-engineer.json +6 -2
  398. package/specialists/org/contracts/architect-to-data-engineer.json +17 -5
  399. package/specialists/org/contracts/architect-to-devil-advocate.json +11 -3
  400. package/specialists/org/contracts/architect-to-engineer.json +20 -4
  401. package/specialists/org/contracts/architect-to-evaluator.json +15 -5
  402. package/specialists/org/contracts/architect-to-legal-compliance.json +15 -5
  403. package/specialists/org/contracts/architect-to-operations.json +17 -4
  404. package/specialists/org/contracts/architect-to-platform-engineer.json +20 -4
  405. package/specialists/org/contracts/construct-to-orchestrator.json +10 -2
  406. package/specialists/org/contracts/data-analyst-to-product-manager.json +11 -2
  407. package/specialists/org/contracts/engineer-to-qa.json +20 -4
  408. package/specialists/org/contracts/engineer-to-reviewer.json +29 -5
  409. package/specialists/org/contracts/explorer-to-engineer.json +11 -3
  410. package/specialists/org/contracts/legal-compliance-to-release-manager.json +12 -4
  411. package/specialists/org/contracts/operations-to-user.json +53 -0
  412. package/specialists/org/contracts/operations-triage-output.json +53 -0
  413. package/specialists/org/contracts/pm-requirements-candidates.json +53 -0
  414. package/specialists/org/contracts/product-manager-to-architect.json +30 -10
  415. package/specialists/org/contracts/product-manager-to-data-analyst.json +13 -3
  416. package/specialists/org/contracts/product-manager-to-ux-researcher.json +15 -5
  417. package/specialists/org/contracts/qa-to-release-manager.json +11 -3
  418. package/specialists/org/contracts/researcher-to-architect.json +10 -2
  419. package/specialists/org/contracts/researcher-to-product-manager.json +16 -5
  420. package/specialists/org/contracts/reviewer-to-security.json +17 -3
  421. package/specialists/org/contracts/test-automation-to-engineer.json +11 -3
  422. package/specialists/org/contracts/trace-reviewer-to-sre.json +12 -4
  423. package/specialists/org/contracts/user-to-construct.json +11 -4
  424. package/specialists/org/frameworks/cx-architect-constraint-option-failure.md +66 -0
  425. package/specialists/org/frameworks/cx-engineer-feasibility-blast-radius.md +66 -0
  426. package/specialists/org/frameworks/cx-ops-dependency-sequencing.md +74 -0
  427. package/specialists/org/frameworks/cx-pm-value-tradeoff.md +66 -0
  428. package/specialists/org/frameworks/cx-qa-risk-based-coverage.md +69 -0
  429. package/specialists/org/groups/engineering-group.json +2 -6
  430. package/specialists/org/groups/governance-group.json +2 -3
  431. package/specialists/org/groups/operations-group.json +5 -8
  432. package/specialists/org/groups/product-group.json +4 -8
  433. package/specialists/org/groups/quality-group.json +3 -7
  434. package/specialists/org/groups/strategy-group.json +6 -9
  435. package/specialists/org/models.json.example +8 -0
  436. package/specialists/org/scopes/creative.json +6 -1
  437. package/specialists/org/scopes/operations.json +7 -1
  438. package/specialists/org/scopes/research.json +6 -1
  439. package/specialists/org/scopes/rnd.json +7 -1
  440. package/specialists/org/specialists/cx-architect.json +27 -8
  441. package/specialists/org/specialists/cx-designer.json +22 -8
  442. package/specialists/org/specialists/cx-engineer.json +71 -7
  443. package/specialists/org/specialists/cx-operations.json +89 -15
  444. package/specialists/org/specialists/cx-orchestrator.json +16 -4
  445. package/specialists/org/specialists/cx-product-manager.json +28 -9
  446. package/specialists/org/specialists/cx-qa.json +16 -6
  447. package/specialists/org/specialists/cx-researcher.json +40 -7
  448. package/specialists/org/specialists/cx-reviewer.json +61 -11
  449. package/specialists/org/specialists/cx-security.json +30 -10
  450. package/specialists/org/teams/design-team.json +3 -4
  451. package/specialists/org/teams/engineering-team.json +3 -10
  452. package/specialists/org/teams/governance-team.json +3 -5
  453. package/specialists/org/teams/operations-team.json +6 -12
  454. package/specialists/org/teams/product-management-team.json +2 -3
  455. package/specialists/org/teams/quality-team.json +4 -12
  456. package/specialists/org/teams/research-team.json +3 -3
  457. package/specialists/org/teams/strategy-team.json +7 -14
  458. package/specialists/prompts/cx-architect.md +20 -1
  459. package/specialists/prompts/cx-data-analyst.md +1 -1
  460. package/specialists/prompts/cx-designer.md +12 -4
  461. package/specialists/prompts/cx-engineer.md +15 -1
  462. package/specialists/prompts/cx-operations.md +14 -2
  463. package/specialists/prompts/cx-orchestrator.md +9 -5
  464. package/specialists/prompts/cx-product-manager.md +5 -1
  465. package/specialists/prompts/cx-qa.md +6 -2
  466. package/specialists/prompts/cx-researcher.md +25 -30
  467. package/specialists/prompts/cx-reviewer.md +19 -1
  468. package/specialists/prompts/cx-security.md +5 -1
  469. package/templates/distribution/construct-brand.typ +123 -59
  470. package/templates/distribution/construct-decision.typ +6 -3
  471. package/templates/distribution/construct-pdf.typ +11 -4
  472. package/templates/distribution/construct-prd.typ +6 -3
  473. package/templates/distribution/construct-research.typ +7 -4
  474. package/lib/providers/contract/adapters/git/index.mjs +0 -115
  475. package/lib/providers/contract/adapters/slack/index.mjs +0 -175
  476. package/specialists/org/contracts/business-strategist-to-product-manager.json +0 -39
  477. package/specialists/org/contracts/construct-to-rd-lead.json +0 -53
  478. package/specialists/org/contracts/data-engineer-to-platform-engineer.json +0 -36
  479. package/specialists/org/contracts/designer-to-accessibility.json +0 -36
  480. package/specialists/org/contracts/platform-engineer-to-engineer.json +0 -31
  481. package/specialists/org/contracts/qa-to-test-automation.json +0 -42
  482. package/specialists/org/contracts/rd-lead-to-architect.json +0 -38
  483. package/specialists/org/contracts/sre-to-release-manager.json +0 -36
  484. package/specialists/org/specialists/cx-accessibility.json +0 -69
  485. package/specialists/org/specialists/cx-ai-engineer.json +0 -75
  486. package/specialists/org/specialists/cx-business-strategist.json +0 -72
  487. package/specialists/org/specialists/cx-data-engineer.json +0 -71
  488. package/specialists/org/specialists/cx-devil-advocate.json +0 -71
  489. package/specialists/org/specialists/cx-docs-keeper.json +0 -82
  490. package/specialists/org/specialists/cx-evaluator.json +0 -69
  491. package/specialists/org/specialists/cx-explorer.json +0 -69
  492. package/specialists/org/specialists/cx-legal-compliance.json +0 -74
  493. package/specialists/org/specialists/cx-oracle.json +0 -46
  494. package/specialists/org/specialists/cx-platform-engineer.json +0 -80
  495. package/specialists/org/specialists/cx-rd-lead.json +0 -73
  496. package/specialists/org/specialists/cx-release-manager.json +0 -77
  497. package/specialists/org/specialists/cx-sre.json +0 -94
  498. package/specialists/org/specialists/cx-test-automation.json +0 -69
  499. package/specialists/org/specialists/cx-trace-reviewer.json +0 -69
  500. package/specialists/org/specialists/cx-ux-researcher.json +0 -68
  501. package/specialists/org/teams/accessibility-team.json +0 -39
  502. package/specialists/org/teams/ux-research-team.json +0 -39
  503. package/specialists/prompts/cx-accessibility.md +0 -55
  504. package/specialists/prompts/cx-ai-engineer.md +0 -124
  505. package/specialists/prompts/cx-business-strategist.md +0 -58
  506. package/specialists/prompts/cx-data-engineer.md +0 -55
  507. package/specialists/prompts/cx-devil-advocate.md +0 -59
  508. package/specialists/prompts/cx-docs-keeper.md +0 -164
  509. package/specialists/prompts/cx-evaluator.md +0 -49
  510. package/specialists/prompts/cx-explorer.md +0 -71
  511. package/specialists/prompts/cx-legal-compliance.md +0 -58
  512. package/specialists/prompts/cx-oracle.md +0 -98
  513. package/specialists/prompts/cx-platform-engineer.md +0 -97
  514. package/specialists/prompts/cx-rd-lead.md +0 -59
  515. package/specialists/prompts/cx-release-manager.md +0 -54
  516. package/specialists/prompts/cx-sre.md +0 -111
  517. package/specialists/prompts/cx-test-automation.md +0 -55
  518. package/specialists/prompts/cx-trace-reviewer.md +0 -101
  519. package/specialists/prompts/cx-ux-researcher.md +0 -53
@@ -19,19 +19,81 @@
19
19
  * exercised end to end against a mock provider without a live key. Failures
20
20
  * surface as structured codes (PROVIDER_KEY_MISSING, PROVIDER_MODEL_UNRESOLVED,
21
21
  * PROVIDER_EXECUTION_FAILED) rather than opaque HTTP errors.
22
+ *
23
+ * Persona resolution (LMCP-E2): the persona system prompt is resolved ONLY
24
+ * through the pack registry (lib/packs/prompts.mjs resolvePersonaPrompt), with
25
+ * no fallback to reading specialists/prompts/ directly. Team/enterprise packs
26
+ * with a missing or malformed prompt already fail at pack load
27
+ * (lib/packs/loader.mjs); a team/enterprise run that somehow still reaches a
28
+ * miss is refused outright (PERSONA_UNAVAILABLE) rather than executed, so a
29
+ * governed run can never proceed silently under the wrong persona. Solo mode
30
+ * degrades visibly on a miss instead: the task result carries `degraded:
31
+ * 'persona-fallback'` and `personaAvailable: false` rather than a silent
32
+ * generic substitution.
33
+ *
34
+ * Execution provenance (LMCP-F1): every provider-executed task result also
35
+ * carries `specialistId` (the resolved `cx-<role>` id), `packId` (which pack
36
+ * in the registry declared the persona, or null on a solo-mode fallback),
37
+ * `promptVersion` (a 12-char sha256 prefix of the resolved persona body — the
38
+ * same fingerprint convention as lib/prompt-metadata.mjs), `toolGrants` (the
39
+ * specialist's declared claudeTools from the org registry, or `[]` when the
40
+ * registry has no entry for the role), and `executionState` (`executed` on a
41
+ * real persona, `degraded-executed` on the solo-mode fallback; a provider call
42
+ * that throws is caught and recorded `failed` by the caller, runtime.mjs). This
43
+ * answers who ran, under which prompt, with which grants — the basis for audit
44
+ * and evaluation — without requiring a reader to cross-reference the pack
45
+ * registry after the fact.
46
+ *
47
+ * Contract boundary (LMCP-F2): specialists/org/contracts/ declares, per role
48
+ * pair, the shape a handoff packet must carry (validatePacket,
49
+ * lib/specialist-contracts.mjs). A task opts into enforcement by carrying
50
+ * `task.packet` (its inbound handoff) and/or `task.outputPacket` (its
51
+ * produced result); a task without either stays unvalidated rather than
52
+ * being failed against a fabricated contract. An invalid input packet throws
53
+ * CONTRACT_VIOLATION_INPUT before any provider call (hard fail, same catch
54
+ * path as PROVIDER_KEY_MISSING); an invalid output never throws — real,
55
+ * already-paid-for model output rides the result with `contractStatus:
56
+ * 'contract-failed'` and `contractViolations` instead of being discarded.
57
+ * Every violation, input or output, is appended to the tamper-evident
58
+ * `.cx/contract-violations.jsonl` log (lib/contracts/violation-log.mjs).
22
59
  */
23
60
 
24
- import { readFileSync, existsSync } from 'node:fs';
25
- import { dirname, join } from 'node:path';
61
+ import { dirname, resolve } from 'node:path';
26
62
  import { fileURLToPath } from 'node:url';
27
- import { resolveSecret } from '../providers/secret-resolver.mjs';
63
+ import { createHash } from 'node:crypto';
64
+ import { resolveSecretAsync } from '../providers/secret-resolver.mjs';
65
+ import { resolveNonNegativeSetting } from '../env-config.mjs';
28
66
  import { webSearch } from '../mcp/tools/web-search.mjs';
29
67
  import { governWebResults } from '../mcp/tools/web-search-governance.mjs';
30
68
  import { roleHoldsWebCapability, resolveWebCapability } from './web-capability.mjs';
69
+ import {
70
+ PROVIDER_ERROR_CODES,
71
+ classifyHttpFailure,
72
+ classifyContentOutcome,
73
+ extractChoiceMeta,
74
+ extractUsage,
75
+ findUnverifiedCitations,
76
+ isMalformedChoice,
77
+ isTimeoutError,
78
+ withProviderRetry,
79
+ } from './provider-outcome.mjs';
80
+ import { loadAllPacks } from '../packs/loader.mjs';
81
+ import { resolvePersonaPrompt } from '../packs/prompts.mjs';
82
+ import { getDeploymentMode } from '../deployment-mode.mjs';
83
+ import { getSpecialist } from '../registry/loader.mjs';
84
+ import { validatePacket, getIncomingContracts, getOutgoingContracts } from '../specialist-contracts.mjs';
85
+ import { logViolation } from '../contracts/violation-log.mjs';
31
86
 
32
87
  export const INLINE = 'inline';
33
88
  export const PROVIDER = 'provider';
34
- export const WORKER_BACKEND_SET = [INLINE, PROVIDER];
89
+ export const HOST = 'host';
90
+ export const WORKER_BACKEND_SET = [INLINE, PROVIDER, HOST];
91
+
92
+ // LLM completion calls run seconds-to-minutes; openai-python and anthropic-sdk-python
93
+ // both default the overall request timeout to 600s. 120s is a conservative floor that
94
+ // clears real responses without over-committing before the full retry/backoff policy
95
+ // (construct-5wkl AC#4) lands. Exported so tests assert the real default, not a copy.
96
+ export const PROVIDER_TIMEOUT_DEFAULT_MS = 120000;
35
97
 
36
98
  const MAX_OUTPUT_TOKENS = 2048;
37
99
 
@@ -41,13 +103,28 @@ const MAX_OUTPUT_TOKENS = 2048;
41
103
 
42
104
  const REASONING_BUDGET_TOKENS = 1024;
43
105
 
106
+ // Bounded retry policy (construct-5wkl AC#4): 3 total attempts (1 original +
107
+ // 2 retries) with exponential backoff, applied only to transport-classified
108
+ // retryable outcomes (rate limit, 5xx, timeout) — never to a content-policy
109
+ // refusal or a malformed body. Configurable so a test or a constrained
110
+ // environment can shrink the window without patching the module.
111
+
112
+ function providerRetryOptions(env) {
113
+ return {
114
+ maxAttempts: resolveNonNegativeSetting(env, 'CONSTRUCT_PROVIDER_MAX_ATTEMPTS', 3),
115
+ baseDelayMs: resolveNonNegativeSetting(env, 'CONSTRUCT_PROVIDER_RETRY_BASE_MS', 250),
116
+ };
117
+ }
118
+
44
119
  const HERE = dirname(fileURLToPath(import.meta.url));
45
- const PROMPTS_DIR = join(HERE, '..', '..', 'specialists', 'prompts');
120
+ const PACKAGE_ROOT = resolve(HERE, '..', '..');
46
121
 
47
- function providerError(code, reason, remediation) {
122
+ function providerError(code, reason, remediation, { retryable = false, meta = null } = {}) {
48
123
  const err = new Error(reason);
49
124
  err.code = code;
50
125
  err.remediation = remediation;
126
+ err.retryable = retryable;
127
+ if (meta) err.providerMeta = meta;
51
128
  return err;
52
129
  }
53
130
 
@@ -70,6 +147,86 @@ function classifyWorkerProvider(provider, model) {
70
147
  return 'openrouter';
71
148
  }
72
149
 
150
+ // ── Contract boundary (LMCP-F2) ──────────────────────────────────────────────
151
+ // specialists/org/contracts/ declares, per producer→consumer pair, the shape a
152
+ // handoff packet must carry (validatePacket, lib/specialist-contracts.mjs). The
153
+ // worker is the last place a specialist runs before its output leaves
154
+ // Construct's control, so it is the enforcement point: an invalid input packet
155
+ // must never reach a model call (wasted spend, garbage propagated downstream),
156
+ // and an invalid output must never look identical to a conforming one. Both
157
+ // sides are opt-in on the task (`task.packet` / `task.outputPacket`) — a task
158
+ // that carries no packet has not adopted a structured handoff yet, and skipping
159
+ // validation for it (rather than failing closed on absence) is what keeps every
160
+ // pre-F2 caller's happy path unchanged.
161
+
162
+ // A role's input contract is the one where it is the CONSUMER (what it must
163
+ // receive); ambiguity (zero or multiple matches) is not this bead's job to
164
+ // resolve silently, so callers wanting a specific contract set
165
+ // `task.inputContractId` explicitly. Symmetrically, its output contract is the
166
+ // one where it is the PRODUCER, and runtime.mjs already resolves that id onto
167
+ // `task.handoffContract` (buildTasks, LMCP-F1) — reused here rather than
168
+ // re-deriving it.
169
+
170
+ function resolveInputContractId(task) {
171
+ if (task?.inputContractId) return task.inputContractId;
172
+ const role = `cx-${String(task?.role || '').replace(/^cx-/, '')}`;
173
+ const incoming = getIncomingContracts(role);
174
+ return incoming.length === 1 ? incoming[0].id : null;
175
+ }
176
+
177
+ function resolveOutputContractId(task) {
178
+ if (task?.outputContractId) return task.outputContractId;
179
+ if (task?.handoffContract) return task.handoffContract;
180
+ const role = `cx-${String(task?.role || '').replace(/^cx-/, '')}`;
181
+ const outgoing = getOutgoingContracts(role);
182
+ return outgoing.length === 1 ? outgoing[0].id : null;
183
+ }
184
+
185
+ /**
186
+ * Validate a task's incoming handoff packet before any provider call. Throws
187
+ * CONTRACT_VIOLATION_INPUT (hard fail, blocks execution) when a packet is
188
+ * present, a contract resolves, and the packet fails shape validation. A task
189
+ * with no packet or no resolvable contract stays unvalidated: fabricating a
190
+ * contract id to fail an unopted-in task against would turn absence of data
191
+ * into a manufactured violation, so that case is the pre-F2 caller's status
192
+ * quo rather than a new failure mode.
193
+ */
194
+ export function validateInputPacket(task, { cwd = process.cwd() } = {}) {
195
+ if (task?.packet == null) return { checked: false };
196
+ const contractId = resolveInputContractId(task);
197
+ if (!contractId) return { checked: false };
198
+
199
+ const result = validatePacket(contractId, task.packet, 'input');
200
+ if (result.ok) return { checked: true, ok: true, contractId };
201
+
202
+ logViolation(contractId, 'input', result.missing, task.packet, { verdict: 'CONTRACT_VIOLATION', repoRoot: cwd });
203
+ throw providerError(
204
+ 'CONTRACT_VIOLATION_INPUT',
205
+ `Handoff packet for ${task?.role} fails contract '${contractId}': missing ${result.missing.join(', ')}`,
206
+ 'Repair the producer packet to include the missing fields, or update the contract definition if the requirement is wrong.',
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Validate a task's output packet against its role's output contract after a
212
+ * provider call. Never throws — a contract-failed output is recorded and
213
+ * reported to the caller (per policy the caller marks the task), rather than
214
+ * discarding real, already-paid-for model output. A task with no output
215
+ * packet or no resolvable contract is not validated (same opt-in rule as
216
+ * validateInputPacket).
217
+ */
218
+ export function validateOutputPacket(task, { cwd = process.cwd() } = {}) {
219
+ if (task?.outputPacket == null) return { checked: false, contractStatus: 'unchecked' };
220
+ const contractId = resolveOutputContractId(task);
221
+ if (!contractId) return { checked: false, contractStatus: 'unchecked' };
222
+
223
+ const result = validatePacket(contractId, task.outputPacket, 'output');
224
+ if (result.ok) return { checked: true, contractStatus: 'ok', contractId };
225
+
226
+ logViolation(contractId, 'output', result.missing, task.outputPacket, { verdict: 'CONTRACT_VIOLATION', repoRoot: cwd });
227
+ return { checked: true, contractStatus: 'contract-failed', contractId, violations: result.missing };
228
+ }
229
+
73
230
  const WORKER_KEY_VAR = {
74
231
  anthropic: 'ANTHROPIC_API_KEY',
75
232
  openai: 'OPENAI_API_KEY',
@@ -90,7 +247,17 @@ const WORKER_PROVIDER_LABEL = {
90
247
  // empty text. (Source: claude-api skill — extended/adaptive thinking; ADR-0030.)
91
248
 
92
249
  function anthropicThinkingConfig(model) {
93
- const adaptive = /(opus-4-(?:[6-9]|\d\d)|sonnet-4-(?:[6-9]|\d\d))/i.test(model || '');
250
+ // Version-agnostic: parse family-major-minor instead of enumerating version
251
+ // ranges, so a newly released model never silently falls back to the legacy
252
+ // budget form (which adaptive-only models reject with a 400). Modern ids put
253
+ // the family first (claude-opus-4-8); legacy ids put the version first
254
+ // (claude-3-5-sonnet) and never match the modern pattern, and any unmatched
255
+ // id is a post-4.6 family that ships adaptive-first.
256
+ const m = String(model || '').toLowerCase();
257
+ const ver = m.match(/(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/);
258
+ const adaptive = ver
259
+ ? Number(ver[1]) > 4 || (Number(ver[1]) === 4 && Number(ver[2] ?? 0) >= 6)
260
+ : !/claude-3/.test(m);
94
261
  return adaptive
95
262
  ? { type: 'adaptive', display: 'summarized' }
96
263
  : { type: 'enabled', budget_tokens: REASONING_BUDGET_TOKENS };
@@ -102,21 +269,123 @@ function anthropicThinkingConfig(model) {
102
269
  // hermetic switch: a caller that injects its own env (embedded callers, tests)
103
270
  // suppresses file/rc discovery so a developer's real key never bleeds into a run.
104
271
 
105
- function resolveKey(varName, env, allowAmbient) {
106
- return resolveSecret(varName, { env, allowAmbient });
272
+ async function resolveKey(varName, env, allowAmbient) {
273
+ return resolveSecretAsync(varName, { env, allowAmbient });
107
274
  }
108
275
 
109
- // The persona prompt is the specialist's system context. It lives in
110
- // specialists/prompts/cx-<role>.md; absent that file, a minimal role-named system
111
- // prompt keeps the backend functional rather than failing on a missing persona.
276
+ // Cached per process, keyed by deployment mode + project cwd: loadAllPacks
277
+ // reads and validates every manifest tier from disk (including the per-project
278
+ // .cx/packs/ tier), which per-task would be wasted work across a
279
+ // multi-specialist run. _resetPackRegistryCache exists for tests that vary
280
+ // either dimension.
112
281
 
113
- function loadPersona(role) {
282
+ const packRegistryCache = new Map();
283
+
284
+ // ADR-0055 prompt resolution order: project packs before user packs, both
285
+ // before builtin. mergePackTiers dedupes by pack id but does not reorder
286
+ // distinct packs across tiers, so the merged list is re-sorted here before a
287
+ // role lookup walks it — a project pack's prompt for a role must win over a
288
+ // builtin pack's prompt for the same role, not just over another version of
289
+ // the same pack id.
290
+
291
+ const PACK_TIER_RANK = { project: 0, user: 1, builtin: 2, unknown: 3 };
292
+
293
+ function sortByTierPrecedence(packs) {
294
+ return [...packs].sort((a, b) => (PACK_TIER_RANK[a._tier] ?? 3) - (PACK_TIER_RANK[b._tier] ?? 3));
295
+ }
296
+
297
+ function getPackRegistry(deploymentMode, env, cwd) {
298
+ const cacheKey = `${deploymentMode}::${cwd}`;
299
+ if (packRegistryCache.has(cacheKey)) return packRegistryCache.get(cacheKey);
300
+ const { packs, errors } = loadAllPacks({ deploymentMode, env, rootDir: cwd, packageRoot: PACKAGE_ROOT });
301
+ const registry = { packs: sortByTierPrecedence(packs), errors };
302
+ packRegistryCache.set(cacheKey, registry);
303
+ return registry;
304
+ }
305
+
306
+ /**
307
+ * Internal: clear the cached pack registry. Test-only.
308
+ */
309
+ export function _resetPackRegistryCache() {
310
+ packRegistryCache.clear();
311
+ }
312
+
313
+ // The persona prompt is the specialist's system context, resolved ONLY through
314
+ // the pack registry (LMCP-E2) — no fallback to reading specialists/prompts/
315
+ // directly, so a pack boundary violation cannot bypass hard-fail validation
316
+ // done at load time. Team/enterprise packs with a missing prompt already fail
317
+ // at pack load (lib/packs/loader.mjs); PERSONA_UNAVAILABLE here is the
318
+ // defense-in-depth refusal if one somehow still reaches this call. Solo mode
319
+ // returns a visible degraded marker instead of executing under a silently
320
+ // substituted generic persona.
321
+
322
+ // promptVersion is a content fingerprint, not a semantic version — it changes the
323
+ // instant the resolved persona body changes (pack edit, tier override, fallback vs
324
+ // real prompt) so two traces can be diffed for "did the same prompt actually run."
325
+ // Matches the sha256-prefix convention lib/prompt-metadata.mjs already uses for
326
+ // telemetry so a promptVersion string means the same thing everywhere in the repo.
327
+
328
+ function hashPromptVersion(content) {
329
+ return createHash('sha256').update(String(content || '')).digest('hex').slice(0, 12);
330
+ }
331
+
332
+ function loadPersona(role, { env = process.env, deploymentMode = getDeploymentMode(env), cwd = process.cwd() } = {}) {
114
333
  const slug = String(role || '').replace(/^cx-/, '');
115
- const file = join(PROMPTS_DIR, `cx-${slug}.md`);
334
+ const specialistId = `cx-${slug}`;
335
+ const { packs, errors } = getPackRegistry(deploymentMode, env, cwd);
336
+
337
+ if ((deploymentMode === 'team' || deploymentMode === 'enterprise') && errors.length > 0) {
338
+ throw providerError(
339
+ 'PERSONA_UNAVAILABLE',
340
+ `Pack registry failed to load under ${deploymentMode} mode: ${errors.join('; ')}`,
341
+ 'Fix the named prompt file or manifest, or set orchestration.workerBackend to "inline".',
342
+ );
343
+ }
344
+
345
+ const resolved = resolvePersonaPrompt(specialistId, { packs, packageRoot: PACKAGE_ROOT });
346
+ if (resolved.found) {
347
+ return {
348
+ content: resolved.content,
349
+ available: true,
350
+ specialistId,
351
+ packId: resolved.packId ?? 'unknown',
352
+ promptVersion: hashPromptVersion(resolved.content),
353
+ };
354
+ }
355
+
356
+ if (deploymentMode === 'team' || deploymentMode === 'enterprise') {
357
+ throw providerError(
358
+ 'PERSONA_UNAVAILABLE',
359
+ `No pack in the registry declares a prompt for 'cx-${slug}' under ${deploymentMode} mode.`,
360
+ 'Add the specialist prompt to a pack manifest, or set orchestration.workerBackend to "inline".',
361
+ );
362
+ }
363
+
364
+ const fallbackContent = `You are the ${slug} specialist. Execute your part of the request within your role and return your result directly.`;
365
+ return {
366
+ content: fallbackContent,
367
+ available: false,
368
+ specialistId,
369
+ packId: null,
370
+ promptVersion: hashPromptVersion(fallbackContent),
371
+ };
372
+ }
373
+
374
+ // Tool grants are the specialist's declared claudeTools string (org registry,
375
+ // specialists/org/**) — the least-privilege surface OWASP GenAI excessive-agency
376
+ // audits need per actor. A role the registry does not know (fallback persona,
377
+ // custom pack-only role) reports an empty grant list rather than throwing, since
378
+ // absence of a registry entry does not mean absence of an execution.
379
+
380
+ function resolveToolGrants(specialistId, { cwd = process.cwd() } = {}) {
116
381
  try {
117
- if (existsSync(file)) return readFileSync(file, 'utf8');
118
- } catch { /* unreadable persona falls back to the minimal prompt */ }
119
- return `You are the ${slug} specialist. Execute your part of the request within your role and return your result directly.`;
382
+ const spec = getSpecialist(specialistId, { rootDir: cwd });
383
+ const raw = spec?.claudeTools;
384
+ if (typeof raw !== 'string' || !raw.trim()) return [];
385
+ return raw.split(',').map((t) => t.trim()).filter(Boolean);
386
+ } catch {
387
+ return [];
388
+ }
120
389
  }
121
390
 
122
391
  function buildUserPrompt({ task, run }) {
@@ -126,6 +395,40 @@ function buildUserPrompt({ task, run }) {
126
395
  return `Request: ${summary}${reason}${handoff}\n\nDo your part of this request as the ${String(task?.role || '').replace(/^cx-/, '')} specialist. Return your result directly.`;
127
396
  }
128
397
 
398
+ /**
399
+ * Materialize the specialist prompt (persona + user turn) and provenance a task
400
+ * would run under, without calling any model. Shared by the provider executor
401
+ * (which then hands the result to a real model call) and the host worker
402
+ * backend (which hands the same result to the calling host to execute in its
403
+ * own session) — a host and a provider must never see two different prompts
404
+ * for the same task (LMCP host-execution).
405
+ *
406
+ * @param {object} opts
407
+ * @param {object} opts.task
408
+ * @param {object} opts.run
409
+ * @param {string} [opts.cwd]
410
+ * @param {Record<string,string>} [opts.env]
411
+ * @returns {{system:string, user:string, specialistId:string, packId:string|null,
412
+ * promptVersion:string, toolGrants:string[], personaAvailable:boolean, degraded?:string}}
413
+ */
414
+ export function materializeTaskPrompt({ task, run, cwd = process.cwd(), env = process.env } = {}) {
415
+ const deploymentMode = run?.execution?.deploymentMode || getDeploymentMode(env);
416
+ const persona = loadPersona(task?.role, { env, deploymentMode, cwd });
417
+ const system = persona.content;
418
+ const user = buildUserPrompt({ task, run });
419
+ const toolGrants = resolveToolGrants(persona.specialistId, { cwd });
420
+ return {
421
+ system,
422
+ user,
423
+ specialistId: persona.specialistId,
424
+ packId: persona.packId,
425
+ promptVersion: persona.promptVersion,
426
+ toolGrants,
427
+ personaAvailable: persona.available,
428
+ ...(persona.available ? {} : { degraded: 'persona-fallback' }),
429
+ };
430
+ }
431
+
129
432
  async function bodyExcerpt(res) {
130
433
  try {
131
434
  return (await res.text()).slice(0, 200);
@@ -157,7 +460,54 @@ function timedFetch(fetchImpl, url, opts, timeoutMs) {
157
460
  ]);
158
461
  }
159
462
 
160
- async function callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning, timeoutMs }) {
463
+ function remediationForCode(code, label, keyVar) {
464
+ if (code === PROVIDER_ERROR_CODES.RATE_LIMITED) return `${label} rate-limited this request and retries were exhausted; wait before retrying, or switch tiers/providers.`;
465
+ if (code === PROVIDER_ERROR_CODES.SERVER_ERROR) return `${label} returned a server error and retries were exhausted; retry later or switch providers.`;
466
+ if (code === PROVIDER_ERROR_CODES.AUTH_ERROR) return `Verify ${keyVar} is a valid, current credential for ${label}, or re-run with worker_backend "host" to execute specialists in the calling session with no provider key at all.`;
467
+ if (code === PROVIDER_ERROR_CODES.NO_CREDITS) return `${label} reports insufficient credits (HTTP 402). Add credits, or re-run with worker_backend "host" — the calling agent executes each specialist prompt in its own session at no API cost.`;
468
+ return `Verify the model id and ${keyVar}, or set orchestration.workerBackend to "inline".`;
469
+ }
470
+
471
+ // One transport call, wrapped in the bounded retry policy (construct-5wkl
472
+ // AC#4): a rate limit, a 5xx, or a timeout each classify as retryable and get
473
+ // re-attempted with backoff; anything else (auth failure, 4xx) fails on the
474
+ // first attempt. Returns the parsed JSON body plus elapsedMs/retryCount so the
475
+ // caller can attach provider metadata regardless of whether content
476
+ // classification later succeeds or fails.
477
+
478
+ async function postJsonRetryable(fetchImpl, url, headers, body, timeoutMs, { label, keyVar, env }) {
479
+ const startedAt = Date.now();
480
+ const { maxAttempts, baseDelayMs } = providerRetryOptions(env);
481
+ const data = await withProviderRetry(async () => {
482
+ let res;
483
+ try {
484
+ res = await timedFetch(fetchImpl, url, { method: 'POST', headers, body: JSON.stringify(body) }, timeoutMs);
485
+ } catch (err) {
486
+ if (isTimeoutError(err)) {
487
+ throw providerError(PROVIDER_ERROR_CODES.TIMEOUT, `${label} specialist execution timed out after ${timeoutMs}ms`, `${label} did not respond in time and retries were exhausted; raise CONSTRUCT_PROVIDER_TIMEOUT_MS or switch providers.`, { retryable: true });
488
+ }
489
+ throw err;
490
+ }
491
+ if (!res.ok) {
492
+ const { code, retryable } = classifyHttpFailure(res.status);
493
+ throw providerError(code, `${label} specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, remediationForCode(code, label, keyVar), { retryable });
494
+ }
495
+ return res.json();
496
+ }, { maxAttempts, baseDelayMs });
497
+ return { data, elapsedMs: Date.now() - startedAt, retryCount: data.retryCount ?? 0 };
498
+ }
499
+
500
+ // Anthropic's own stop-reason vocabulary (end_turn/max_tokens/stop_sequence/
501
+ // tool_use/refusal) maps onto the shared content-outcome codes so
502
+ // classifyContentOutcome can treat every provider family the same way.
503
+
504
+ function normalizeAnthropicStopReason(stopReason) {
505
+ if (stopReason === 'max_tokens') return 'length';
506
+ if (stopReason === 'refusal') return 'content_filter';
507
+ return stopReason ?? null;
508
+ }
509
+
510
+ async function callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning, timeoutMs, env }) {
161
511
  const thinking = reasoning ? anthropicThinkingConfig(model) : null;
162
512
  const body = {
163
513
  model: model.replace(/^anthropic\//, ''),
@@ -166,78 +516,113 @@ async function callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning
166
516
  messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
167
517
  };
168
518
  if (thinking) body.thinking = thinking;
169
- const res = await timedFetch(fetchImpl, 'https://api.anthropic.com/v1/messages', {
170
- method: 'POST',
171
- headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
172
- body: JSON.stringify(body),
173
- }, timeoutMs);
174
- if (!res.ok) {
175
- throw providerError('PROVIDER_EXECUTION_FAILED', `Anthropic specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set orchestration.workerBackend to "inline".');
519
+ const { data, elapsedMs, retryCount } = await postJsonRetryable(
520
+ fetchImpl, 'https://api.anthropic.com/v1/messages',
521
+ { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
522
+ body, timeoutMs, { label: 'Anthropic', keyVar: 'ANTHROPIC_API_KEY', env },
523
+ );
524
+
525
+ if (!Array.isArray(data.content)) {
526
+ throw providerError(PROVIDER_ERROR_CODES.MALFORMED_RESPONSE, 'Anthropic response is missing content blocks', 'Retry, or set orchestration.workerBackend to "inline".', {
527
+ meta: { provider: 'anthropic', model, elapsedMs, retryCount },
528
+ });
176
529
  }
177
- const data = await res.json();
178
- const blocks = data.content || [];
530
+ const blocks = data.content;
179
531
  const output = blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('');
180
532
  const thought = blocks.filter((b) => b && b.type === 'thinking').map((b) => b.thinking || '').join('').trim();
181
- return { output, reasoning: thought };
533
+ const finishReason = normalizeAnthropicStopReason(data.stop_reason);
534
+ const usage = data.usage ? {
535
+ promptTokens: data.usage.input_tokens,
536
+ completionTokens: data.usage.output_tokens,
537
+ totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
538
+ } : null;
539
+ const meta = {
540
+ provider: 'anthropic', model, finishReason, nativeFinishReason: data.stop_reason ?? null, usage,
541
+ elapsedMs, retryCount, reasoningRequested: Boolean(reasoning), reasoningReturned: Boolean(thought),
542
+ };
543
+ const outcome = classifyContentOutcome({ content: output, finishReason, reasoning: thought, wantsReasoning: reasoning });
544
+ if (!outcome.ok) {
545
+ throw providerError(outcome.code, `Anthropic returned no usable answer (${outcome.code}, finish_reason=${finishReason})`, contentOutcomeRemediation(outcome.code), { retryable: outcome.retryable, meta });
546
+ }
547
+ return { output, reasoning: thought, meta };
182
548
  }
183
549
 
184
- async function callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning, timeoutMs }) {
550
+ async function callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning, timeoutMs, env }) {
185
551
  const body = {
186
552
  model: model.replace(/^openrouter\//, ''),
187
- max_tokens: MAX_OUTPUT_TOKENS,
553
+ // Reasoning tokens draw from the same completion budget on OpenRouter as
554
+ // the visible answer — without the extra headroom a reasoning-heavy model
555
+ // can spend the entire budget on reasoning and return empty content
556
+ // (construct-5wkl AC#6), mirroring the Anthropic thinking-budget headroom.
557
+ max_tokens: reasoning ? MAX_OUTPUT_TOKENS + REASONING_BUDGET_TOKENS : MAX_OUTPUT_TOKENS,
188
558
  messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
189
559
  };
190
560
  // Enable reasoning when wanted; explicitly exclude it otherwise — some models
191
561
  // (e.g. Gemini) return reasoning by default, which would leak in hidden mode.
192
562
  // An empty `{}` is a no-op on OpenRouter; `{enabled:true}` is the on switch.
193
563
  body.reasoning = reasoning ? { enabled: true } : { exclude: true };
194
- const res = await timedFetch(fetchImpl, 'https://openrouter.ai/api/v1/chat/completions', {
195
- method: 'POST',
196
- headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/geraldmaron/construct' },
197
- body: JSON.stringify(body),
198
- }, timeoutMs);
199
- if (!res.ok) {
200
- throw providerError('PROVIDER_EXECUTION_FAILED', `OpenRouter specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set orchestration.workerBackend to "inline".');
201
- }
202
- const data = await res.json();
203
- const message = data.choices?.[0]?.message || {};
204
- return { output: message.content || '', reasoning: reasoning ? extractOpenRouterReasoning(message) : '' };
564
+ const { data, elapsedMs, retryCount } = await postJsonRetryable(
565
+ fetchImpl, 'https://openrouter.ai/api/v1/chat/completions',
566
+ { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/geraldmaron/construct' },
567
+ body, timeoutMs, { label: 'OpenRouter', keyVar: 'OPENROUTER_API_KEY', env },
568
+ );
569
+ return finishOpenAiStyleCall(data, { provider: 'openrouter', model, elapsedMs, retryCount, reasoning, label: 'OpenRouter' });
205
570
  }
206
571
 
207
- async function callCopilot({ model, system, user, fetchImpl, timeoutMs }) {
572
+ async function callCopilot({ model, system, user, fetchImpl, timeoutMs, env }) {
208
573
  const { getCopilotToken, copilotApiHeaders, COPILOT_API_BASE } = await import('../providers/copilot-auth.mjs');
209
574
  const token = await getCopilotToken({ fetchImpl });
210
- const res = await timedFetch(fetchImpl, `${COPILOT_API_BASE}/chat/completions`, {
211
- method: 'POST',
212
- headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json', ...copilotApiHeaders() },
213
- body: JSON.stringify({
214
- model: model.replace(/^github-copilot\//, ''),
215
- messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
216
- }),
217
- }, timeoutMs);
218
- if (!res.ok) {
219
- throw providerError('PROVIDER_EXECUTION_FAILED', `GitHub Copilot specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Run `construct creds login copilot`, or set orchestration.workerBackend to "inline".');
575
+ const { data, elapsedMs, retryCount } = await postJsonRetryable(
576
+ fetchImpl, `${COPILOT_API_BASE}/chat/completions`,
577
+ { Authorization: `Bearer ${token}`, 'content-type': 'application/json', ...copilotApiHeaders() },
578
+ { model: model.replace(/^github-copilot\//, ''), messages: [{ role: 'system', content: system }, { role: 'user', content: user }] },
579
+ timeoutMs, { label: 'GitHub Copilot', keyVar: 'a Copilot login', env },
580
+ );
581
+ return finishOpenAiStyleCall(data, { provider: 'github-copilot', model, elapsedMs, retryCount, reasoning: false, label: 'GitHub Copilot' });
582
+ }
583
+
584
+ async function callOpenAI({ model, apiKey, system, user, fetchImpl, timeoutMs, env }) {
585
+ const { data, elapsedMs, retryCount } = await postJsonRetryable(
586
+ fetchImpl, 'https://api.openai.com/v1/chat/completions',
587
+ { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
588
+ { model: model.replace(/^openai\//, ''), messages: [{ role: 'system', content: system }, { role: 'user', content: user }] },
589
+ timeoutMs, { label: 'OpenAI', keyVar: 'OPENAI_API_KEY', env },
590
+ );
591
+ return finishOpenAiStyleCall(data, { provider: 'openai', model, elapsedMs, retryCount, reasoning: false, label: 'OpenAI' });
592
+ }
593
+
594
+ function contentOutcomeRemediation(code) {
595
+ if (code === PROVIDER_ERROR_CODES.CONTENT_FILTERED) return 'The provider refused on content-policy grounds; rephrase the task or route it to a different model — retrying the same request will not help.';
596
+ if (code === PROVIDER_ERROR_CODES.REASONING_ONLY) return 'The model spent its entire output budget on reasoning and returned no visible answer; this should not recur now that the reasoning budget reserves headroom for the answer, but if it does, raise the output-token ceiling.';
597
+ if (code === PROVIDER_ERROR_CODES.EMPTY_CONTENT) return 'The provider returned a 2xx response with no usable answer content; retry, or switch providers/models.';
598
+ return 'Retry, or set orchestration.workerBackend to "inline".';
599
+ }
600
+
601
+ // Shared tail for every OpenAI-shaped (choices[0].message) response:
602
+ // OpenRouter, OpenAI, and GitHub Copilot all use this shape. Malformed-choice,
603
+ // finish_reason, usage, and reasoning-only classification are identical across
604
+ // the three; only the reasoning-extraction source differs (OpenRouter alone
605
+ // returns a `reasoning`/`reasoning_details` field).
606
+
607
+ function finishOpenAiStyleCall(data, { provider, model, elapsedMs, retryCount, reasoning, label }) {
608
+ if (isMalformedChoice(data)) {
609
+ throw providerError(PROVIDER_ERROR_CODES.MALFORMED_RESPONSE, `${label} response is missing choices[0].message`, 'Retry, or set orchestration.workerBackend to "inline".', {
610
+ meta: { provider, model, elapsedMs, retryCount },
611
+ });
220
612
  }
221
- const data = await res.json();
222
- const message = data.choices?.[0]?.message || {};
223
- return { output: message.content || '', reasoning: '' };
224
- }
225
-
226
- async function callOpenAI({ model, apiKey, system, user, fetchImpl, timeoutMs }) {
227
- const res = await timedFetch(fetchImpl, 'https://api.openai.com/v1/chat/completions', {
228
- method: 'POST',
229
- headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
230
- body: JSON.stringify({
231
- model: model.replace(/^openai\//, ''),
232
- messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
233
- }),
234
- }, timeoutMs);
235
- if (!res.ok) {
236
- throw providerError('PROVIDER_EXECUTION_FAILED', `OpenAI specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENAI_API_KEY, or set orchestration.workerBackend to "inline".');
613
+ const { message, finishReason, nativeFinishReason } = extractChoiceMeta(data);
614
+ const output = message.content || '';
615
+ const thought = provider === 'openrouter' && reasoning ? extractOpenRouterReasoning(message) : '';
616
+ const usage = extractUsage(data);
617
+ const meta = {
618
+ provider, model, finishReason, nativeFinishReason, usage, elapsedMs, retryCount,
619
+ reasoningRequested: Boolean(reasoning), reasoningReturned: Boolean(thought),
620
+ };
621
+ const outcome = classifyContentOutcome({ content: output, finishReason, reasoning: thought, wantsReasoning: reasoning });
622
+ if (!outcome.ok) {
623
+ throw providerError(outcome.code, `${label} returned no usable answer (${outcome.code}, finish_reason=${finishReason})`, contentOutcomeRemediation(outcome.code), { retryable: outcome.retryable, meta });
237
624
  }
238
- const data = await res.json();
239
- const message = data.choices?.[0]?.message || {};
240
- return { output: message.content || '', reasoning: '' };
625
+ return { output, reasoning: thought, meta };
241
626
  }
242
627
 
243
628
  // ── Web-capable specialist execution (ADR-0050) ──────────────────────────────
@@ -411,18 +796,18 @@ async function runNativeOpenRouter({ model, apiKey, system, user, fetchImpl, tim
411
796
  // Single-shot honest fallback: no web tool, the no-web clause appended to the system
412
797
  // prompt so the specialist refuses rather than fabricates.
413
798
 
414
- async function runHonestNoWeb({ family, model, apiKey, system, user, fetchImpl, timeoutMs }) {
799
+ async function runHonestNoWeb({ family, model, apiKey, system, user, fetchImpl, timeoutMs, env }) {
415
800
  const honestSystem = system + NO_WEB_CLAUSE;
416
- if (family === 'github-copilot') return callCopilot({ model, system: honestSystem, user, fetchImpl, timeoutMs });
417
- if (family === 'anthropic') return callAnthropic({ model, apiKey, system: honestSystem, user, fetchImpl, reasoning: false, timeoutMs });
418
- if (family === 'openai') return callOpenAI({ model, apiKey, system: honestSystem, user, fetchImpl, timeoutMs });
419
- return callOpenRouter({ model, apiKey, system: honestSystem, user, fetchImpl, reasoning: false, timeoutMs });
801
+ if (family === 'github-copilot') return callCopilot({ model, system: honestSystem, user, fetchImpl, timeoutMs, env });
802
+ if (family === 'anthropic') return callAnthropic({ model, apiKey, system: honestSystem, user, fetchImpl, reasoning: false, timeoutMs, env });
803
+ if (family === 'openai') return callOpenAI({ model, apiKey, system: honestSystem, user, fetchImpl, timeoutMs, env });
804
+ return callOpenRouter({ model, apiKey, system: honestSystem, user, fetchImpl, reasoning: false, timeoutMs, env });
420
805
  }
421
806
 
422
807
  async function runWebCapableTask({ family, model, apiKey, system, user, fetchImpl, env, timeoutMs, now, defaultClaim }) {
423
808
  const grant = resolveWebCapability({ family, env });
424
- const maxRounds = Number(env.CONSTRUCT_WORKER_TOOL_ROUNDS) || 4;
425
- const loopCap = Number(env.CONSTRUCT_WORKER_WEB_LOOP_CAP) || maxRounds;
809
+ const maxRounds = resolveNonNegativeSetting(env, 'CONSTRUCT_WORKER_TOOL_ROUNDS', 4);
810
+ const loopCap = resolveNonNegativeSetting(env, 'CONSTRUCT_WORKER_WEB_LOOP_CAP', maxRounds);
426
811
 
427
812
  if (grant.mode === 'governed') {
428
813
  if (family === 'anthropic') {
@@ -450,8 +835,8 @@ async function runWebCapableTask({ family, model, apiKey, system, user, fetchImp
450
835
  }
451
836
 
452
837
  // host-delegated and unavailable both mean this worker cannot fetch: answer honestly.
453
- const r = await runHonestNoWeb({ family, model, apiKey, system, user, fetchImpl, timeoutMs });
454
- return { output: r.output, reasoning: '', webCapability: grant.mode === 'host-delegated' ? 'host-delegated' : 'unavailable', webEvidence: [], webCalls: 0, webSearchRequests: 0 };
838
+ const r = await runHonestNoWeb({ family, model, apiKey, system, user, fetchImpl, timeoutMs, env });
839
+ return { output: r.output, reasoning: '', meta: r.meta, webCapability: grant.mode === 'host-delegated' ? 'host-delegated' : 'unavailable', webEvidence: [], webCalls: 0, webSearchRequests: 0 };
455
840
  }
456
841
 
457
842
  /**
@@ -467,16 +852,33 @@ async function runWebCapableTask({ family, model, apiKey, system, user, fetchImp
467
852
  * @param {string} [opts.chainOfThought] reasoning disclosure mode: `hidden` (default,
468
853
  * no reasoning requested) | `surface` | `telemetry_only` (both request and
469
854
  * return the model's reasoning for the caller to display or record)
470
- * @returns {Promise<{output:string, reasoning:string, model:string, provider:string, characters:number}>}
855
+ * @param {string} [opts.cwd] project root, for project-tier pack resolution (.cx/packs/)
856
+ * @returns {Promise<{output:string, reasoning:string, model:string, provider:string, characters:number,
857
+ * personaAvailable:boolean, degraded?:string, specialistId:string, packId:string|null,
858
+ * promptVersion:string, toolGrants:string[], executionState:string, contractStatus:string,
859
+ * contractId?:string, contractViolations?:string[]}>}
471
860
  */
472
- export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch, chainOfThought = 'hidden' } = {}) {
861
+ export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch, chainOfThought = 'hidden', cwd = process.cwd() } = {}) {
473
862
  if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider worker backend selected but no model resolved.', 'Configure the model tier registry so a model resolves, or set orchestration.workerBackend to "inline".');
474
863
  if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider execution.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
475
864
 
476
- const timeoutMs = Number(env.CONSTRUCT_PROVIDER_TIMEOUT_MS) || 200;
865
+ // Packet validation runs before any model call: an invalid handoff must never
866
+ // reach the provider (LMCP-F2). Throws CONTRACT_VIOLATION_INPUT, caught by
867
+ // the same path that already handles PROVIDER_KEY_MISSING etc.
868
+ validateInputPacket(task, { cwd });
869
+
870
+ // The shared resolver honors an explicit 0 (near-instant abort) and falls back to the
871
+ // minute-scale default for unset/empty/garbage/negative values — a bare `Number(env)`
872
+ // would turn "abc" into NaN and every real provider call would abort immediately.
873
+ const timeoutMs = resolveNonNegativeSetting(env, 'CONSTRUCT_PROVIDER_TIMEOUT_MS', PROVIDER_TIMEOUT_DEFAULT_MS);
477
874
  const family = classifyWorkerProvider(provider, model);
478
- const system = loadPersona(task?.role);
479
- const user = buildUserPrompt({ task, run });
875
+ // The same materialization the host worker backend uses (LMCP host-execution):
876
+ // persona + user turn + provenance, resolved once here and handed to the model
877
+ // call below — a provider-executed task and a host-executed task run under
878
+ // byte-identical prompts for the same task.
879
+ const prompt = materializeTaskPrompt({ task, run, cwd, env });
880
+ const system = prompt.system;
881
+ const user = prompt.user;
480
882
  const wantsReasoning = chainOfThought !== 'hidden';
481
883
  const webCapable = roleHoldsWebCapability(task?.role);
482
884
  const now = Date.now();
@@ -486,7 +888,7 @@ export async function runTaskViaProvider({ task, run, model, provider = null, en
486
888
  let apiKey = null;
487
889
  if (family !== 'github-copilot') {
488
890
  const keyVar = WORKER_KEY_VAR[family];
489
- apiKey = resolveKey(keyVar, env, env === process.env);
891
+ apiKey = await resolveKey(keyVar, env, env === process.env);
490
892
  if (!apiKey) {
491
893
  throw providerError('PROVIDER_KEY_MISSING', `No API key for ${WORKER_PROVIDER_LABEL[family]} specialist execution.`, `Set ${keyVar} (a value or an op:// reference), or set orchestration.workerBackend to "inline".`);
492
894
  }
@@ -494,27 +896,64 @@ export async function runTaskViaProvider({ task, run, model, provider = null, en
494
896
 
495
897
  let output;
496
898
  let reasoning;
899
+ let meta = null;
497
900
  let web = null;
498
901
  if (webCapable) {
499
902
  const defaultClaim = String(run?.request?.summary || run?.request || task?.reason || 'the subject under research').slice(0, 200);
500
903
  web = await runWebCapableTask({ family, model, apiKey, system, user, fetchImpl, env, timeoutMs, now, defaultClaim });
501
- ({ output, reasoning } = web);
904
+ ({ output, reasoning, meta } = web);
502
905
  } else if (family === 'github-copilot') {
503
- ({ output, reasoning } = await callCopilot({ model, system, user, fetchImpl, timeoutMs }));
906
+ ({ output, reasoning, meta } = await callCopilot({ model, system, user, fetchImpl, timeoutMs, env }));
504
907
  } else {
505
908
  const call = family === 'anthropic' ? callAnthropic
506
909
  : family === 'openai' ? callOpenAI
507
910
  : callOpenRouter;
508
- ({ output, reasoning } = await call({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning, timeoutMs }));
911
+ ({ output, reasoning, meta } = await call({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning, timeoutMs, env }));
509
912
  }
510
913
 
511
914
  const text = output || '';
915
+ const toolGrants = prompt.toolGrants;
916
+ // executionState is task-scoped here: the provider call above either returned
917
+ // (this function only reaches this line on success) or threw (the caller,
918
+ // runtime.mjs executeTaskViaProvider, catches it and records 'failed' itself).
919
+ // A degraded persona still genuinely executed the model call, so it is
920
+ // 'degraded-executed' rather than 'failed' — the run did not fail, the
921
+ // specialist prompt it ran under was the visible fallback.
922
+ const executionState = prompt.personaAvailable ? 'executed' : 'degraded-executed';
923
+
924
+ // Output validation runs after the provider call and never throws: a
925
+ // contract-failed output is still real, already-paid-for model output, so it
926
+ // rides the result with contractStatus rather than being discarded
927
+ // (LMCP-F2). Checked only when the task opted in via task.outputPacket.
928
+ const outputCheck = validateOutputPacket(task, { cwd });
929
+
930
+ // Evidence grounding (construct-5wkl AC#5): a web-capable specialist's only
931
+ // observed evidence is its own governed webEvidence (ADR-0050 trust/Admiralty
932
+ // labeling). A URL cited in the answer that is not among that evidence was
933
+ // never actually retrieved through Construct's governed path — either
934
+ // fabricated or recalled from ungoverned model memory — so the output is
935
+ // downgraded with a warning rather than presented as verified evidence.
936
+ const evidenceUrls = web?.webEvidence ? web.webEvidence.map((e) => e?.url).filter(Boolean) : null;
937
+ const unverifiedCitations = evidenceUrls ? findUnverifiedCitations(text, evidenceUrls) : [];
938
+
512
939
  return {
513
940
  output: text,
514
941
  reasoning: wantsReasoning ? (reasoning || '') : '',
515
942
  model,
516
943
  provider: family,
517
944
  characters: text.length,
945
+ personaAvailable: prompt.personaAvailable,
946
+ specialistId: prompt.specialistId,
947
+ packId: prompt.packId,
948
+ promptVersion: prompt.promptVersion,
949
+ toolGrants,
950
+ executionState,
951
+ contractStatus: outputCheck.contractStatus,
952
+ ...(outputCheck.checked ? { contractId: outputCheck.contractId } : {}),
953
+ ...(outputCheck.contractStatus === 'contract-failed' ? { contractViolations: outputCheck.violations } : {}),
954
+ ...(prompt.degraded ? { degraded: prompt.degraded } : {}),
518
955
  ...(web ? { webCapability: web.webCapability, webEvidence: web.webEvidence || [], webCalls: web.webCalls || 0, webSearchRequests: web.webSearchRequests || 0 } : {}),
956
+ ...(unverifiedCitations.length ? { evidenceStatus: 'unverified-citations', unverifiedCitations } : {}),
957
+ ...(meta ? { providerMeta: meta } : {}),
519
958
  };
520
959
  }