@webpresso/agent-kit 3.1.30 → 3.2.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.

Potentially problematic release.


This version of @webpresso/agent-kit might be problematic. Click here for more details.

Files changed (257) hide show
  1. package/README.md +53 -15
  2. package/bin/_run.js +179 -19
  3. package/catalog/AGENTS.md.tpl +3 -3
  4. package/catalog/agent/agents/implementer.md +45 -0
  5. package/catalog/agent/rules/parallel-orchestration-quota.md +100 -0
  6. package/catalog/agent/rules/pre-implementation.md +28 -1
  7. package/catalog/agent/rules/supported-agent-clis.md +9 -0
  8. package/catalog/agent/rules/typescript-version.md +50 -0
  9. package/catalog/agent/skills/codex/SKILL.md +25 -1
  10. package/catalog/agent/skills/verify/SKILL.md +4 -2
  11. package/catalog/docs/templates/blueprint.md +1 -1
  12. package/dist/esm/audit/agents.js +1 -0
  13. package/dist/esm/audit/blueprint-lifecycle-sql.d.ts +12 -0
  14. package/dist/esm/audit/blueprint-lifecycle-sql.js +23 -2
  15. package/dist/esm/audit/blueprint-pr-coverage.js +4 -0
  16. package/dist/esm/audit/ci-guardrails-wiring.d.ts +22 -0
  17. package/dist/esm/audit/ci-guardrails-wiring.js +90 -0
  18. package/dist/esm/audit/ci-test-perf.js +30 -0
  19. package/dist/esm/audit/command-surface.js +4 -2
  20. package/dist/esm/audit/host-substitution-risk.d.ts +2 -0
  21. package/dist/esm/audit/host-substitution-risk.js +256 -0
  22. package/dist/esm/audit/registry.d.ts +4 -4
  23. package/dist/esm/audit/registry.js +8 -1
  24. package/dist/esm/audit/toolchain-isolation.js +1 -2
  25. package/dist/esm/audit/typescript-version.d.ts +2 -0
  26. package/dist/esm/audit/typescript-version.js +123 -0
  27. package/dist/esm/blueprint/core/parser.js +43 -1
  28. package/dist/esm/blueprint/core/validation/criteria.d.ts +14 -0
  29. package/dist/esm/blueprint/core/validation/criteria.js +41 -2
  30. package/dist/esm/blueprint/core/validation/state.js +25 -14
  31. package/dist/esm/blueprint/db/migrations/run.js +4 -2
  32. package/dist/esm/blueprint/lifecycle/audit.d.ts +20 -0
  33. package/dist/esm/blueprint/lifecycle/audit.js +65 -10
  34. package/dist/esm/blueprint/lifecycle/canonicalize.d.ts +6 -0
  35. package/dist/esm/blueprint/lifecycle/canonicalize.js +135 -0
  36. package/dist/esm/blueprint/lifecycle/local.d.ts +2 -1
  37. package/dist/esm/blueprint/lifecycle/local.js +19 -3
  38. package/dist/esm/blueprint/lifecycle/review-provenance.js +36 -11
  39. package/dist/esm/blueprint/service/BlueprintCreationService.d.ts +1 -0
  40. package/dist/esm/blueprint/service/BlueprintCreationService.js +53 -4
  41. package/dist/esm/blueprint/service/blueprint-tech-debt-links.js +16 -2
  42. package/dist/esm/blueprint/trust/command-runner.d.ts +26 -0
  43. package/dist/esm/blueprint/trust/command-runner.js +148 -0
  44. package/dist/esm/blueprint/trust/gates.d.ts +7 -0
  45. package/dist/esm/blueprint/trust/gates.js +20 -0
  46. package/dist/esm/blueprint/trust/promotion.d.ts +8 -0
  47. package/dist/esm/blueprint/trust/promotion.js +62 -112
  48. package/dist/esm/blueprint/trust/scaffold.d.ts +19 -0
  49. package/dist/esm/blueprint/trust/scaffold.js +64 -0
  50. package/dist/esm/blueprint/trust/validator.d.ts +1 -0
  51. package/dist/esm/blueprint/trust/validator.js +7 -1
  52. package/dist/esm/blueprint/utils/archive.js +27 -5
  53. package/dist/esm/blueprint/utils/document-paths.d.ts +18 -0
  54. package/dist/esm/blueprint/utils/document-paths.js +45 -12
  55. package/dist/esm/build/cli-mcp-parity.js +10 -0
  56. package/dist/esm/cli/bundle/commands/blueprint.d.ts +1 -0
  57. package/dist/esm/cli/bundle/commands/blueprint.js +1 -0
  58. package/dist/esm/cli/cli.d.ts +1 -1
  59. package/dist/esm/cli/cli.js +34 -0
  60. package/dist/esm/cli/commands/agent-launch.d.ts +6 -1
  61. package/dist/esm/cli/commands/agent-launch.js +82 -4
  62. package/dist/esm/cli/commands/audit-core.d.ts +1 -1
  63. package/dist/esm/cli/commands/audit-core.js +8 -1
  64. package/dist/esm/cli/commands/audit.js +17 -5
  65. package/dist/esm/cli/commands/blueprint/execution.js +18 -6
  66. package/dist/esm/cli/commands/blueprint/mutations.js +59 -40
  67. package/dist/esm/cli/commands/blueprint/router-dispatch.d.ts +2 -1
  68. package/dist/esm/cli/commands/blueprint/router-dispatch.js +20 -1
  69. package/dist/esm/cli/commands/blueprint/router-output.d.ts +21 -0
  70. package/dist/esm/cli/commands/blueprint/router-output.js +48 -17
  71. package/dist/esm/cli/commands/blueprint/router.d.ts +7 -0
  72. package/dist/esm/cli/commands/blueprint/router.js +13 -1
  73. package/dist/esm/cli/commands/ci-preflight.d.ts +13 -2
  74. package/dist/esm/cli/commands/ci-preflight.js +6 -3
  75. package/dist/esm/cli/commands/compile.js +5 -31
  76. package/dist/esm/cli/commands/dash/delivery-status.d.ts +1 -0
  77. package/dist/esm/cli/commands/dash/delivery-status.js +11 -4
  78. package/dist/esm/cli/commands/doctor.js +5 -0
  79. package/dist/esm/cli/commands/hook.d.ts +5 -0
  80. package/dist/esm/cli/commands/hook.js +15 -9
  81. package/dist/esm/cli/commands/init/config.js +2 -2
  82. package/dist/esm/cli/commands/init/convergence-apply.d.ts +24 -0
  83. package/dist/esm/cli/commands/init/convergence-apply.js +221 -0
  84. package/dist/esm/cli/commands/init/convergence-state.d.ts +13 -0
  85. package/dist/esm/cli/commands/init/convergence-state.js +54 -0
  86. package/dist/esm/cli/commands/init/convergence.d.ts +23 -0
  87. package/dist/esm/cli/commands/init/convergence.js +57 -0
  88. package/dist/esm/cli/commands/init/git-convergence.d.ts +19 -0
  89. package/dist/esm/cli/commands/init/git-convergence.js +168 -0
  90. package/dist/esm/cli/commands/init/gitignore-patcher.js +2 -0
  91. package/dist/esm/cli/commands/init/grok-privacy-smoke.d.ts +52 -0
  92. package/dist/esm/cli/commands/init/grok-privacy-smoke.js +91 -0
  93. package/dist/esm/cli/commands/init/host-native-proof.d.ts +18 -0
  94. package/dist/esm/cli/commands/init/host-native-proof.js +88 -0
  95. package/dist/esm/cli/commands/init/host-visibility.d.ts +2 -2
  96. package/dist/esm/cli/commands/init/host-visibility.js +94 -5
  97. package/dist/esm/cli/commands/init/index.d.ts +1 -0
  98. package/dist/esm/cli/commands/init/index.js +326 -173
  99. package/dist/esm/cli/commands/init/mcp-spec.d.ts +57 -0
  100. package/dist/esm/cli/commands/init/mcp-spec.js +273 -0
  101. package/dist/esm/cli/commands/init/merge.d.ts +1 -0
  102. package/dist/esm/cli/commands/init/merge.js +30 -1
  103. package/dist/esm/cli/commands/init/plugin-cache-prune.d.ts +0 -3
  104. package/dist/esm/cli/commands/init/plugin-cache-prune.js +0 -1
  105. package/dist/esm/cli/commands/init/scaffold-agents-md.d.ts +1 -1
  106. package/dist/esm/cli/commands/init/scaffold-agents-md.js +9 -3
  107. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +11 -0
  108. package/dist/esm/cli/commands/init/scaffold-base-kit.js +56 -1
  109. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/grok.d.ts +14 -8
  110. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/grok.js +41 -24
  111. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.d.ts +2 -0
  112. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +51 -23
  113. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +12 -31
  114. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +288 -202
  115. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +7 -0
  116. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +58 -0
  117. package/dist/esm/cli/commands/init/scaffolders/grok-mcp/index.d.ts +38 -0
  118. package/dist/esm/cli/commands/init/scaffolders/grok-mcp/index.js +73 -0
  119. package/dist/esm/cli/commands/init/scaffolders/opencode-plugin/index.d.ts +21 -0
  120. package/dist/esm/cli/commands/init/scaffolders/opencode-plugin/index.js +559 -63
  121. package/dist/esm/cli/commands/init/untracked-collision.d.ts +14 -0
  122. package/dist/esm/cli/commands/init/untracked-collision.js +42 -0
  123. package/dist/esm/cli/commands/opencode-rotate.d.ts +49 -0
  124. package/dist/esm/cli/commands/opencode-rotate.js +135 -0
  125. package/dist/esm/cli/commands/public-readiness.d.ts +11 -0
  126. package/dist/esm/cli/commands/public-readiness.js +42 -0
  127. package/dist/esm/cli/commands/review.d.ts +35 -3
  128. package/dist/esm/cli/commands/review.js +729 -209
  129. package/dist/esm/cli/commands/run-wait.d.ts +2 -0
  130. package/dist/esm/cli/commands/run-wait.js +122 -0
  131. package/dist/esm/cli/commands/setup-help.js +1 -1
  132. package/dist/esm/cli/commands/setup-repair-help.js +1 -1
  133. package/dist/esm/cli/commands/worktree/router-dispatch.js +13 -8
  134. package/dist/esm/cli/direct-provider-launch.js +9 -5
  135. package/dist/esm/cli/optional-tool-freshness.d.ts +27 -0
  136. package/dist/esm/cli/optional-tool-freshness.js +219 -9
  137. package/dist/esm/daemon/domains/git-actions.d.ts +29 -0
  138. package/dist/esm/daemon/domains/git-actions.js +136 -0
  139. package/dist/esm/daemon/domains/run-registry-interop.d.ts +16 -0
  140. package/dist/esm/daemon/domains/run-registry-interop.js +45 -0
  141. package/dist/esm/daemon/domains/sessions-search.d.ts +11 -0
  142. package/dist/esm/daemon/domains/sessions-search.js +29 -0
  143. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +5 -0
  144. package/dist/esm/daemon/handlers/blueprints-handlers.js +36 -0
  145. package/dist/esm/daemon/handlers/deploy-handlers.d.ts +6 -0
  146. package/dist/esm/daemon/handlers/deploy-handlers.js +5 -0
  147. package/dist/esm/daemon/handlers/fleet-handlers.d.ts +15 -0
  148. package/dist/esm/daemon/handlers/fleet-handlers.js +91 -0
  149. package/dist/esm/daemon/handlers/git-handlers.d.ts +6 -0
  150. package/dist/esm/daemon/handlers/git-handlers.js +27 -0
  151. package/dist/esm/daemon/handlers/product.d.ts +7 -0
  152. package/dist/esm/daemon/handlers/product.js +10 -119
  153. package/dist/esm/daemon/handlers/sessions-handlers.d.ts +12 -0
  154. package/dist/esm/daemon/handlers/sessions-handlers.js +31 -0
  155. package/dist/esm/daemon/handlers/shared.d.ts +5 -0
  156. package/dist/esm/daemon/handlers/shared.js +16 -0
  157. package/dist/esm/daemon/methods.d.ts +8 -1
  158. package/dist/esm/daemon/methods.js +7 -0
  159. package/dist/esm/daemon/protocol/contract.d.ts +43 -0
  160. package/dist/esm/daemon/protocol/contract.js +26 -0
  161. package/dist/esm/daemon/protocol/index.d.ts +1 -1
  162. package/dist/esm/daemon/protocol/index.js +1 -1
  163. package/dist/esm/hooks/__conformance__/matrix.d.ts +6 -0
  164. package/dist/esm/hooks/__conformance__/matrix.js +137 -0
  165. package/dist/esm/hooks/conformance/batch.js +5 -0
  166. package/dist/esm/hooks/doctor.d.ts +3 -3
  167. package/dist/esm/hooks/doctor.js +30 -15
  168. package/dist/esm/hooks/permission-request/index.js +9 -3
  169. package/dist/esm/hooks/permission-request/policy.d.ts +9 -0
  170. package/dist/esm/hooks/permission-request/policy.js +13 -0
  171. package/dist/esm/hooks/pretool-guard/dev-routing.d.ts +9 -0
  172. package/dist/esm/hooks/pretool-guard/dev-routing.js +41 -5
  173. package/dist/esm/hooks/pretool-guard/logger.d.ts +7 -0
  174. package/dist/esm/hooks/pretool-guard/logger.js +16 -0
  175. package/dist/esm/hooks/pretool-guard/runner.d.ts +6 -0
  176. package/dist/esm/hooks/pretool-guard/runner.js +68 -16
  177. package/dist/esm/hooks/pretool-guard/validators/blueprint.js +30 -10
  178. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +54 -0
  179. package/dist/esm/hooks/pretool-guard/validators/index.d.ts +7 -0
  180. package/dist/esm/hooks/pretool-guard/validators/index.js +21 -0
  181. package/dist/esm/hooks/pretool-guard/validators/path-contract.d.ts +19 -0
  182. package/dist/esm/hooks/pretool-guard/validators/path-contract.js +25 -8
  183. package/dist/esm/hooks/shared/guard-outcome.d.ts +149 -0
  184. package/dist/esm/hooks/shared/guard-outcome.js +215 -0
  185. package/dist/esm/hooks/shared/hook-bootstrap.d.ts +26 -0
  186. package/dist/esm/hooks/shared/hook-bootstrap.js +64 -1
  187. package/dist/esm/hooks/shared/types.d.ts +8 -5
  188. package/dist/esm/hooks/shared/types.js +23 -8
  189. package/dist/esm/hooks/stop/qa-changed-files.d.ts +11 -0
  190. package/dist/esm/hooks/stop/qa-changed-files.js +74 -0
  191. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +14 -1
  192. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +78 -4
  193. package/dist/esm/mcp/blueprint/handlers/review-log.js +8 -1
  194. package/dist/esm/mcp/blueprint/handlers/task-advance.js +113 -54
  195. package/dist/esm/mcp/blueprint/handlers/task-verify.js +39 -19
  196. package/dist/esm/mcp/blueprint/registration.js +3 -2
  197. package/dist/esm/mcp/server.d.ts +1 -0
  198. package/dist/esm/mcp/server.js +27 -0
  199. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  200. package/dist/esm/mcp/tools/_names.js +5 -0
  201. package/dist/esm/mcp/tools/_registry.js +10 -0
  202. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  203. package/dist/esm/mcp/tools/_shared/result.js +44 -1
  204. package/dist/esm/mcp/tools/audit.d.ts +4 -1
  205. package/dist/esm/mcp/tools/audit.js +30 -0
  206. package/dist/esm/mcp/tools/audits.d.ts +3 -0
  207. package/dist/esm/mcp/tools/ci-preflight.d.ts +50 -0
  208. package/dist/esm/mcp/tools/ci-preflight.js +183 -0
  209. package/dist/esm/mcp/tools/pr-upsert.js +39 -1
  210. package/dist/esm/mcp/tools/review-run.d.ts +28 -0
  211. package/dist/esm/mcp/tools/review-run.js +216 -0
  212. package/dist/esm/mcp/tools/run-wait.d.ts +86 -0
  213. package/dist/esm/mcp/tools/run-wait.js +593 -0
  214. package/dist/esm/mcp/tools/session-id.d.ts +3 -0
  215. package/dist/esm/mcp/tools/session-id.js +45 -0
  216. package/dist/esm/mcp/tools/session-info.d.ts +3 -0
  217. package/dist/esm/mcp/tools/session-info.js +49 -0
  218. package/dist/esm/output-transforms/audit.d.ts +23 -0
  219. package/dist/esm/output-transforms/audit.js +47 -0
  220. package/dist/esm/output-transforms/index.js +2 -0
  221. package/dist/esm/review/authority.js +13 -5
  222. package/dist/esm/review/availability.d.ts +22 -0
  223. package/dist/esm/review/availability.js +107 -20
  224. package/dist/esm/review/delivery-verifier.d.ts +27 -0
  225. package/dist/esm/review/delivery-verifier.js +115 -0
  226. package/dist/esm/review/events.d.ts +3 -2
  227. package/dist/esm/review/events.js +23 -3
  228. package/dist/esm/review/execution/adapters.js +26 -9
  229. package/dist/esm/review/execution/output-policy.d.ts +2 -0
  230. package/dist/esm/review/execution/output-policy.js +12 -0
  231. package/dist/esm/review/execution/review-checkout.js +87 -6
  232. package/dist/esm/review/execution/supervisor.js +5 -0
  233. package/dist/esm/review/execution/types.d.ts +8 -0
  234. package/dist/esm/review/opencode-account.d.ts +24 -0
  235. package/dist/esm/review/opencode-account.js +68 -0
  236. package/dist/esm/review/opencode-auth.d.ts +33 -0
  237. package/dist/esm/review/opencode-auth.js +79 -0
  238. package/dist/esm/review/subject.d.ts +12 -0
  239. package/dist/esm/review/subject.js +74 -22
  240. package/dist/esm/runtime/opencode-account-materializer.d.ts +34 -0
  241. package/dist/esm/runtime/opencode-account-materializer.js +33 -0
  242. package/dist/esm/status/snapshot.js +30 -19
  243. package/dist/esm/test/duration-sequencer.d.ts +20 -0
  244. package/dist/esm/test/duration-sequencer.js +54 -0
  245. package/dist/esm/test/shard-durations.json +922 -0
  246. package/dist/esm/test/worker-budget.d.ts +6 -0
  247. package/dist/esm/test/worker-budget.js +59 -1
  248. package/dist/esm/test-helpers/global-setup.d.ts +1 -1
  249. package/dist/esm/test-helpers/global-setup.js +68 -24
  250. package/dist/esm/ultragoal/service.d.ts +7 -0
  251. package/dist/esm/ultragoal/service.js +42 -2
  252. package/dist/esm/utils/write-json-file.js +3 -1
  253. package/dist/esm/worktrees/codex-project-trust.d.ts +15 -0
  254. package/dist/esm/worktrees/codex-project-trust.js +56 -0
  255. package/dist/esm/worktrees/registry.d.ts +8 -0
  256. package/dist/esm/worktrees/registry.js +18 -3
  257. package/package.json +16 -13
@@ -54,11 +54,85 @@ export function findTestFiles(sourceFile, projectDir) {
54
54
  const pattern = join(dir, `${base}.{test,integration.test}{.ts,.tsx}`);
55
55
  return globSync(pattern, { cwd: projectDir, withFileTypes: false });
56
56
  }
57
+ /**
58
+ * Source files whose tests are NOT colocated.
59
+ *
60
+ * `findTestFiles` maps a changed file to `<same-dir>/<same-basename>.test.ts`.
61
+ * That is right for the common case and wrong for a few "contract" files whose
62
+ * dependent assertions deliberately live elsewhere — a registry that generates
63
+ * docs, a template rendered under a byte budget, and so on. Those tests then
64
+ * stay invisible to `--affected`, so `wp ci-preflight` (whose every stage runs
65
+ * `--affected --branch`) can report "all required checks passed" while they are
66
+ * genuinely failing.
67
+ *
68
+ * Observed 2026-07-24: adding MCP tools broke `scaffold-agents-md`,
69
+ * `package-manifest`, `reference-parity-tool-surface`, and `session-docs`.
70
+ * `wp ci-preflight` was green locally AND the pre-push hook passed; CI caught
71
+ * all of it. Three PRs landed red before the pattern was understood.
72
+ *
73
+ * Patterns are matched against repo-relative POSIX paths. Listed tests are
74
+ * existence-filtered by the caller, so an entry that does not apply to a
75
+ * consumer repo is simply inert.
76
+ */
77
+ const CROSS_CUTTING_TEST_DEPENDENTS = [
78
+ {
79
+ // The compiled MCP tool registry feeds the generated AGENTS.md tool list
80
+ // (and its byte budget), the packed README session-memory contract, and
81
+ // reference-parity classification. None are colocated with the registry.
82
+ when: /^src\/mcp\/tools\/_(names|registry)\.ts$/u,
83
+ tests: [
84
+ "src/cli/commands/init/scaffold-agents-md.test.ts",
85
+ "src/build/package-manifest.test.ts",
86
+ "src/__integration__/reference-parity-tool-surface.integration.test.ts",
87
+ "src/mcp/tools/session-docs.test.ts",
88
+ ],
89
+ },
90
+ {
91
+ // The AGENTS.md template renders into the scaffold byte-budget assertion.
92
+ when: /^catalog\/AGENTS\.md\.tpl$/u,
93
+ tests: ["src/cli/commands/init/scaffold-agents-md.test.ts"],
94
+ },
95
+ {
96
+ // README and the session-memory guide are pinned to the registered
97
+ // session-memory tool set from both directions.
98
+ when: /^(README\.md|docs\/guides\/session-memory\.md)$/u,
99
+ tests: ["src/mcp/tools/session-docs.test.ts", "src/build/package-manifest.test.ts"],
100
+ },
101
+ ];
102
+ /**
103
+ * Every test path the cross-cutting map can yield.
104
+ *
105
+ * Exported so a guard test can assert these still exist on disk. Without that
106
+ * guard a rename would keep the unit tests green — they only compare strings —
107
+ * while the caller's existence filter silently dropped the path, reinstating
108
+ * the very under-selection this map exists to prevent.
109
+ */
110
+ export function crossCuttingTestDependentPaths() {
111
+ const paths = new Set();
112
+ for (const rule of CROSS_CUTTING_TEST_DEPENDENTS) {
113
+ for (const test of rule.tests)
114
+ paths.add(test);
115
+ }
116
+ return [...paths];
117
+ }
118
+ /** Non-colocated tests a changed file is known to affect. */
119
+ export function findCrossCuttingTestFiles(sourceFile) {
120
+ const normalized = sourceFile.replaceAll("\\", "/");
121
+ const matched = new Set();
122
+ for (const rule of CROSS_CUTTING_TEST_DEPENDENTS) {
123
+ if (rule.when.test(normalized))
124
+ for (const test of rule.tests)
125
+ matched.add(test);
126
+ }
127
+ return [...matched];
128
+ }
57
129
  export function discoverTestFiles(changedFiles, projectDir) {
58
130
  const testFiles = new Set();
59
131
  for (const file of changedFiles) {
60
132
  for (const testFile of findTestFiles(file, projectDir))
61
133
  testFiles.add(testFile);
134
+ for (const testFile of findCrossCuttingTestFiles(file))
135
+ testFiles.add(testFile);
62
136
  }
63
137
  return [...testFiles];
64
138
  }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { parseBlueprint } from "#core/parser";
5
5
  import { setBlueprintFrontmatterFields } from "#lifecycle/engine";
6
- import { evaluateApprovalGate, formatApprovalGateRequirement } from "#lifecycle/audit";
6
+ import { assertCompletedApprovalGate, evaluateApprovalGate, formatApprovalGateRequirement, } from "#lifecycle/audit";
7
7
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
8
8
  import { getBlueprintDocumentPaths } from "#utils/document-paths.js";
9
9
  import { assertAllTasksHaveCanonicalPassingEvidence } from "#verification.js";
@@ -70,6 +70,19 @@ export async function applyLocalBlueprintTransition(input) {
70
70
  throw new Error(`Cannot promote "${slug}" to ${to_state}: frontmatter \`approvals:\` does not satisfy the promotion reviewer mix (${formatApprovalGateRequirement(approvalEvaluation)}). Record approvals, commit the matching review ledger (e.g. reviews.md), and include tracked review transcript/artifact files referenced by each structured review entry — see catalog/agent/rules/pre-implementation.md.`);
71
71
  }
72
72
  }
73
+ // Completion parity for the MCP lifecycle funnel: finalize, promote, and
74
+ // transition all route here, so gate `completed` writes on the same
75
+ // plan-purpose approval CI's `wp audit blueprint-lifecycle` enforces
76
+ // afterward. Reads the raw on-disk markdown (not the trust-gated copy) to
77
+ // mirror the audit's frontmatter parsing exactly.
78
+ if (to_state === "completed") {
79
+ try {
80
+ assertCompletedApprovalGate(overviewPath, markdown);
81
+ }
82
+ catch (error) {
83
+ throw new Error(`Cannot complete "${slug}": ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ }
73
86
  const updated = setBlueprintFrontmatterFields(trustedMarkdown, {
74
87
  status: to_state,
75
88
  last_updated: todayIsoDate(),
@@ -2,14 +2,17 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import matter from "gray-matter";
5
+ import { parseBlueprint } from "#core/parser";
5
6
  import { evaluateApprovalGate, formatApprovalGateRequirement } from "#lifecycle/audit";
7
+ import { canonicalizeBlueprint } from "#lifecycle/canonicalize.js";
6
8
  import { BlueprintCreationService } from "#service/BlueprintCreationService";
7
9
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
8
- import { getBlueprintDocumentPaths } from "#utils/document-paths.js";
10
+ import { getBlueprintDocumentPaths, isBlueprintSlugSegment, } from "#utils/document-paths.js";
9
11
  import { makeNextAction } from "#next-action.js";
10
12
  import { ensureProjectionReady } from "#projection-ready.js";
11
13
  import { applyPromotionTrustGate } from "#trust/promotion.js";
12
14
  import { validateBlueprintTrust } from "#trust/validator.js";
15
+ import { applyVerification, readTaskVerification } from "#verification.js";
13
16
  import { dbPath, persistBlueprintMarkdown, reIngest } from "#mcp/blueprint/_shared/db";
14
17
  import { err, finishPayload, jsonContent } from "#mcp/blueprint/_shared/errors";
15
18
  import { hashMutationPayload, readMutationReplay, recordMutationReplay, validateMutationFreshnessToken, } from "#mcp/blueprint/_shared/freshness";
@@ -353,6 +356,52 @@ function renderMarkdownTable(headers, rows) {
353
356
  function markdownTableCell(value) {
354
357
  return value.replace(/\s*\n\s*/gu, " ").trim();
355
358
  }
359
+ /**
360
+ * wp_blueprint_put's task schema has no field for the `webpresso-evidence-v1`
361
+ * verification block that wp_blueprint_task_verify writes — evidence is an
362
+ * append-only trust artifact that callers must never freely reconstruct (see
363
+ * catalog/agent/rules/pre-implementation.md). A naive re-render of the whole
364
+ * document therefore silently discards any `**Verification:**` block already
365
+ * recorded for a `done` task.
366
+ *
367
+ * This reads the exact evidence already on disk for every task that has a
368
+ * block, and re-applies it verbatim onto the freshly rendered markdown when
369
+ * that task is still present in the payload with status "done" — the caller
370
+ * never supplies or fabricates evidence content. When the payload cannot
371
+ * represent a protected task (dropped, or moved off "done"), this fails
372
+ * closed with an actionable error instead of destroying the evidence.
373
+ */
374
+ function preserveExistingTaskVerification(existingMarkdown, slug, document, renderedMarkdown) {
375
+ const existingBlueprint = parseBlueprint(existingMarkdown, slug);
376
+ const protectedTasks = existingBlueprint.tasks
377
+ .map((task) => ({ id: task.id, evidence: readTaskVerification(existingMarkdown, task.id) }))
378
+ .filter((entry) => entry.evidence !== null);
379
+ let markdown = renderedMarkdown;
380
+ for (const protectedTask of protectedTasks) {
381
+ const { id, evidence } = protectedTask;
382
+ const submittedTask = document.tasks.find((task) => task.id === id);
383
+ if (!submittedTask || submittedTask.status !== "done") {
384
+ return { error: describeEvidenceLossRefusal(id, submittedTask?.status) };
385
+ }
386
+ const applied = applyVerification(markdown, id, evidence);
387
+ if (!applied.ok) {
388
+ return {
389
+ error: `wp_blueprint_put failed to re-apply preserved verification evidence for task ${id}: ${applied.failures.join("; ")}`,
390
+ };
391
+ }
392
+ markdown = applied.markdown;
393
+ }
394
+ return { markdown };
395
+ }
396
+ function describeEvidenceLossRefusal(taskId, submittedStatus) {
397
+ const disposition = submittedStatus === undefined
398
+ ? `the submitted document does not include task ${taskId}`
399
+ : `the submitted document sets task ${taskId}'s status to "${submittedStatus}"`;
400
+ return (`wp_blueprint_put refused: task ${taskId} already has verification evidence recorded by ` +
401
+ `wp_blueprint_task_verify, but ${disposition} — wp_blueprint_put cannot represent or discard ` +
402
+ `that evidence. Keep task ${taskId} present with status "done" in the payload; put will carry ` +
403
+ `the recorded evidence forward automatically.`);
404
+ }
356
405
  function validatePutTrustDossier(repoRoot, file, markdown) {
357
406
  const validated = validateBlueprintTrust({
358
407
  repoRoot,
@@ -374,6 +423,13 @@ export async function handleBlueprintPut(projectResolver, cwd, raw) {
374
423
  if (!p.success)
375
424
  return err("wp_blueprint_put validation error", p.error.message);
376
425
  const { project_id, slug, document, request_id, head_at_ingest } = p.data;
426
+ // Validate the slug against the canonical segment pattern before ANY
427
+ // routing or filesystem probe: an unvalidated slug such as "../../outside"
428
+ // would otherwise construct paths outside the blueprint root.
429
+ const slugSegments = slug.split("/");
430
+ if (slugSegments.length === 0 || !slugSegments.every((s) => isBlueprintSlugSegment(s))) {
431
+ return err("wp_blueprint_put validation error", `Invalid blueprint slug "${slug}": every segment must match lowercase [a-z0-9] with single hyphens.`);
432
+ }
377
433
  const resolvedProject = await resolveToolProject(projectResolver, cwd, project_id);
378
434
  if ("content" in resolvedProject)
379
435
  return resolvedProject;
@@ -408,16 +464,34 @@ export async function handleBlueprintPut(projectResolver, cwd, raw) {
408
464
  if (found && found.state !== document.status) {
409
465
  return err("wp_blueprint_put refused", `Blueprint "${slug}" currently lives in "${found.state}" and cannot be rewritten as "${document.status}" without a lifecycle transition.`);
410
466
  }
467
+ // Failure atomicity: render and validate against the canonical folder
468
+ // path FIRST, and only canonicalize an existing flat document after every
469
+ // validation has passed. A validation failure must leave the flat file
470
+ // untouched; a persistence failure after conversion leaves the
471
+ // byte-identical document at the canonical folder path (retry-safe).
411
472
  const overviewPath = found
412
- ? found.path
413
- : getBlueprintDocumentPaths(root, document.status, slug).flat;
414
- const markdown = renderBlueprintMarkdownFromDocument(slug, document);
473
+ ? found.shape === "flat"
474
+ ? getBlueprintDocumentPaths(root, found.state, slug).folder
475
+ : found.path
476
+ : getBlueprintDocumentPaths(root, document.status, slug).folder;
477
+ let markdown = renderBlueprintMarkdownFromDocument(slug, document);
478
+ if (found) {
479
+ const existingMarkdown = readFileSync(found.path, "utf8");
480
+ const preserved = preserveExistingTaskVerification(existingMarkdown, slug, document, markdown);
481
+ if ("error" in preserved) {
482
+ return err("wp_blueprint_put refused", preserved.error);
483
+ }
484
+ markdown = preserved.markdown;
485
+ }
415
486
  if (document.trust_dossier) {
416
487
  const trustDossierFailure = validatePutTrustDossier(writeCwd, overviewPath, markdown);
417
488
  if (trustDossierFailure) {
418
489
  return err("wp_blueprint_put validation error", trustDossierFailure);
419
490
  }
420
491
  }
492
+ if (found && found.shape === "flat") {
493
+ await canonicalizeBlueprint(writeCwd, slug);
494
+ }
421
495
  const blueprint = await persistBlueprintMarkdown({
422
496
  projectCwd: writeCwd,
423
497
  slug,
@@ -4,7 +4,7 @@ import matter from "gray-matter";
4
4
  import { z } from "zod";
5
5
  import { logReviewEntry, REVIEWER_IDS } from "#cli/commands/review.js";
6
6
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
7
- import { isBlueprintSlugSegment } from "#utils/document-paths.js";
7
+ import { folderBlueprintRequiredMessage, isBlueprintSlugSegment } from "#utils/document-paths.js";
8
8
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
9
9
  import { writeFileAtomic } from "#shared-utils/write-json-file.js";
10
10
  import { err, finishPayload } from "#mcp/blueprint/_shared/errors";
@@ -98,6 +98,13 @@ export async function handleReviewLog(projectResolver, cwd, raw) {
98
98
  const found = findBlueprintDir(root, slug, ALL_BLUEPRINT_STATES);
99
99
  if (!found)
100
100
  return err("wp_blueprint_review_log failed", `Blueprint "${slug}" not found in any state directory`);
101
+ const shapeFailure = folderBlueprintRequiredMessage({
102
+ documentPath: found.path,
103
+ lifecycleSlug: `${found.state}/${slug}`,
104
+ shape: found.shape,
105
+ });
106
+ if (shapeFailure)
107
+ return err("wp_blueprint_review_log failed", shapeFailure);
101
108
  const blueprintDir = realpathSync(path.dirname(found.path));
102
109
  const artifactAbsolute = path.resolve(blueprintDir, normalizedArtifactPath);
103
110
  if (!artifactAbsolute.startsWith(`${blueprintDir}${path.sep}`)) {
@@ -8,12 +8,53 @@ import { dbPath, reIngest } from "#mcp/blueprint/_shared/db";
8
8
  import { err, finishPayload, jsonContent } from "#mcp/blueprint/_shared/errors";
9
9
  import { hashMutationPayload, readMutationReplay, recordMutationReplay, validateMutationFreshnessToken, } from "#mcp/blueprint/_shared/freshness";
10
10
  import { toStr } from "#mcp/blueprint/_shared/payload";
11
- import { resolveToolProject } from "#mcp/blueprint/_shared/project";
11
+ import { findBlueprintDir, resolveToolProject } from "#mcp/blueprint/_shared/project";
12
12
  import { resolveSyncAdapter, runPlatformMutationSync } from "#mcp/blueprint/_shared/sync";
13
13
  import { writeFileAtomic } from "#shared-utils/write-json-file.js";
14
14
  import { escapeRegex } from "#utils/string";
15
+ import { withMarkdownWriteLock } from "#db/paths.js";
16
+ import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
17
+ const ALL_STATES = ["draft", "planned", "in-progress", "parked", "archived", "completed"];
18
+ /**
19
+ * Matches the canonical task-heading grammar `parser.ts`'s `extractTasks`
20
+ * uses (`^####\s+(?:\[lane\]\s+)?Task\s+<id>:`), not a looser ad hoc pattern.
21
+ * The id is anchored on a trailing colon so "1.1" cannot match "1.10", and
22
+ * an optional `[lane]` prefix is accepted the same way the canonical parser
23
+ * does.
24
+ */
25
+ function buildTaskHeadingPattern(taskId) {
26
+ return new RegExp(`^####\\s+(?:\\[[^\\]]+\\]\\s+)?Task\\s+${escapeRegex(taskId)}:`, "u");
27
+ }
28
+ function findTaskStatusLine(markdown, taskId) {
29
+ const lines = markdown.split("\n");
30
+ const headingPattern = buildTaskHeadingPattern(taskId);
31
+ let inBlock = false;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i] ?? "";
34
+ if (headingPattern.test(line))
35
+ inBlock = true;
36
+ else if (inBlock && /^####\s+/u.test(line))
37
+ break;
38
+ else if (inBlock) {
39
+ // Preserve any inline metadata sharing the status line (the canonical
40
+ // format allows `**Status:** done | **Depends:** Task 1.1`) instead of
41
+ // overwriting the whole line and discarding it.
42
+ const match = line.match(/^(\*\*Status:\*\*\s*)(\S+)(.*)$/u);
43
+ if (match) {
44
+ return {
45
+ lineIndex: i,
46
+ prefix: match[1] ?? "",
47
+ status: match[2] ?? "",
48
+ suffix: match[3] ?? "",
49
+ };
50
+ }
51
+ }
52
+ }
53
+ return null;
54
+ }
15
55
  const advanceSchema = z.object({
16
56
  project_id: z.string(),
57
+ slug: z.string(),
17
58
  task_id: z.string(),
18
59
  to: z.enum(["todo", "in-progress", "blocked", "done", "dropped"]),
19
60
  request_id: z.string().min(1).optional(),
@@ -23,7 +64,7 @@ export async function handleTaskAdvance(projectResolver, cwd, raw) {
23
64
  const p = advanceSchema.safeParse(raw);
24
65
  if (!p.success)
25
66
  return err("wp_blueprint_task_advance validation error", p.error.message);
26
- const { project_id, task_id, to, request_id, head_at_ingest } = p.data;
67
+ const { project_id, slug, task_id, to, request_id, head_at_ingest } = p.data;
27
68
  const resolvedProject = await resolveToolProject(projectResolver, cwd, project_id);
28
69
  if ("content" in resolvedProject)
29
70
  return resolvedProject;
@@ -32,7 +73,7 @@ export async function handleTaskAdvance(projectResolver, cwd, raw) {
32
73
  const freshnessFailure = validateMutationFreshnessToken(projectCwd, head_at_ingest, "wp_blueprint_task_advance", "wp_blueprint_list");
33
74
  if (freshnessFailure)
34
75
  return freshnessFailure;
35
- const payloadHash = hashMutationPayload({ task_id, to });
76
+ const payloadHash = hashMutationPayload({ slug, task_id, to });
36
77
  const replay = request_id !== undefined
37
78
  ? readMutationReplay(projectCwd, "wp_blueprint_task_advance", request_id, payloadHash)
38
79
  : null;
@@ -54,70 +95,88 @@ export async function handleTaskAdvance(projectResolver, cwd, raw) {
54
95
  return err("wp_blueprint_task_advance failed", "Blueprint DB not found");
55
96
  try {
56
97
  const conn = openDb(target);
57
- let oldStatus = null;
58
- let filePath = null;
59
- let blueprintSlug = null;
60
98
  try {
61
99
  const row = conn.db
62
- .prepare("SELECT status, blueprint_slug FROM tasks WHERE task_id = ? LIMIT 1")
63
- .get(task_id);
100
+ .prepare("SELECT status FROM tasks WHERE task_id = ? AND blueprint_slug = ? LIMIT 1")
101
+ .get(task_id, slug);
64
102
  if (!row)
65
- return err("wp_blueprint_task_advance failed", `Task "${task_id}" not found in DB`);
66
- oldStatus = row.status;
67
- blueprintSlug = row.blueprint_slug;
68
- const bp = conn.db
69
- .prepare("SELECT file_path FROM blueprints WHERE slug = ?")
70
- .get(row.blueprint_slug);
71
- if (bp?.file_path)
72
- filePath = bp.file_path;
103
+ return err("wp_blueprint_task_advance failed", `Task "${task_id}" not found in blueprint "${slug}"`);
73
104
  }
74
105
  finally {
75
106
  conn.close();
76
107
  }
108
+ // Derive the authoritative fromStatus from a live read of the blueprint's
109
+ // on-disk markdown, not the SQLite projection above (which is only
110
+ // re-ingested asynchronously and can be stale relative to the file --
111
+ // e.g. after a blueprint move/promotion changed its lifecycle directory).
112
+ // Fail closed here rather than proceeding with a guessed value, or later
113
+ // silently reporting success when the local write never happens.
114
+ const root = resolveBlueprintRoot(projectCwd);
115
+ const foundBeforePush = findBlueprintDir(root, slug, ALL_STATES);
116
+ if (!foundBeforePush || !existsSync(foundBeforePush.path)) {
117
+ return err("wp_blueprint_task_advance failed", `Blueprint "${slug}" overview file not found`);
118
+ }
119
+ const preStatus = findTaskStatusLine(readFileSync(foundBeforePush.path, "utf8"), task_id);
120
+ if (!preStatus) {
121
+ return err("wp_blueprint_task_advance failed", `Task "${task_id}" has no **Status:** line in blueprint "${slug}"`);
122
+ }
123
+ const oldStatus = preStatus.status;
77
124
  // Platform-first path: push event + pull fresh replica before local update.
78
125
  // Iron rule: resolveSyncAdapter() returns null when WP_BLUEPRINT_PLATFORM_DISABLED=1.
79
126
  const adapter = await resolveSyncAdapter(projectCwd);
80
- if (blueprintSlug !== null && oldStatus !== null) {
81
- try {
82
- await runPlatformMutationSync(adapter, {
83
- label: "wp_blueprint_task_advance",
84
- event: {
85
- eventId: randomUUID(),
86
- repoId: process.env["WP_BLUEPRINT_PLATFORM_REPO_ID"] ?? "local",
87
- occurredAt: new Date().toISOString(),
127
+ try {
128
+ await runPlatformMutationSync(adapter, {
129
+ label: "wp_blueprint_task_advance",
130
+ event: {
131
+ eventId: randomUUID(),
132
+ repoId: process.env["WP_BLUEPRINT_PLATFORM_REPO_ID"] ?? "local",
133
+ occurredAt: new Date().toISOString(),
134
+ type: "task.status_changed",
135
+ payload: {
88
136
  type: "task.status_changed",
89
- payload: {
90
- type: "task.status_changed",
91
- blueprintSlug,
92
- taskId: task_id,
93
- fromStatus: oldStatus,
94
- toStatus: to,
95
- },
137
+ blueprintSlug: slug,
138
+ taskId: task_id,
139
+ fromStatus: oldStatus,
140
+ toStatus: to,
96
141
  },
97
- ensureFreshSlug: blueprintSlug,
98
- });
99
- }
100
- catch (e) {
101
- return err("wp_blueprint_task_advance failed", toStr(e));
102
- }
142
+ },
143
+ ensureFreshSlug: slug,
144
+ });
103
145
  }
104
- // Always update local markdown + SQLite.
105
- // Platform-first: these become derived artifacts; disabled: these are canonical.
106
- if (filePath && existsSync(filePath)) {
107
- const lines = readFileSync(filePath, "utf8").split("\n");
108
- let inBlock = false;
109
- for (let i = 0; i < lines.length; i++) {
110
- const line = lines[i] ?? "";
111
- if (line.match(new RegExp(`#### Task\\s+${escapeRegex(task_id)}`)))
112
- inBlock = true;
113
- else if (inBlock && line.startsWith("#### "))
114
- break;
115
- else if (inBlock && line.startsWith("**Status:**")) {
116
- lines[i] = `**Status:** ${to}`;
117
- break;
118
- }
119
- }
120
- writeFileAtomic(filePath, lines.join("\n"), "utf8");
146
+ catch (e) {
147
+ return err("wp_blueprint_task_advance failed", toStr(e));
148
+ }
149
+ // Always update local markdown + SQLite. Re-resolve the path live INSIDE
150
+ // the repo-scoped write lock: a concurrent promotion can rename the
151
+ // blueprint's directory between the pre-push read above and this write.
152
+ // A missing blueprint or status line here is a genuine race, not
153
+ // something to paper over as success -- `wrote` makes that failure
154
+ // observable to the caller instead of silently no-op'ing.
155
+ let wrote = false;
156
+ await withMarkdownWriteLock(projectCwd, () => {
157
+ const liveFound = findBlueprintDir(root, slug, ALL_STATES);
158
+ const livePath = liveFound?.path;
159
+ if (!livePath || !existsSync(livePath))
160
+ return;
161
+ const markdown = readFileSync(livePath, "utf8");
162
+ const live = findTaskStatusLine(markdown, task_id);
163
+ if (!live)
164
+ return;
165
+ const lines = markdown.split("\n");
166
+ lines[live.lineIndex] = `${live.prefix}${to}${live.suffix}`;
167
+ writeFileAtomic(livePath, lines.join("\n"), "utf8");
168
+ wrote = true;
169
+ });
170
+ if (!wrote) {
171
+ // The platform mutation above already succeeded by this point (or was
172
+ // a no-op if platform sync is disabled) -- this failure therefore
173
+ // means platform and local state have genuinely diverged, not just
174
+ // that the local write was skipped harmlessly. There is no automatic
175
+ // reconciliation path today (that requires the platform-first
176
+ // mutation reconciliation work tracked separately, not built here);
177
+ // surface the divergence explicitly rather than a generic error so an
178
+ // operator knows this needs manual attention, not just a retry.
179
+ return err("wp_blueprint_task_advance failed", `Task "${task_id}" in blueprint "${slug}" could not be updated locally after the platform mutation already succeeded (blueprint or status line changed concurrently). Platform and local state have diverged and require manual reconciliation -- retrying this call alone will not fix it if the blueprint moved again.`);
121
180
  }
122
181
  try {
123
182
  await reIngest(projectCwd);
@@ -10,8 +10,9 @@ import { hashMutationPayload, readMutationReplay, recordMutationReplay, validate
10
10
  import { bytes } from "#mcp/blueprint/_shared/payload";
11
11
  import { findBlueprintDir, resolveToolProject } from "#mcp/blueprint/_shared/project";
12
12
  import { handleTaskNext } from "#mcp/blueprint/handlers/task-next";
13
- import { writeFileAtomic } from "#shared-utils/write-json-file.js";
14
- import { applyVerification, readTaskVerification } from "#verification.js";
13
+ import { readTaskVerification } from "#verification.js";
14
+ import { applyBlueprintLifecycle } from "#lifecycle/engine.js";
15
+ import { applyBlueprintLifecycleToFile } from "#lifecycle/local.js";
15
16
  const ALL_STATES = ["draft", "planned", "in-progress", "parked", "archived", "completed"];
16
17
  // Task 3.2 — wp_blueprint_task_verify
17
18
  const taskVerifySchema = z.object({
@@ -52,13 +53,31 @@ export async function handleTaskVerify(projectResolver, cwd, raw) {
52
53
  return err("wp_blueprint_task_verify failed", `Blueprint overview not found at ${filePath}`);
53
54
  }
54
55
  const markdownBefore = readFileSync(filePath, "utf8");
55
- // Idempotency check: if the task already has the same canonical evidence block, skip write.
56
- // We check this BEFORE calling applyVerification to avoid whitespace normalization drift
57
- // making `result.markdown !== markdownBefore` always true even for identical evidence.
58
56
  const incomingCanonical = canonicalizeEvidenceList(evidence);
59
57
  const existingEvidence = readTaskVerification(markdownBefore, task_id);
58
+ let result;
59
+ try {
60
+ result = applyBlueprintLifecycle(markdownBefore, slug, {
61
+ type: "task_verify",
62
+ taskId: task_id,
63
+ evidence,
64
+ });
65
+ }
66
+ catch (error) {
67
+ return jsonContent({
68
+ summary: "Verification failed",
69
+ failures: [error instanceof Error ? error.message : String(error)],
70
+ next_action: makeNextAction("verify_task", error instanceof Error ? error.message : String(error)),
71
+ bytes: 0,
72
+ tokensSaved: 0,
73
+ }, true);
74
+ }
75
+ // Idempotency includes all lifecycle-owned fields. Reapplying identical
76
+ // evidence repairs stale progress/last_updated metadata once, then becomes a
77
+ // byte-for-byte no-op.
60
78
  if (existingEvidence !== null &&
61
- canonicalizeEvidenceList(existingEvidence) === incomingCanonical) {
79
+ canonicalizeEvidenceList(existingEvidence) === incomingCanonical &&
80
+ result.markdown === markdownBefore) {
62
81
  const nextPayload = parseStructuredJson(await handleTaskNext(projectResolver, projectCwd, {
63
82
  blueprint: slug,
64
83
  project_id: resolvedProject.project_id ?? projectCwd,
@@ -82,19 +101,20 @@ export async function handleTaskVerify(projectResolver, cwd, raw) {
82
101
  }
83
102
  return finishPayload(payload);
84
103
  }
85
- // Apply verification (pure function no FS side effects)
86
- const result = applyVerification(markdownBefore, task_id, evidence);
87
- if (!result.ok) {
88
- return jsonContent({
89
- summary: "Verification failed",
90
- failures: result.failures,
91
- next_action: makeNextAction("verify_task", result.failures[0] ?? "Verification failed"),
92
- bytes: 0,
93
- tokensSaved: 0,
94
- }, true);
95
- }
96
- // Write updated markdown back to disk
97
- writeFileAtomic(filePath, result.markdown, "utf8");
104
+ // Write the mutated markdown back to disk via the file-aware lifecycle
105
+ // wrapper, not a raw write to the pre-mutation path: the pure engine call
106
+ // above can change the blueprint's frontmatter `status` (e.g. draft ->
107
+ // in-progress on the first task mutation), and that status change must be
108
+ // paired with the matching physical folder move -- the same contract the
109
+ // CLI router and BlueprintService already honor via
110
+ // applyBlueprintLifecycleToFile. Writing to the original `filePath`
111
+ // unconditionally would leave folder and status disagreeing whenever the
112
+ // blueprint started outside in-progress/.
113
+ await applyBlueprintLifecycleToFile(projectCwd, slug, {
114
+ type: "task_verify",
115
+ taskId: task_id,
116
+ evidence,
117
+ });
98
118
  // Re-ingest so the DB projection reflects the completed status
99
119
  try {
100
120
  await reIngest(projectCwd);
@@ -107,16 +107,17 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
107
107
  type: "object",
108
108
  properties: { blueprint: { type: "string" }, project_id: { type: "string" } },
109
109
  }, undefined, (r) => handleTaskNext(projectResolver, cwd, r), { title: "Blueprint Task Next", readOnlyHint: true, openWorldHint: false });
110
- registrar.registerTool("wp_blueprint_task_advance", "Advance task status. Edits the canonical blueprint markdown and re-syncs DB. Accepts optional request_id for idempotent retries and optional head_at_ingest from wp_blueprint_get/wp_blueprint_list to reject stale writes. Returns { summary, old_status, new_status, idempotent }.", {
110
+ registrar.registerTool("wp_blueprint_task_advance", "Advance task status within a specific blueprint. Requires slug to scope the mutation — task ids (e.g. 2.1) are not unique across blueprints in a project, so slug prevents advancing the wrong blueprint. Edits the canonical blueprint markdown and re-syncs DB. Accepts optional request_id for idempotent retries and optional head_at_ingest from wp_blueprint_get/wp_blueprint_list to reject stale writes. Returns { summary, old_status, new_status, idempotent }.", {
111
111
  type: "object",
112
112
  properties: {
113
113
  project_id: { type: "string" },
114
+ slug: { type: "string" },
114
115
  task_id: { type: "string" },
115
116
  to: { type: "string", enum: ["todo", "in-progress", "blocked", "done", "dropped"] },
116
117
  request_id: { type: "string" },
117
118
  head_at_ingest: { type: ["string", "null"] },
118
119
  },
119
- required: ["project_id", "task_id", "to"],
120
+ required: ["project_id", "slug", "task_id", "to"],
120
121
  }, {
121
122
  ...summaryEnvelopeOutputSchema,
122
123
  properties: {
@@ -7,6 +7,7 @@
7
7
  * — no edits required here.
8
8
  */
9
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ export declare function resolveMcpToolAllowlist(raw?: string | undefined): ReadonlySet<string> | null;
10
11
  export declare function isBunSingleFileModuleUrl(moduleUrl: string): boolean;
11
12
  export type ToolLoadMode = "filesystem" | "registry";
12
13
  export declare function resolveDefaultToolLoadMode(moduleUrl?: string): ToolLoadMode;
@@ -16,6 +16,24 @@ import { COMPILED_TOOL_REGISTRY } from "./tools/_registry.js";
16
16
  import { readOwnedPackageVersion } from "#runtime/package-version.js";
17
17
  const SERVER_NAME = "webpresso";
18
18
  const SERVER_VERSION = readOwnedPackageVersion(import.meta.url);
19
+ const MCP_TOOL_ALLOWLIST_ENV = "WP_MCP_TOOL_ALLOWLIST";
20
+ export function resolveMcpToolAllowlist(raw = process.env[MCP_TOOL_ALLOWLIST_ENV]) {
21
+ if (raw === undefined)
22
+ return null;
23
+ const names = raw
24
+ .split(",")
25
+ .map((name) => name.trim())
26
+ .filter(Boolean);
27
+ if (names.length === 0 || new Set(names).size !== names.length) {
28
+ throw new Error(`${MCP_TOOL_ALLOWLIST_ENV} must contain a non-empty, duplicate-free comma-separated tool list`);
29
+ }
30
+ for (const name of names) {
31
+ if (!/^wp_[a-z0-9_]+$/u.test(name)) {
32
+ throw new Error(`${MCP_TOOL_ALLOWLIST_ENV} contains invalid tool name: ${name}`);
33
+ }
34
+ }
35
+ return new Set(names);
36
+ }
19
37
  function modulePathFromUrl(moduleUrl) {
20
38
  try {
21
39
  return fileURLToPath(moduleUrl);
@@ -53,8 +71,11 @@ export async function createServer(options = {}) {
53
71
  },
54
72
  });
55
73
  const tools = new Map();
74
+ const allowlist = resolveMcpToolAllowlist();
56
75
  const registrar = {
57
76
  registerTool(name, description, inputSchema, outputSchema, handler, annotations) {
77
+ if (allowlist !== null && !allowlist.has(name))
78
+ return;
58
79
  tools.set(name, { name, description, inputSchema, outputSchema, handler, annotations });
59
80
  },
60
81
  };
@@ -90,6 +111,12 @@ export async function createServer(options = {}) {
90
111
  }
91
112
  },
92
113
  });
114
+ if (allowlist !== null) {
115
+ const missing = [...allowlist].filter((name) => !tools.has(name));
116
+ if (missing.length > 0) {
117
+ throw new Error(`${MCP_TOOL_ALLOWLIST_ENV} names unavailable tools: ${missing.sort().join(", ")}`);
118
+ }
119
+ }
93
120
  // Empty prompts/resources/resource-templates handlers, registered exactly
94
121
  // the same way other MCP servers do. Several MCP clients —
95
122
  // including Claude Code 2.1.x and OpenCode — call listPrompts() or