@webpresso/agent-kit 3.3.3 → 3.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/agent/rules/changeset-release.md +15 -4
  3. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  4. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  5. package/catalog/agent/rules/pre-implementation.md +36 -20
  6. package/catalog/agent/rules/rtk-routing.md +22 -16
  7. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  8. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  9. package/dist/esm/audit/agents.js +126 -26
  10. package/dist/esm/audit/hook-surface.js +5 -1
  11. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  12. package/dist/esm/audit/repo-guardrails.js +71 -1
  13. package/dist/esm/blueprint/core/parser.js +38 -12
  14. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  15. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  16. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  17. package/dist/esm/blueprint/execution/metadata.js +8 -4
  18. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  19. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  20. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  22. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  23. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  24. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  25. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  26. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  27. package/dist/esm/blueprint/trust/dossier.js +41 -1
  28. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  29. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  30. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  31. package/dist/esm/blueprint/trust/promotion.js +19 -19
  32. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  33. package/dist/esm/build/atomic-file-copy.js +44 -0
  34. package/dist/esm/build/vendor-oxlint-plugins.d.ts +11 -0
  35. package/dist/esm/build/vendor-oxlint-plugins.js +48 -0
  36. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  37. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  38. package/dist/esm/cli/commands/compile.d.ts +13 -0
  39. package/dist/esm/cli/commands/compile.js +182 -62
  40. package/dist/esm/cli/commands/format.js +17 -1
  41. package/dist/esm/cli/commands/gain/index.js +10 -3
  42. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  43. package/dist/esm/cli/commands/init/index.js +32 -52
  44. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  45. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  46. package/dist/esm/cli/commands/init/merge.js +30 -10
  47. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  48. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  49. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  50. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  51. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  52. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  53. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  54. package/dist/esm/cli/commands/pr-help.js +1 -1
  55. package/dist/esm/cli/commands/pr.js +18 -4
  56. package/dist/esm/cli/commands/review.d.ts +1 -0
  57. package/dist/esm/cli/commands/review.js +137 -7
  58. package/dist/esm/cli/commands/sync.d.ts +8 -6
  59. package/dist/esm/cli/commands/sync.js +11 -9
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  62. package/dist/esm/cli/commands/worktree/router.js +5 -2
  63. package/dist/esm/cli/direct-provider-launch.js +12 -4
  64. package/dist/esm/compiler/flatten.d.ts +10 -1
  65. package/dist/esm/compiler/flatten.js +14 -4
  66. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  67. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  68. package/dist/esm/compose/compose.d.ts +8 -0
  69. package/dist/esm/compose/compose.js +115 -8
  70. package/dist/esm/compose/ops-report-schema.js +17 -3
  71. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  72. package/dist/esm/compose/registry-resolve.js +78 -11
  73. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  74. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  75. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  76. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  77. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  78. package/dist/esm/config/oxlint/oxlintrc.js +19 -31
  79. package/dist/esm/config/oxlint/path-roles.js +53 -0
  80. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  81. package/dist/esm/content/dispatch.js +4 -0
  82. package/dist/esm/content/loader.d.ts +1 -1
  83. package/dist/esm/content/loader.js +10 -6
  84. package/dist/esm/content/skill-policy.d.ts +11 -0
  85. package/dist/esm/content/skill-policy.js +33 -0
  86. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  87. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  88. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  89. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  90. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  91. package/dist/esm/daemon/domains/launch.js +30 -1
  92. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  93. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  94. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  95. package/dist/esm/daemon/handlers/product.js +10 -1
  96. package/dist/esm/daemon/methods.d.ts +21 -1
  97. package/dist/esm/daemon/methods.js +20 -0
  98. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  99. package/dist/esm/daemon/protocol/contract.js +38 -0
  100. package/dist/esm/daemon/server.js +7 -0
  101. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  102. package/dist/esm/errors/wp-error.js +21 -2
  103. package/dist/esm/hooks/doctor.d.ts +11 -0
  104. package/dist/esm/hooks/doctor.js +114 -45
  105. package/dist/esm/hooks/pretool-guard/dev-routing.js +68 -5
  106. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  107. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  108. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  109. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  110. package/dist/esm/hooks/status/index.js +1 -1
  111. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  112. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  113. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  114. package/dist/esm/mcp/blueprint/registration.js +1 -1
  115. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  116. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  117. package/dist/esm/mcp/tools/gain.js +28 -2
  118. package/dist/esm/mcp/tools/worktree.js +65 -3
  119. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  120. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  121. package/dist/esm/review/authority.js +56 -1
  122. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  123. package/dist/esm/review/delivery-verifier.js +9 -1
  124. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  125. package/dist/esm/review/lifecycle-writers.js +330 -0
  126. package/dist/esm/review/subject.js +66 -32
  127. package/dist/esm/status/snapshot.d.ts +5 -0
  128. package/dist/esm/status/snapshot.js +27 -2
  129. package/dist/esm/symlinker/consumers.d.ts +3 -1
  130. package/dist/esm/symlinker/consumers.js +7 -1
  131. package/dist/esm/symlinker/unified-sync.d.ts +3 -3
  132. package/dist/esm/symlinker/unified-sync.js +88 -11
  133. package/dist/esm/test/shard-durations.json +0 -2
  134. package/dist/esm/test-helpers/global-setup.js +38 -3
  135. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  136. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  137. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  138. package/dist/esm/utils/package-root.d.ts +32 -0
  139. package/dist/esm/utils/package-root.js +82 -0
  140. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  141. package/dist/esm/worktrees/git-metadata.js +48 -0
  142. package/package.json +16 -21
  143. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  144. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
  145. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  146. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  147. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  148. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  149. package/dist/esm/config/oxlint/index.d.ts +0 -22
  150. package/dist/esm/config/oxlint/index.js +0 -28
  151. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  152. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  153. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  154. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  155. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  156. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -1,20 +1,13 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { readConfig } from "#cli/commands/init/config";
4
- import { DEFAULT_PACKAGED_SKILL_SLUG_SET } from "#content/skill-policy";
4
+ import { AGENT_KIT_INTERNAL_SKILL_SLUGS, DEFAULT_PACKAGED_SKILL_SLUG_SET, DEFAULT_PACKAGED_SKILL_SLUGS, MERGED_DELETED_SKILL_SLUGS, UNPACKAGED_SKILL_SLUGS, } from "#content/skill-policy";
5
5
  import { readTrustedJsonFile } from "#shared-utils/read-json-file.js";
6
6
  import matter from "gray-matter";
7
7
  import { WP_HOOK_SPECS } from "#cli/commands/init/scaffolders/agent-hooks/ir.js";
8
8
  const REQUIRED_HOOKS = WP_HOOK_SPECS.map(({ event, bin }) => ({ event, bin }));
9
9
  const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
10
10
  const CODEX_HOOKS_PATH = ".codex/hooks.json";
11
- const REQUIRED_SUBAGENTS = [
12
- "code-reviewer.md",
13
- "security-auditor.md",
14
- "doc-writer.md",
15
- "explorer.md",
16
- "implementer.md",
17
- ];
18
11
  const EXPLORER_CONTRACT_MARKERS = [
19
12
  "3–5 tool calls",
20
13
  "Defs:",
@@ -54,6 +47,10 @@ export function auditAgents(rootDirectory = process.cwd()) {
54
47
  checkCatalogAgents(root, violations);
55
48
  checked += 1;
56
49
  checkCatalogReportOnlySkills(root, violations);
50
+ checked += 1;
51
+ checkSelfHostClaudeAgents(root, violations);
52
+ checked += 1;
53
+ checkCatalogSkillClassification(root, violations);
57
54
  }
58
55
  else {
59
56
  checked += 1;
@@ -117,6 +114,19 @@ function checkCatalogSkillHooks(root, violations) {
117
114
  }
118
115
  }
119
116
  }
117
+ /**
118
+ * List the `.md` filenames (e.g. "code-reviewer.md") directly under a
119
+ * catalog/agent/agents-shaped directory, excluding README.md. Returns an
120
+ * empty array when the directory does not exist. This is the single source
121
+ * of truth for "which subagents are required" — no hardcoded list.
122
+ */
123
+ function listCatalogAgentFiles(agentsSourceDir) {
124
+ if (!existsSync(agentsSourceDir))
125
+ return [];
126
+ return readdirSync(agentsSourceDir)
127
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
128
+ .sort();
129
+ }
120
130
  function checkCatalogAgents(root, violations) {
121
131
  const agentsSource = join(root, "catalog", "agent", "agents");
122
132
  if (!existsSync(agentsSource)) {
@@ -126,28 +136,44 @@ function checkCatalogAgents(root, violations) {
126
136
  });
127
137
  return;
128
138
  }
129
- for (const file of REQUIRED_SUBAGENTS) {
139
+ const requiredFiles = listCatalogAgentFiles(agentsSource);
140
+ if (requiredFiles.length === 0) {
141
+ violations.push({
142
+ file: "catalog/agent/agents",
143
+ message: "catalog/agent/agents must contain canonical subagent markdown files.",
144
+ });
145
+ return;
146
+ }
147
+ for (const file of requiredFiles) {
148
+ if (file !== "explorer.md")
149
+ continue;
130
150
  const sourcePath = join(agentsSource, file);
131
- if (!existsSync(sourcePath)) {
151
+ const content = readFileSync(sourcePath, "utf8");
152
+ for (const marker of EXPLORER_CONTRACT_MARKERS) {
153
+ if (content.includes(marker))
154
+ continue;
132
155
  violations.push({
133
156
  file: relative(root, sourcePath),
134
- message: `Missing canonical subagent ${file} in catalog/agent/agents.`,
157
+ message: `Explorer subagent output contract is missing marker: ${marker}`,
135
158
  });
136
- continue;
137
- }
138
- if (file === "explorer.md") {
139
- const content = readFileSync(sourcePath, "utf8");
140
- for (const marker of EXPLORER_CONTRACT_MARKERS) {
141
- if (content.includes(marker))
142
- continue;
143
- violations.push({
144
- file: relative(root, sourcePath),
145
- message: `Explorer subagent output contract is missing marker: ${marker}`,
146
- });
147
- }
148
159
  }
149
160
  }
150
161
  }
162
+ /**
163
+ * Self-host-only: validate `.claude/agents/` (this repo's own generated
164
+ * projection) the same way a consumer's `.claude/agents/` is validated.
165
+ * Absent is acceptable — the surface is generated + gitignored in a clean
166
+ * checkout — but when present, every catalog-required subagent must resolve
167
+ * to a live symlink.
168
+ */
169
+ function checkSelfHostClaudeAgents(root, violations) {
170
+ const agentsTarget = join(root, ".claude", "agents");
171
+ if (!existsSync(agentsTarget))
172
+ return;
173
+ const agentsSource = join(root, "catalog", "agent", "agents");
174
+ const requiredFiles = listCatalogAgentFiles(agentsSource);
175
+ validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
176
+ }
151
177
  function checkCatalogReportOnlySkills(root, violations) {
152
178
  const skillsSource = join(root, "catalog", "agent", "skills");
153
179
  if (!existsSync(skillsSource)) {
@@ -193,6 +219,63 @@ function checkCatalogReportOnlySkills(root, violations) {
193
219
  }
194
220
  }
195
221
  }
222
+ /**
223
+ * Resolve the required subagent file list for a consumer repo. Preferred
224
+ * source: the installed catalog under node_modules (the real consumer-mode
225
+ * source of truth that `.claude/agents/*.md` symlinks point at). When
226
+ * node_modules is not installed (e.g. a partial checkout), fall back to
227
+ * whatever `.claude/agents/*.md` already exists so symlink-health checks
228
+ * still run without false-positiving "missing" for agents we have no
229
+ * catalog reference for.
230
+ */
231
+ function resolveConsumerRequiredAgentFiles(root) {
232
+ const installedAgentsSource = join(root, "node_modules", "@webpresso", "agent-kit", "catalog", "agent", "agents");
233
+ const fromInstalled = listCatalogAgentFiles(installedAgentsSource);
234
+ if (fromInstalled.length > 0)
235
+ return fromInstalled;
236
+ const agentsTarget = join(root, ".claude", "agents");
237
+ if (!existsSync(agentsTarget))
238
+ return [];
239
+ // Restrict to symlinks only — a hand-authored real file (Claude Code's
240
+ // native subagent authoring surface) is never agent-kit-managed and must
241
+ // not be false-flagged as "must remain a symlink" just because node_modules
242
+ // isn't installed to prove otherwise.
243
+ return readdirSync(agentsTarget)
244
+ .filter((file) => file.endsWith(".md") && lstatSync(join(agentsTarget, file)).isSymbolicLink())
245
+ .sort();
246
+ }
247
+ /**
248
+ * Every catalog skill slug must be accounted for as either default-packaged,
249
+ * explicitly unpackaged (with a reason), merged/deleted, or an
250
+ * agent-kit-internal maintainer-only skill. An unclassified slug is a
251
+ * violation — it means a new catalog skill was added without a deliberate
252
+ * packaging decision.
253
+ */
254
+ function checkCatalogSkillClassification(root, violations) {
255
+ const skillsSource = join(root, "catalog", "agent", "skills");
256
+ if (!existsSync(skillsSource))
257
+ return; // reported by checkCatalogReportOnlySkills
258
+ const knownSlugs = new Set([
259
+ ...DEFAULT_PACKAGED_SKILL_SLUGS,
260
+ ...Object.keys(UNPACKAGED_SKILL_SLUGS),
261
+ ...MERGED_DELETED_SKILL_SLUGS,
262
+ ...AGENT_KIT_INTERNAL_SKILL_SLUGS,
263
+ ]);
264
+ for (const entry of readdirSync(skillsSource, { withFileTypes: true })) {
265
+ if (!entry.isDirectory())
266
+ continue;
267
+ if (!existsSync(join(skillsSource, entry.name, "SKILL.md")))
268
+ continue;
269
+ if (knownSlugs.has(entry.name))
270
+ continue;
271
+ violations.push({
272
+ file: `catalog/agent/skills/${entry.name}/SKILL.md`,
273
+ message: `Skill "${entry.name}" is not classified as packaged or unpackaged. Add it to ` +
274
+ "DEFAULT_PACKAGED_SKILL_SLUGS or UNPACKAGED_SKILL_SLUGS in src/content/skill-policy.ts " +
275
+ "with a one-line reason.",
276
+ });
277
+ }
278
+ }
196
279
  function checkClaudeAgents(root, violations) {
197
280
  const agentsTarget = join(root, ".claude", "agents");
198
281
  if (!existsSync(agentsTarget)) {
@@ -202,16 +285,33 @@ function checkClaudeAgents(root, violations) {
202
285
  });
203
286
  return;
204
287
  }
205
- for (const file of REQUIRED_SUBAGENTS) {
288
+ const requiredFiles = resolveConsumerRequiredAgentFiles(root);
289
+ validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
290
+ }
291
+ /**
292
+ * Shared per-file symlink validator for `.claude/agents/<file>`, used by both
293
+ * the self-host and consumer audit paths. Checks: missing entry, not a
294
+ * symlink, wrong/unrecognized target, and dangling target.
295
+ */
296
+ function validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations) {
297
+ for (const file of requiredFiles) {
206
298
  const targetPath = join(agentsTarget, file);
207
- if (!existsSync(targetPath)) {
299
+ // lstatSync (not existsSync) so a dangling symlink — an entry that
300
+ // exists but resolves to nothing — is distinguished from a genuinely
301
+ // missing entry. existsSync follows symlinks, so it would misreport
302
+ // every dangling symlink as "missing" and the dangling check below
303
+ // would never be reachable.
304
+ let stat;
305
+ try {
306
+ stat = lstatSync(targetPath);
307
+ }
308
+ catch {
208
309
  violations.push({
209
310
  file: relative(root, targetPath),
210
311
  message: `Missing Claude subagent ${file}. Run \`wp setup\` to re-sync canonical subagents.`,
211
312
  });
212
313
  continue;
213
314
  }
214
- const stat = lstatSync(targetPath);
215
315
  if (!stat.isSymbolicLink()) {
216
316
  violations.push({
217
317
  file: relative(root, targetPath),
@@ -33,7 +33,11 @@ export function detectDrift(entries) {
33
33
  .map(([key, entries]) => ({ key, count: entries.length, entries }));
34
34
  }
35
35
  function isRewriter(command) {
36
- return (command.includes("rtk hook claude") ||
36
+ // Managed pretool-guard emits updatedInput rewrites (exact git status → rtk).
37
+ // Classify it as a rewriter so freestanding RTK + pretool-guard collide in audit.
38
+ return (command.includes("wp-pretool-guard") ||
39
+ command.includes("wp hook pretool-guard") ||
40
+ command.includes("rtk hook claude") ||
37
41
  command.endsWith("pretooluse.mjs") ||
38
42
  command.includes("/pretooluse.mjs"));
39
43
  }
@@ -49,6 +49,14 @@ export declare function auditCatalogDrift(rootDirectory?: string, options?: Cata
49
49
  export declare function validateCommitMessage(message: string, options?: CommitMessageOptions): RepoAuditResult;
50
50
  export declare function auditCommitMessageFile(messageFile: string, options?: CommitMessageOptions): RepoAuditResult;
51
51
  export declare function auditDocsFrontmatter(rootDirectory?: string, options?: DocsFrontmatterOptions): RepoAuditResult;
52
+ /**
53
+ * Flags a blueprint whose raw frontmatter clearly lists one or more
54
+ * `approvals:` entries but whose YAML-parsed `approvals` field yields zero —
55
+ * the corruption signature `wp blueprint move --force-recovery` produces
56
+ * when it silently destroys approval authority without making the document
57
+ * fail to parse outright.
58
+ */
59
+ export declare function detectSuspiciousZeroApprovals(raw: string, frontmatter: Record<string, unknown>): string | undefined;
52
60
  export declare function auditBlueprintLifecycle(rootDirectory?: string, options?: BlueprintLifecycleOptions): RepoAuditResult;
53
61
  export declare function formatRepoAuditReport(auditResult: RepoAuditResult): string;
54
62
  export declare function parseFrontmatter(markdown: string): Record<string, string>;
@@ -210,6 +210,53 @@ export function auditDocsFrontmatter(rootDirectory = process.cwd(), options = {}
210
210
  }
211
211
  return result("Docs frontmatter", markdownFiles.length, violations);
212
212
  }
213
+ const APPROVALS_KEY_LINE = /^approvals:\s*$/m;
214
+ /**
215
+ * Count `- ` list-item lines that follow a top-level `approvals:` key inside
216
+ * the RAW frontmatter text, stopping at the next top-level key. Returns -1
217
+ * when there is no `approvals:` key at all (nothing to check).
218
+ *
219
+ * This is deliberately independent of the YAML parser: it is the ground
220
+ * truth for "did the author intend approval entries here", used to catch a
221
+ * corrupted write where the YAML still happens to parse but the approvals
222
+ * mapping was silently truncated or displaced (e.g. a scalar-restore bug
223
+ * that inserts or drops lines near the approvals block).
224
+ */
225
+ function countRawApprovalEntries(raw) {
226
+ const frontmatterBlock = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? "";
227
+ const keyIndex = frontmatterBlock.search(APPROVALS_KEY_LINE);
228
+ if (keyIndex === -1)
229
+ return -1;
230
+ const afterKey = frontmatterBlock.slice(keyIndex).split("\n").slice(1);
231
+ let count = 0;
232
+ for (const line of afterKey) {
233
+ if (/^[A-Za-z0-9_]+:/.test(line))
234
+ break;
235
+ if (/^\s*-\s/.test(line))
236
+ count += 1;
237
+ }
238
+ return count;
239
+ }
240
+ /**
241
+ * Flags a blueprint whose raw frontmatter clearly lists one or more
242
+ * `approvals:` entries but whose YAML-parsed `approvals` field yields zero —
243
+ * the corruption signature `wp blueprint move --force-recovery` produces
244
+ * when it silently destroys approval authority without making the document
245
+ * fail to parse outright.
246
+ */
247
+ export function detectSuspiciousZeroApprovals(raw, frontmatter) {
248
+ const rawEntryCount = countRawApprovalEntries(raw);
249
+ if (rawEntryCount <= 0)
250
+ return undefined;
251
+ const parsedApprovals = frontmatter["approvals"];
252
+ const parsedCount = Array.isArray(parsedApprovals) ? parsedApprovals.length : 0;
253
+ if (parsedCount > 0)
254
+ return undefined;
255
+ return (`Frontmatter parses to 0 approvals but the raw YAML lists ${rawEntryCount} ` +
256
+ "approval entr" +
257
+ (rawEntryCount === 1 ? "y" : "ies") +
258
+ " — likely corrupted frontmatter near the approvals block.");
259
+ }
213
260
  export function auditBlueprintLifecycle(rootDirectory = process.cwd(), options = {}) {
214
261
  const root = resolve(rootDirectory);
215
262
  const blueprintsRoot = resolve(root, options.blueprintsRoot ?? "blueprints");
@@ -259,7 +306,30 @@ export function auditBlueprintLifecycle(rootDirectory = process.cwd(), options =
259
306
  continue;
260
307
  }
261
308
  const raw = readFileSync(canonicalPath, "utf8");
262
- const frontmatter = matter(raw).data;
309
+ let frontmatter;
310
+ try {
311
+ frontmatter = matter(raw).data;
312
+ }
313
+ catch (error) {
314
+ // A blueprint write (e.g. `wp blueprint move --force-recovery`) can
315
+ // emit invalid YAML — most commonly a folded-scalar continuation
316
+ // line stranded after an already-quoted scalar. Every downstream
317
+ // reader (including the approval gate) then fails the same way this
318
+ // parse does, so surface it as a first-class violation instead of
319
+ // letting the whole audit crash on the first corrupted file.
320
+ violations.push({
321
+ file: relativePath(root, canonicalPath),
322
+ message: `Frontmatter failed to parse as YAML: ${error instanceof Error ? error.message : String(error)}`,
323
+ });
324
+ continue;
325
+ }
326
+ const suspiciousApprovals = detectSuspiciousZeroApprovals(raw, frontmatter);
327
+ if (suspiciousApprovals) {
328
+ violations.push({
329
+ file: relativePath(root, canonicalPath),
330
+ message: suspiciousApprovals,
331
+ });
332
+ }
263
333
  if (frontmatter.type !== "blueprint" && frontmatter.type !== "parent-roadmap") {
264
334
  violations.push({
265
335
  file: relativePath(root, canonicalPath),
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import matter from "gray-matter";
8
8
  import { planFrontmatterSchema, taskStatusSchema } from "#core/schema";
9
+ import { extractBlockedReason } from "#markdown/blocked-line";
9
10
  import { buildTaskHeadingRegex, isTaskHeaderLine } from "#markdown/task-heading";
10
11
  function parseWithSchema(markdown, schema) {
11
12
  const { data: rawData, content } = matter(markdown);
@@ -87,7 +88,13 @@ const SERIALIZE_OWNED_FRONTMATTER_KEYS = new Set([
87
88
  "progress",
88
89
  "completed_at",
89
90
  ]);
90
- const FRONTMATTER_SCALAR_LINE = /^([A-Za-z0-9_]+):[ \t]+\S.*$/;
91
+ // `\r?$` is load-bearing on a CRLF checkout: `blueprint.raw` is stored verbatim
92
+ // and split on "\n", so every original line still carries a trailing `\r`. `.`
93
+ // does not match a line terminator, so without `\r?` the anchor cannot match,
94
+ // NO originals are collected, preservation bails, and matter.stringify's
95
+ // re-quoted scalars survive — silently invalidating every recorded approval on
96
+ // that checkout. `FRONTMATTER_MULTILINE_KEY` below already carries `\r?$`.
97
+ const FRONTMATTER_SCALAR_LINE = /^([A-Za-z0-9_]+):[ \t]+\S.*\r?$/;
91
98
  function quoteGeneratedProgressScalar(serialized, progress) {
92
99
  if (!progress)
93
100
  return serialized;
@@ -108,7 +115,10 @@ function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
108
115
  for (const line of originalFrontmatter.split("\n")) {
109
116
  const match = FRONTMATTER_SCALAR_LINE.exec(line);
110
117
  if (match && !SERIALIZE_OWNED_FRONTMATTER_KEYS.has(match[1])) {
111
- originalScalars.set(match[1], line);
118
+ // Store without the source's line ending; the substitution below re-applies
119
+ // whichever ending the SERIALIZED document uses, so a CRLF original is
120
+ // never spliced into an LF document (or vice versa) as a mixed ending.
121
+ originalScalars.set(match[1], line.replace(/\r$/, ""));
112
122
  }
113
123
  }
114
124
  if (originalScalars.size === 0)
@@ -116,13 +126,30 @@ function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
116
126
  const block = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(serialized);
117
127
  if (!block)
118
128
  return serialized;
119
- const rebuilt = block[2]
120
- .split("\n")
121
- .map((line) => {
129
+ const lines = block[2].split("\n");
130
+ const rebuiltLines = [];
131
+ for (let index = 0; index < lines.length; index += 1) {
132
+ const line = lines[index];
122
133
  const match = FRONTMATTER_SCALAR_LINE.exec(line);
123
- return (match && originalScalars.get(match[1])) || line;
124
- })
125
- .join("\n");
134
+ const originalScalar = match && originalScalars.get(match[1]);
135
+ if (!originalScalar) {
136
+ rebuiltLines.push(line);
137
+ continue;
138
+ }
139
+ rebuiltLines.push(line.endsWith("\r") ? `${originalScalar}\r` : originalScalar);
140
+ // A freshly re-dumped value for this key may not fit on one line: long
141
+ // plain/quoted scalars get YAML-folded by js-yaml into indented
142
+ // continuation lines directly beneath the `key:` line (no `key:` prefix
143
+ // of their own). We are replacing the whole value wholesale with the
144
+ // original single-line scalar, so those continuation lines must be
145
+ // dropped too — otherwise they survive as an orphaned mapping fragment
146
+ // right after an already-complete (and possibly already-quote-closed)
147
+ // scalar, which is invalid YAML.
148
+ while (index + 1 < lines.length && /^[ \t]/.test(lines[index + 1])) {
149
+ index += 1;
150
+ }
151
+ }
152
+ const rebuilt = rebuiltLines.join("\n");
126
153
  return `${serialized.slice(0, block.index)}${block[1]}${rebuilt}${block[3]}${serialized.slice(block.index + block[0].length)}`;
127
154
  }
128
155
  // Same residual as scalars, for multi-line blocks (YAML arrays/maps under a
@@ -328,11 +355,10 @@ function extractDepends(section) {
328
355
  return ids.filter((id) => id !== "");
329
356
  }
330
357
  function extractBlocked(section) {
331
- const blockedMatch = section.match(/\*\*Blocked:\*\*\s*(.+)/i);
332
- if (!blockedMatch?.[1])
358
+ const blockedText = extractBlockedReason(section);
359
+ if (blockedText === undefined)
333
360
  return undefined;
334
- const blockedText = blockedMatch[1].trim();
335
- if (blockedText === "" || blockedText.toLowerCase() === "none") {
361
+ if (blockedText.toLowerCase() === "none") {
336
362
  return undefined;
337
363
  }
338
364
  return blockedText;
@@ -29,16 +29,20 @@ export function readBlueprintExecutionArtifacts(markdown) {
29
29
  };
30
30
  }
31
31
  export function writeBlueprintExecutionArtifacts(markdown, artifacts) {
32
- return setBlueprintFrontmatterFields(markdown, {
32
+ // Typed against the shared key union so a key can never be written here
33
+ // without also entering the plan-subject normalizer's strip set.
34
+ const fields = {
33
35
  execution_artifacts: artifacts.artifacts,
34
36
  execution_log_path: artifacts.logPath,
35
37
  execution_verifications: artifacts.verifications,
36
- });
38
+ };
39
+ return setBlueprintFrontmatterFields(markdown, fields);
37
40
  }
38
41
  export function clearBlueprintExecutionArtifacts(markdown) {
39
- return setBlueprintFrontmatterFields(markdown, {
42
+ const fields = {
40
43
  execution_artifacts: undefined,
41
44
  execution_log_path: undefined,
42
45
  execution_verifications: undefined,
43
- });
46
+ };
47
+ return setBlueprintFrontmatterFields(markdown, fields);
44
48
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Single owner of the `execution_*` blueprint frontmatter key names.
3
+ *
4
+ * The execution backend writes these keys into `_overview.md`
5
+ * (`./metadata.js`, `./artifacts.js`), and the plan-subject normalizer
6
+ * (`#review/subject.js`) strips them so starting or advancing an execution does
7
+ * not dismiss a recorded plan approval. Both sides import this list, so a key
8
+ * added to one side without the other is a typecheck error rather than a silent
9
+ * digest leak.
10
+ *
11
+ * Deliberately dependency-free so the normalizer can import it without pulling
12
+ * in the lifecycle engine.
13
+ */
14
+ export declare const EXECUTION_METADATA_FRONTMATTER_KEYS: readonly ["execution_backend", "execution_id", "execution_status", "execution_updated_at"];
15
+ export type ExecutionMetadataFrontmatterKey = (typeof EXECUTION_METADATA_FRONTMATTER_KEYS)[number];
16
+ export declare const EXECUTION_ARTIFACTS_FRONTMATTER_KEYS: readonly ["execution_artifacts", "execution_log_path", "execution_verifications"];
17
+ export type ExecutionArtifactsFrontmatterKey = (typeof EXECUTION_ARTIFACTS_FRONTMATTER_KEYS)[number];
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Single owner of the `execution_*` blueprint frontmatter key names.
3
+ *
4
+ * The execution backend writes these keys into `_overview.md`
5
+ * (`./metadata.js`, `./artifacts.js`), and the plan-subject normalizer
6
+ * (`#review/subject.js`) strips them so starting or advancing an execution does
7
+ * not dismiss a recorded plan approval. Both sides import this list, so a key
8
+ * added to one side without the other is a typecheck error rather than a silent
9
+ * digest leak.
10
+ *
11
+ * Deliberately dependency-free so the normalizer can import it without pulling
12
+ * in the lifecycle engine.
13
+ */
14
+ export const EXECUTION_METADATA_FRONTMATTER_KEYS = [
15
+ "execution_backend",
16
+ "execution_id",
17
+ "execution_status",
18
+ "execution_updated_at",
19
+ ];
20
+ export const EXECUTION_ARTIFACTS_FRONTMATTER_KEYS = [
21
+ "execution_artifacts",
22
+ "execution_log_path",
23
+ "execution_verifications",
24
+ ];
@@ -28,18 +28,22 @@ export function readBlueprintExecutionMetadata(markdown) {
28
28
  };
29
29
  }
30
30
  export function writeBlueprintExecutionMetadata(markdown, metadata) {
31
- return setBlueprintFrontmatterFields(markdown, {
31
+ // Typed against the shared key union so a key can never be written here
32
+ // without also entering the plan-subject normalizer's strip set.
33
+ const fields = {
32
34
  execution_backend: metadata.backend,
33
35
  execution_id: metadata.executionId,
34
36
  execution_status: metadata.status,
35
37
  execution_updated_at: metadata.updatedAt,
36
- });
38
+ };
39
+ return setBlueprintFrontmatterFields(markdown, fields);
37
40
  }
38
41
  export function clearBlueprintExecutionMetadata(markdown) {
39
- return setBlueprintFrontmatterFields(markdown, {
42
+ const fields = {
40
43
  execution_backend: undefined,
41
44
  execution_id: undefined,
42
45
  execution_status: undefined,
43
46
  execution_updated_at: undefined,
44
- });
47
+ };
48
+ return setBlueprintFrontmatterFields(markdown, fields);
45
49
  }
@@ -47,6 +47,27 @@ export interface ApprovalGateEvaluation {
47
47
  satisfied: boolean;
48
48
  strongReviewers: string[];
49
49
  }
50
+ /**
51
+ * Whether to explain a skipped legacy approval leg to the caller.
52
+ *
53
+ * Exported so the decision is unit-testable without standing up a v2 ledger
54
+ * fixture. Two properties matter and neither is obvious from the call site:
55
+ *
56
+ * 1. Never fail silently. Before this message existed a skipped leg reported as a
57
+ * bare "0 approvals" with no reason, which is the most expensive failure mode
58
+ * in this area.
59
+ * 2. Never cry wolf. A post-rollout blueprint that already holds content-bound v2
60
+ * authority and merely still carries a leftover frontmatter entry needs no
61
+ * message: the leg was not required. `validateApprovalGate` turns sink entries
62
+ * into `Review evidence integrity:` WARNINGS on an otherwise-passing audit, so
63
+ * an unconditional note would flag clean strict runs as suspect.
64
+ */
65
+ export declare function shouldExplainSkippedLegacyLeg(input: {
66
+ readonly legacyLegApplies: boolean;
67
+ readonly includesPlanPurpose: boolean;
68
+ readonly approvals: unknown;
69
+ readonly currentReviewers: readonly string[];
70
+ }): boolean;
50
71
  export declare function countDistinctProvenanceBackedApprovals(file: string, approvals: unknown): number;
51
72
  export declare function evaluateApprovalGate(file: string, approvals: unknown, options?: {
52
73
  readonly purpose?: "plan" | "delivery";
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import matter from "gray-matter";
@@ -13,7 +14,7 @@ import { resolveBlueprintRoot } from "#utils/blueprint-root";
13
14
  import { parseBlueprintDocumentRelativePath } from "#utils/document-paths.js";
14
15
  import { relativeBlueprintSlug } from "#lifecycle/local.js";
15
16
  import { collectV2ReviewProvenance, collectTrackedReviewProvenance, normalizeTrackedReviewApprovals, stableSlugForBlueprintFile, } from "#lifecycle/review-provenance.js";
16
- import { isBlueprintExemptFromRollout, ROLLOUT_DATE } from "#lifecycle/rollout-anchor.js";
17
+ import { isBlueprintExemptFromRollout, LEGACY_APPROVAL_LEG_ANCHOR_COMMIT, LEGACY_APPROVAL_LEG_ROLLOUT_DATE, ROLLOUT_DATE, } from "#lifecycle/rollout-anchor.js";
17
18
  import { MAINTAINER_OVERRIDE_REVIEWER } from "#review/events.js";
18
19
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
19
20
  function isBlueprintOverview(file) {
@@ -106,14 +107,99 @@ function approvalReviewerKey(reviewer) {
106
107
  const parts = normalized.split(/[/:@#]/).filter(Boolean);
107
108
  return parts.at(-1) ?? normalized;
108
109
  }
110
+ /**
111
+ * `created:` as written in the blueprint's own frontmatter.
112
+ *
113
+ * Only consulted on the non-agent-kit fallback path inside
114
+ * {@link isBlueprintExemptFromRollout} (where the anchor commit is absent and
115
+ * cannot answer the question), so the fact that the gated author writes this
116
+ * value does not weaken the gate where it is enforced. Read from disk rather than
117
+ * threaded through {@link evaluateApprovalGate}'s ten call sites: a call site
118
+ * that forgot the parameter would silently fail the leg closed and block
119
+ * promotion on a grandfathered blueprint.
120
+ */
121
+ function readBlueprintCreated(file) {
122
+ try {
123
+ return matter(readFileSync(file, "utf8")).data.created;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /**
130
+ * True when this blueprint may still satisfy the approval gate through the
131
+ * LEGACY frontmatter `approvals:` leg.
132
+ *
133
+ * That leg carries no content binding: `collectTrackedReviewProvenance` resolves
134
+ * a ledger from `approval.evidence`, matches a `reviews.md` record and requires a
135
+ * tracked artifact, but never reads or hashes the blueprint body — and the gate
136
+ * supplies no `targetHash`, so `matchesApproval`'s binding clause is skipped too.
137
+ * An approval recorded against one plan therefore kept satisfying the gate after
138
+ * the plan was rewritten into something else entirely.
139
+ *
140
+ * The leg is grandfathered rather than deleted because 172 already-landed
141
+ * blueprints resolve their approval through it; failing those would block
142
+ * ordinary work (a completed blueprint touched again in a PR is re-gated). New
143
+ * blueprints get the fail-closed behaviour: their authority must come from the
144
+ * content-addressed v2 `review-events.jsonl` ledger, which `wp review gate` and
145
+ * `wp review override` already write.
146
+ *
147
+ * `approval.targetHash`/`commit`/`rev` do NOT make an approval content-bound —
148
+ * those fields only cross-check the approval against its `reviews.md` record
149
+ * (`matchesApproval`), and neither is ever compared against document content. So
150
+ * presence of a binding-looking field is deliberately not an escape from this
151
+ * check.
152
+ */
153
+ function legacyApprovalLegIsGrandfathered(file) {
154
+ return isBlueprintExemptFromRollout({
155
+ repoRoot: findGitRoot(file),
156
+ slug: stableSlugForBlueprintFile(file),
157
+ created: readBlueprintCreated(file),
158
+ rolloutDate: LEGACY_APPROVAL_LEG_ROLLOUT_DATE,
159
+ anchorCommit: LEGACY_APPROVAL_LEG_ANCHOR_COMMIT,
160
+ });
161
+ }
162
+ function hasPolicyApprovalEntry(approvals) {
163
+ return normalizeTrackedReviewApprovals(approvals).some((entry) => isPolicyApprovalVerdict(entry.verdict));
164
+ }
165
+ /**
166
+ * Whether to explain a skipped legacy approval leg to the caller.
167
+ *
168
+ * Exported so the decision is unit-testable without standing up a v2 ledger
169
+ * fixture. Two properties matter and neither is obvious from the call site:
170
+ *
171
+ * 1. Never fail silently. Before this message existed a skipped leg reported as a
172
+ * bare "0 approvals" with no reason, which is the most expensive failure mode
173
+ * in this area.
174
+ * 2. Never cry wolf. A post-rollout blueprint that already holds content-bound v2
175
+ * authority and merely still carries a leftover frontmatter entry needs no
176
+ * message: the leg was not required. `validateApprovalGate` turns sink entries
177
+ * into `Review evidence integrity:` WARNINGS on an otherwise-passing audit, so
178
+ * an unconditional note would flag clean strict runs as suspect.
179
+ */
180
+ export function shouldExplainSkippedLegacyLeg(input) {
181
+ return (!input.legacyLegApplies &&
182
+ input.includesPlanPurpose &&
183
+ hasPolicyApprovalEntry(input.approvals) &&
184
+ !evaluateApprovalReviewers(input.currentReviewers).satisfied);
185
+ }
109
186
  function provenanceBackedApprovalReviewers(file, approvals, options = {}) {
110
187
  const purposes = options.purpose
111
188
  ? [options.purpose]
112
189
  : ["plan", "delivery"];
113
- const legacy = purposes.includes("plan")
190
+ const current = purposes.flatMap((purpose) => collectV2ReviewProvenance(file, purpose, "HEAD", options.issuesSink).map((record) => record.reviewer));
191
+ const legacyLegApplies = purposes.includes("plan") && legacyApprovalLegIsGrandfathered(file);
192
+ if (shouldExplainSkippedLegacyLeg({
193
+ legacyLegApplies,
194
+ includesPlanPurpose: purposes.includes("plan"),
195
+ approvals,
196
+ currentReviewers: current,
197
+ })) {
198
+ options.issuesSink?.push("legacy frontmatter approval entries are not accepted for this blueprint: the unbound `approvals:` leg is grandfathered only for slugs tracked at the legacy-approval rollout anchor. Record the approval through `wp review gate` so it binds to the reviewed content.");
199
+ }
200
+ const legacy = legacyLegApplies
114
201
  ? collectTrackedReviewProvenance(file, approvals, options).map((record) => record.reviewer)
115
202
  : [];
116
- const current = purposes.flatMap((purpose) => collectV2ReviewProvenance(file, purpose, "HEAD", options.issuesSink).map((record) => record.reviewer));
117
203
  return new Set([...legacy, ...current]);
118
204
  }
119
205
  export function countDistinctProvenanceBackedApprovals(file, approvals) {