@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
@@ -0,0 +1,6 @@
1
+ export interface CanonicalizeBlueprintResult {
2
+ readonly from: string;
3
+ readonly to: string;
4
+ readonly moved: boolean;
5
+ }
6
+ export declare function canonicalizeBlueprint(projectRoot: string, slug: string): Promise<CanonicalizeBlueprintResult>;
@@ -0,0 +1,135 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, mkdir, readdir, rename, rmdir } from "node:fs/promises";
3
+ import { refreshBlueprintReadmeIndex } from "#audit/blueprint-readme-drift";
4
+ import { resolveBlueprintFile } from "#lifecycle/local.js";
5
+ import { BLUEPRINT_STATUSES, getBlueprintDocumentPaths, isBlueprintSlugSegment, isBlueprintStatus, } from "#utils/document-paths.js";
6
+ import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
7
+ async function exists(filePath) {
8
+ try {
9
+ await access(filePath, constants.F_OK);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function assertRegularFile(filePath) {
17
+ const stats = await lstat(filePath);
18
+ if (!stats.isFile() || stats.isSymbolicLink()) {
19
+ throw new Error(`Blueprint canonicalization requires a regular file: ${filePath}`);
20
+ }
21
+ }
22
+ async function directCanonicalizeLocation(projectRoot, slug) {
23
+ const root = resolveBlueprintRoot(projectRoot);
24
+ const segments = slug.split("/").filter((segment) => segment.length > 0);
25
+ const scopedState = isBlueprintStatus(segments[0]) ? segments[0] : null;
26
+ const slugSegments = scopedState === null ? segments : segments.slice(1);
27
+ const stableSlug = slugSegments.join("/");
28
+ // Validate before any filesystem probe: an unvalidated slug such as
29
+ // "../outside" would otherwise resolve paths outside the blueprint root.
30
+ if (!stableSlug || !slugSegments.every((segment) => isBlueprintSlugSegment(segment))) {
31
+ return null;
32
+ }
33
+ const states = scopedState === null ? BLUEPRINT_STATUSES : [scopedState];
34
+ for (const state of states) {
35
+ const paths = getBlueprintDocumentPaths(root, state, stableSlug);
36
+ const [flatExists, folderExists] = await Promise.all([
37
+ exists(paths.flat),
38
+ exists(paths.folder),
39
+ ]);
40
+ if (flatExists && folderExists) {
41
+ await assertRegularFile(paths.flat);
42
+ await assertRegularFile(paths.folder);
43
+ throw new Error(`Blueprint canonicalization refused to overwrite existing overview: ${paths.folder}`);
44
+ }
45
+ if (flatExists) {
46
+ return {
47
+ folderDirectory: paths.directory,
48
+ folderPath: paths.folder,
49
+ path: paths.flat,
50
+ shape: "flat",
51
+ };
52
+ }
53
+ if (folderExists) {
54
+ return {
55
+ folderDirectory: paths.directory,
56
+ folderPath: paths.folder,
57
+ path: paths.folder,
58
+ shape: "folder",
59
+ };
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ const GOVERNANCE_ENTRY_NAMES = new Set(["review-events.jsonl", "reviews.md", "review-artifacts"]);
65
+ async function assertUsableTargetDirectory(directory) {
66
+ try {
67
+ const stats = await lstat(directory);
68
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
69
+ throw new Error(`Blueprint canonicalization target is not a real directory: ${directory}`);
70
+ }
71
+ }
72
+ catch (error) {
73
+ const code = error.code;
74
+ if (code !== "ENOENT")
75
+ throw error;
76
+ await mkdir(directory, { recursive: true });
77
+ return "created";
78
+ }
79
+ // Adopting a directory that already holds governance files would turn
80
+ // previously unattributed evidence into authoritative folder evidence the
81
+ // moment the overview lands next to it. Refuse; the operator must review
82
+ // and remove (or intentionally commit) that evidence first. Compare
83
+ // case-folded: on case-insensitive filesystems "Reviews.md" aliases the
84
+ // authoritative lowercase path.
85
+ const adopted = (await readdir(directory)).filter((entry) => GOVERNANCE_ENTRY_NAMES.has(entry.toLowerCase()));
86
+ if (adopted.length > 0) {
87
+ throw new Error(`Blueprint canonicalization refused to adopt a directory containing review evidence: ${directory} (${adopted.join(", ")})`);
88
+ }
89
+ return "existing";
90
+ }
91
+ async function removeCreatedDirectoryIfEmpty(directory, created) {
92
+ if (!created)
93
+ return;
94
+ try {
95
+ const entries = await readdir(directory);
96
+ if (entries.length === 0)
97
+ await rmdir(directory);
98
+ }
99
+ catch {
100
+ // Best-effort cleanup only. A concurrent caller may have completed the move.
101
+ }
102
+ }
103
+ export async function canonicalizeBlueprint(projectRoot, slug) {
104
+ const location = (await directCanonicalizeLocation(projectRoot, slug)) ??
105
+ (await resolveBlueprintFile(projectRoot, slug));
106
+ const from = location.path;
107
+ const to = location.folderPath;
108
+ if (location.shape === "folder") {
109
+ await assertRegularFile(to);
110
+ return { from, to, moved: false };
111
+ }
112
+ await assertRegularFile(from);
113
+ const directoryState = await assertUsableTargetDirectory(location.folderDirectory);
114
+ const createdDirectory = directoryState === "created";
115
+ try {
116
+ if (await exists(to)) {
117
+ await assertRegularFile(to);
118
+ if (await exists(from)) {
119
+ throw new Error(`Blueprint canonicalization refused to overwrite existing overview: ${to}`);
120
+ }
121
+ return { from, to, moved: false };
122
+ }
123
+ await rename(from, to);
124
+ }
125
+ catch (error) {
126
+ if (error.code === "ENOENT" && (await exists(to))) {
127
+ await assertRegularFile(to);
128
+ return { from, to, moved: false };
129
+ }
130
+ await removeCreatedDirectoryIfEmpty(location.folderDirectory, createdDirectory);
131
+ throw error;
132
+ }
133
+ refreshBlueprintReadmeIndex(projectRoot);
134
+ return { from, to, moved: true };
135
+ }
@@ -1,5 +1,6 @@
1
1
  import type { BlueprintLifecycleIntent, BlueprintLifecycleResult } from "./engine.js";
2
- export interface ResolvedBlueprintFile {
2
+ import { type BlueprintDocumentIdentity } from "#utils/document-paths.js";
3
+ export interface ResolvedBlueprintFile extends BlueprintDocumentIdentity {
3
4
  path: string;
4
5
  slug: string;
5
6
  }
@@ -1,10 +1,11 @@
1
1
  import { mkdir, readFile, readdir, rename, rmdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { refreshBlueprintReadmeIndex } from "#audit/blueprint-readme-drift";
4
+ import { assertCompletedApprovalGate } from "#lifecycle/audit";
4
5
  import { applyBlueprintLifecycle } from "#lifecycle/engine";
5
6
  import { scanBlueprintDirectory } from "#service/scanner";
6
7
  import { resolveBlueprintRoot } from "#utils/blueprint-root";
7
- import { getBlueprintDocumentPaths } from "#utils/document-paths.js";
8
+ import { getBlueprintDocumentPaths, resolveBlueprintDocumentIdentity, } from "#utils/document-paths.js";
8
9
  const BLUEPRINT_SLUG_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
10
  function isStatusSegment(segment) {
10
11
  return (segment === "draft" ||
@@ -57,7 +58,15 @@ export async function resolveBlueprintFile(projectRoot, slug) {
57
58
  .map((entry) => entry.slug)
58
59
  .join(", ")}`);
59
60
  }
60
- return { path: match.path, slug: match.slug };
61
+ const identity = resolveBlueprintDocumentIdentity(baseDir, match.path);
62
+ if (!identity) {
63
+ throw new Error(`Blueprint ${match.slug} resolved to an invalid document path: ${match.path}`);
64
+ }
65
+ return {
66
+ ...identity,
67
+ path: identity.documentPath,
68
+ slug: match.slug,
69
+ };
61
70
  }
62
71
  async function tryRemoveEmptyParent(dir) {
63
72
  try {
@@ -75,8 +84,15 @@ export async function applyBlueprintLifecycleToFile(projectRoot, slug, intent) {
75
84
  const location = await resolveBlueprintFile(projectRoot, slug);
76
85
  const raw = await readFile(location.path, "utf-8");
77
86
  const mutation = applyBlueprintLifecycle(raw, location.slug, intent);
87
+ // Completion parity: the execution engine's `finalize` intent (exec/resume/
88
+ // stop/move flows) also writes `status: completed`. Gate it with the same
89
+ // plan-approval check CI enforces so this path cannot diverge from the audit
90
+ // either — matching the MCP funnel and CLI completion owners.
91
+ if (mutation.targetStatus === "completed") {
92
+ assertCompletedApprovalGate(location.path, raw);
93
+ }
78
94
  const relativeSlug = relativeBlueprintSlug(location.slug);
79
- const isFlatFile = path.basename(location.path) !== "_overview.md";
95
+ const isFlatFile = location.shape === "flat";
80
96
  const sourceDir = path.dirname(location.path);
81
97
  const targetDocumentPaths = getBlueprintDocumentPaths(baseDir, mutation.targetStatus, relativeSlug);
82
98
  const targetDir = targetDocumentPaths.directory;
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readReviewAuthorityAtRef, readWorkingReviewEvents } from "#review/authority.js";
5
5
  import { evaluateReviewEvents, parseReviewEventLog } from "#review/events.js";
6
- import { createPlanSubjectDigestForContent, normalizePlanSubjectContent } from "#review/subject.js";
6
+ import { createDeliverySubjectAtRef, createPlanSubjectDigestForContent, normalizePlanSubjectContent, } from "#review/subject.js";
7
7
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
8
8
  const REVIEW_ENTRY_MARKER = "<!-- wp:review-entry ";
9
9
  function normalizePath(file) {
@@ -50,6 +50,10 @@ function stableSlugForBlueprintFile(file) {
50
50
  if (lifecycleIndex >= 0 && segments.at(-1) === "_overview.md") {
51
51
  return segments.slice(lifecycleIndex + 1, -1).join("/");
52
52
  }
53
+ const basename = segments.at(-1) ?? "";
54
+ if (lifecycleIndex >= 0 && basename.endsWith(".md")) {
55
+ return basename.slice(0, -3);
56
+ }
53
57
  return path.basename(path.dirname(file));
54
58
  }
55
59
  /**
@@ -83,7 +87,7 @@ export function readBlueprintOverviewAtRef(repoRoot, ref, file) {
83
87
  timeout: 5_000,
84
88
  });
85
89
  const escaped = slug.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
86
- const pattern = new RegExp(`(?:^|/)(?:draft|planned|parked|in-progress|completed|archived)/${escaped}/_overview\\.md$`, "u");
90
+ const pattern = new RegExp(`(?:^|/)(?:draft|planned|parked|in-progress|completed|archived)/${escaped}(?:/_overview\\.md|\\.md)$`, "u");
87
91
  const hits = names.split("\n").filter((name) => pattern.test(normalizePath(name)));
88
92
  if (hits.length !== 1)
89
93
  return null;
@@ -135,21 +139,42 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
135
139
  // event still counts only when its subjectDigest matches the digest of the
136
140
  // exact working bytes being committed and its artifact hash-verifies on disk —
137
141
  // content-addressing keeps this sound before the commit exists.
138
- function collectWorkingV2ReviewProvenance(file, issues) {
142
+ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
139
143
  try {
144
+ const repoRoot = findGitRoot(file);
145
+ if (!repoRoot)
146
+ return [];
140
147
  const slug = stableSlugForBlueprintFile(file);
141
148
  const working = readWorkingReviewEvents(file, slug);
142
149
  if (working.events.length === 0)
143
150
  return [];
144
151
  issues?.push(...working.issues);
145
- const digest = createPlanSubjectDigestForContent(slug, readFileSync(file));
146
- const evaluation = evaluateReviewEvents(working.events, {
147
- purpose: "plan",
152
+ const subjects = purpose === "plan"
153
+ ? [
154
+ {
155
+ scheme: "wp-blueprint-v1",
156
+ digest: createPlanSubjectDigestForContent(slug, readFileSync(file)),
157
+ },
158
+ ]
159
+ : [
160
+ ...new Map(working.events
161
+ .filter((event) => event.purpose === "delivery")
162
+ .flatMap((event) => {
163
+ const subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
164
+ if (subject.digest !== event.subjectDigest) {
165
+ issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
166
+ return [];
167
+ }
168
+ return [[subject.digest, subject]];
169
+ })).values(),
170
+ ];
171
+ return subjects
172
+ .flatMap((subject) => evaluateReviewEvents(working.events, {
173
+ purpose,
148
174
  blueprintSlug: slug,
149
- subjectScheme: "wp-blueprint-v1",
150
- subjectDigest: digest,
151
- }, working.compromisedIds);
152
- return evaluation.approvals
175
+ subjectScheme: subject.scheme,
176
+ subjectDigest: subject.digest,
177
+ }, working.compromisedIds).approvals)
153
178
  .filter((event) => event.artifactPath)
154
179
  .map((event) => ({
155
180
  reviewer: event.reviewer,
@@ -166,7 +191,7 @@ export function collectV2ReviewProvenance(file, purpose, ref = "HEAD", issues) {
166
191
  const repoRoot = findGitRoot(file);
167
192
  if (!repoRoot)
168
193
  return [];
169
- const workingApprovals = purpose === "plan" ? collectWorkingV2ReviewProvenance(file, issues) : [];
194
+ const workingApprovals = collectWorkingV2ReviewProvenance(file, purpose, issues);
170
195
  if (!workingContentMatchesRef(repoRoot, file, purpose, ref))
171
196
  return workingApprovals;
172
197
  try {
@@ -48,6 +48,7 @@ export declare class BlueprintCreationService {
48
48
  resolveDraftSlug(input: CreateBlueprintDraftInput): string;
49
49
  compileDraft(input: CreateBlueprintDraftInput): Promise<CompiledBlueprintDraft>;
50
50
  create(input: CreateBlueprintDraftInput): Promise<CreatedBlueprintDraft>;
51
+ private reserveDraftSlugFolder;
51
52
  private resolveCollisionSafeSlug;
52
53
  }
53
54
  export {};
@@ -42,12 +42,38 @@ function sentenceCase(value) {
42
42
  }
43
43
  return value.slice(0, 1).toUpperCase() + value.slice(1);
44
44
  }
45
+ // A slug becomes a branch name, worktree directory, and repeated CLI argument,
46
+ // so an unbounded goal string must not produce an unbounded slug. Cap it at a
47
+ // word AND a character ceiling (whichever binds first), truncating on a word
48
+ // boundary. The full goal survives as the human-readable title; only the slug
49
+ // is bounded. Collision suffixing runs AFTER this cap, so `-2` is never counted
50
+ // against the ceiling.
51
+ const MAX_SLUG_WORDS = 6;
52
+ const MAX_SLUG_CHARS = 50;
53
+ // Bound on atomic slug-folder reservation retries under concurrent creation.
54
+ const MAX_SLUG_RESERVATION_ATTEMPTS = 50;
55
+ function capSlug(slug) {
56
+ const [firstWord, ...rest] = slug.split("-").filter(Boolean).slice(0, MAX_SLUG_WORDS);
57
+ if (firstWord === undefined)
58
+ return slug;
59
+ let capped = firstWord;
60
+ for (const word of rest) {
61
+ const candidate = `${capped}-${word}`;
62
+ if (candidate.length > MAX_SLUG_CHARS)
63
+ break;
64
+ capped = candidate;
65
+ }
66
+ // A single first word longer than the ceiling is hard-trimmed; drop any
67
+ // trailing hyphen the trim could expose.
68
+ return capped.slice(0, MAX_SLUG_CHARS).replace(/-+$/u, "");
69
+ }
45
70
  function deriveSlug(goal) {
46
- return goal
71
+ const slug = goal
47
72
  .toLowerCase()
48
73
  .replace(/[^a-z0-9]+/g, "-")
49
74
  .replace(/^-+|-+$/g, "")
50
75
  .replace(/-{2,}/g, "-");
76
+ return capSlug(slug);
51
77
  }
52
78
  function assertGoalProducesUsableSlug(goal, slug) {
53
79
  if (slug.length === 0) {
@@ -223,11 +249,16 @@ export class BlueprintCreationService {
223
249
  async create(input) {
224
250
  // H-009: refuse primary-like roots so drafts never leak onto main checkouts.
225
251
  assertDraftAuthoringAllowed(this.projectRoot);
226
- const draft = await this.compileDraft(input);
227
252
  const draftRoot = path.join(this.blueprintsRoot, "draft");
228
- const finalPath = draft.outputPath;
229
253
  await mkdir(draftRoot, { recursive: true });
230
- await mkdir(path.dirname(finalPath), { recursive: true });
254
+ // Atomically RESERVE the slug folder. Two concurrent creations (CLI *and*
255
+ // MCP) can each resolve the same collision-safe slug before either writes;
256
+ // a plain recursive mkdir + rename would then let the later writer silently
257
+ // overwrite the earlier draft. `mkdir(..., { recursive: false })` is atomic:
258
+ // exactly one racer creates the folder, the loser gets EEXIST and re-resolves
259
+ // to the next `-N` suffix (compileDraft re-scans and sees the reservation).
260
+ const draft = await this.reserveDraftSlugFolder(input);
261
+ const finalPath = draft.outputPath;
231
262
  const tempDir = await mkdtemp(path.join(draftRoot, `${draft.slug}.tmp-`));
232
263
  const tempPath = path.join(tempDir, `${draft.slug}.md`);
233
264
  try {
@@ -252,6 +283,24 @@ export class BlueprintCreationService {
252
283
  throw error;
253
284
  }
254
285
  }
286
+ // Compile a draft and atomically claim its slug folder, retrying with the next
287
+ // collision-safe slug when a concurrent creator wins the folder first.
288
+ async reserveDraftSlugFolder(input) {
289
+ for (let attempt = 0; attempt < MAX_SLUG_RESERVATION_ATTEMPTS; attempt += 1) {
290
+ const candidate = await this.compileDraft(input);
291
+ try {
292
+ await mkdir(path.dirname(candidate.outputPath), { recursive: false });
293
+ return candidate;
294
+ }
295
+ catch (error) {
296
+ // Folder already reserved by a concurrent creator — re-resolve the slug.
297
+ if (error instanceof Error && error.code === "EEXIST")
298
+ continue;
299
+ throw error;
300
+ }
301
+ }
302
+ throw new Error(`Could not reserve a unique blueprint slug after ${MAX_SLUG_RESERVATION_ATTEMPTS} attempts.`);
303
+ }
255
304
  resolveCollisionSafeSlug(baseSlug) {
256
305
  const blueprintsRoot = this.blueprintsRoot;
257
306
  const existing = new Set(scanBlueprintDirectory({
@@ -1,15 +1,29 @@
1
1
  import matter from "gray-matter";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { getBlueprintDocumentCandidates } from "#utils/document-paths.js";
4
5
  import { resolveTechDebtRoot } from "#utils/tech-debt-root";
6
+ async function resolveBlueprintDocumentPath(baseDir, bpSlug) {
7
+ const candidates = getBlueprintDocumentCandidates(baseDir, bpSlug);
8
+ for (const candidate of candidates) {
9
+ try {
10
+ await fs.access(candidate);
11
+ return candidate;
12
+ }
13
+ catch {
14
+ // Keep trying the other canonical shape.
15
+ }
16
+ }
17
+ return candidates.at(-1) ?? path.join(baseDir, bpSlug, "_overview.md");
18
+ }
5
19
  export async function linkBlueprintToTechDebt(baseDir, projectPath, bpSlug, tdSlug) {
6
- const blueprintPath = path.join(baseDir, bpSlug, "_overview.md");
20
+ const blueprintPath = await resolveBlueprintDocumentPath(baseDir, bpSlug);
7
21
  await updateLinkedSlugs(blueprintPath, "linked_tech_debt_slugs", (linked) => [...linked, tdSlug]);
8
22
  const tdPath = path.join(resolveTechDebtRoot(projectPath), tdSlug, "README.md");
9
23
  await updateLinkedSlugs(tdPath, "linked_blueprints", (linked) => [...linked, bpSlug]);
10
24
  }
11
25
  export async function unlinkBlueprintFromTechDebt(baseDir, projectPath, bpSlug, tdSlug) {
12
- const blueprintPath = path.join(baseDir, bpSlug, "_overview.md");
26
+ const blueprintPath = await resolveBlueprintDocumentPath(baseDir, bpSlug);
13
27
  await updateLinkedSlugs(blueprintPath, "linked_tech_debt_slugs", (linked) => linked.filter((slug) => slug !== tdSlug));
14
28
  const tdPath = path.join(resolveTechDebtRoot(projectPath), tdSlug, "README.md");
15
29
  try {
@@ -0,0 +1,26 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export declare const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30000;
3
+ export declare const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
4
+ export type AllowedWpCommandOutcome = "passed" | "failed" | "timed-out";
5
+ export interface AllowedWpCommandResult {
6
+ readonly command: string;
7
+ readonly outcome: AllowedWpCommandOutcome;
8
+ readonly durationMs: number;
9
+ readonly exitCode: number | null;
10
+ readonly signal?: string;
11
+ readonly stdout: string;
12
+ readonly stderr: string;
13
+ readonly logPath?: string;
14
+ readonly failureCode?: string;
15
+ readonly errorMessage?: string;
16
+ }
17
+ export interface ExecuteAllowedWpCommandInput {
18
+ readonly command: string;
19
+ readonly executionRoot: string;
20
+ readonly logRoot: string;
21
+ readonly env?: NodeJS.ProcessEnv;
22
+ readonly spawn?: typeof spawnSync;
23
+ }
24
+ export declare function executeAllowedWpCommand(input: ExecuteAllowedWpCommandInput): AllowedWpCommandResult;
25
+ export declare function tailBounded(text: string): string;
26
+ export declare function relativeCommandLogPath(root: string, logPath: string | undefined): string | undefined;
@@ -0,0 +1,148 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { parseAllowedWpCommand } from "./gates.js";
5
+ import { resolvePackageAssetPreferred } from "#utils/package-assets.js";
6
+ export const ALLOWED_WP_COMMAND_TIMEOUT_MS = 30_000;
7
+ export const ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT = 500;
8
+ export function executeAllowedWpCommand(input) {
9
+ const started = Date.now();
10
+ let invocation;
11
+ try {
12
+ const argv = parseAllowedWpCommand(input.command);
13
+ const [binary, ...args] = argv;
14
+ if (binary === undefined)
15
+ throw new Error(`Promotion gate command is empty: ${input.command}`);
16
+ invocation = resolveAllowedWpInvocation(input.executionRoot, binary, args, input.env);
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ const result = {
21
+ command: input.command,
22
+ outcome: "failed",
23
+ durationMs: Date.now() - started,
24
+ exitCode: null,
25
+ stdout: "",
26
+ stderr: message,
27
+ failureCode: "command-policy-rejected",
28
+ errorMessage: message,
29
+ };
30
+ return {
31
+ ...result,
32
+ logPath: writeAllowedWpCommandLog(input.logRoot, input.command, null, result),
33
+ };
34
+ }
35
+ const spawn = input.spawn ?? spawnSync;
36
+ const result = spawn(invocation.command, invocation.args, {
37
+ cwd: input.executionRoot,
38
+ encoding: "utf8",
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ timeout: ALLOWED_WP_COMMAND_TIMEOUT_MS,
41
+ env: invocation.env,
42
+ });
43
+ const durationMs = Date.now() - started;
44
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
45
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
46
+ const timedOut = result.error ? errorCode(result.error) === "ETIMEDOUT" : false;
47
+ const output = {
48
+ command: input.command,
49
+ outcome: result.status === 0 && !result.error && result.signal === null
50
+ ? "passed"
51
+ : timedOut
52
+ ? "timed-out"
53
+ : "failed",
54
+ durationMs,
55
+ exitCode: typeof result.status === "number" ? result.status : null,
56
+ ...(result.signal ? { signal: result.signal } : {}),
57
+ stdout,
58
+ stderr,
59
+ ...(timedOut ? { failureCode: "command-timed-out" } : {}),
60
+ ...(result.error ? { errorMessage: result.error.message } : {}),
61
+ };
62
+ if (output.outcome === "passed")
63
+ return output;
64
+ return {
65
+ ...output,
66
+ logPath: writeAllowedWpCommandLog(input.logRoot, input.command, invocation, output),
67
+ };
68
+ }
69
+ export function tailBounded(text) {
70
+ if (text.length <= ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT)
71
+ return text.trim();
72
+ return text.slice(-ALLOWED_WP_COMMAND_STDIO_TAIL_LIMIT).trim();
73
+ }
74
+ export function relativeCommandLogPath(root, logPath) {
75
+ if (!logPath)
76
+ return undefined;
77
+ const rel = relative(root, logPath).replace(/\\/gu, "/");
78
+ return rel === "" || rel.startsWith("../") ? logPath : rel;
79
+ }
80
+ function resolveAllowedWpInvocation(executionRoot, binary, args, extraEnv) {
81
+ const baseEnv = { ...process.env, ...extraEnv };
82
+ if (binary === "./bin/wp" || binary === "wp") {
83
+ const localLauncher = resolveRepoWpLauncher(executionRoot);
84
+ if (localLauncher !== null) {
85
+ return {
86
+ command: localLauncher,
87
+ args: [...args],
88
+ env: { ...baseEnv, PATH: `${executionRoot}/bin:${baseEnv["PATH"] ?? ""}` },
89
+ };
90
+ }
91
+ if (binary === "./bin/wp") {
92
+ return {
93
+ command: join(executionRoot, "bin", process.platform === "win32" ? "wp.cmd" : "wp"),
94
+ args: [...args],
95
+ env: { ...baseEnv, PATH: `${executionRoot}/bin:${baseEnv["PATH"] ?? ""}` },
96
+ };
97
+ }
98
+ const packagedLauncher = resolvePackagedWpLauncher();
99
+ return {
100
+ command: process.execPath,
101
+ args: [packagedLauncher, ...args],
102
+ env: baseEnv,
103
+ };
104
+ }
105
+ return {
106
+ command: binary,
107
+ args: [...args],
108
+ env: { ...baseEnv, PATH: `${executionRoot}/bin:${baseEnv["PATH"] ?? ""}` },
109
+ };
110
+ }
111
+ function resolveRepoWpLauncher(repoRoot) {
112
+ const launcher = join(repoRoot, "bin", process.platform === "win32" ? "wp.cmd" : "wp");
113
+ return existsSync(launcher) ? launcher : null;
114
+ }
115
+ function resolvePackagedWpLauncher() {
116
+ const launcher = resolvePackageAssetPreferred([
117
+ process.platform === "win32" ? "bin/wp.cmd" : "bin/wp",
118
+ "bin/wp",
119
+ ]);
120
+ if (!existsSync(launcher)) {
121
+ throw new Error(`Promotion gate failed: packaged wp launcher is unavailable at ${launcher}`);
122
+ }
123
+ return launcher;
124
+ }
125
+ function writeAllowedWpCommandLog(logRoot, command, invocation, result) {
126
+ mkdirSync(logRoot, { recursive: true });
127
+ const logPath = join(logRoot, `${Date.now()}-wp-command.log`);
128
+ writeFileSync(logPath, [
129
+ `command: ${command}`,
130
+ `invocation: ${invocation ? `${invocation.command} ${invocation.args.join(" ")}` : "not-run"}`,
131
+ `outcome: ${result.outcome}`,
132
+ `exit: ${result.exitCode ?? "null"}`,
133
+ `signal: ${result.signal ?? "null"}`,
134
+ `timeout_ms: ${ALLOWED_WP_COMMAND_TIMEOUT_MS}`,
135
+ `error: ${result.errorMessage ?? "none"}`,
136
+ "--- stderr ---",
137
+ result.stderr,
138
+ "--- stdout ---",
139
+ result.stdout,
140
+ "",
141
+ ].join("\n"), "utf8");
142
+ return logPath;
143
+ }
144
+ function errorCode(error) {
145
+ return "code" in error && typeof error.code === "string"
146
+ ? error.code
147
+ : null;
148
+ }
@@ -1 +1,8 @@
1
1
  export declare function parseAllowedWpCommand(command: string): string[];
2
+ /**
3
+ * Extracts the `--file` targets from an argv already parsed by
4
+ * `parseAllowedWpCommand`. Returns an empty array for any non-`wp test` argv.
5
+ * `parseAllowedWpCommand` guarantees `wp test` args are only `--file <target>`
6
+ * pairs, so no shell/flag validation is needed here.
7
+ */
8
+ export declare function wpTestFileTargets(argv: readonly string[]): string[];
@@ -54,3 +54,23 @@ export function parseAllowedWpCommand(command) {
54
54
  }
55
55
  throw new Error(`Unsupported promotion gate wp subcommand: ${sub ?? ""}`);
56
56
  }
57
+ /**
58
+ * Extracts the `--file` targets from an argv already parsed by
59
+ * `parseAllowedWpCommand`. Returns an empty array for any non-`wp test` argv.
60
+ * `parseAllowedWpCommand` guarantees `wp test` args are only `--file <target>`
61
+ * pairs, so no shell/flag validation is needed here.
62
+ */
63
+ export function wpTestFileTargets(argv) {
64
+ if (argv[1] !== "test")
65
+ return [];
66
+ const targets = [];
67
+ const rest = argv.slice(2);
68
+ for (let i = 0; i < rest.length; i++) {
69
+ const target = rest[i + 1];
70
+ if (rest[i] === "--file" && target !== undefined) {
71
+ targets.push(target);
72
+ i += 1;
73
+ }
74
+ }
75
+ return targets;
76
+ }
@@ -7,7 +7,15 @@ export type PromotionTrustInput = {
7
7
  markdown: string;
8
8
  now?: Date;
9
9
  };
10
+ export type PromotionTrustProof = {
11
+ readonly file: string;
12
+ readonly sourceMarkdown: string;
13
+ readonly verifiedHead: string;
14
+ readonly trustedMarkdown: string;
15
+ };
16
+ export declare function preparePromotionTrustGate(input: PromotionTrustInput): PromotionTrustProof;
10
17
  export declare function applyPromotionTrustGate(input: PromotionTrustInput): string;
18
+ export declare function assertPromotionTrustProofCurrent(input: Pick<PromotionTrustInput, "repoRoot" | "file" | "markdown">, proof: PromotionTrustProof): void;
11
19
  export declare function runPromotionCommand(repoRoot: string, command: string, deps?: {
12
20
  spawn?: typeof spawnSync;
13
21
  }): void;