@webpresso/agent-kit 3.3.2 → 3.3.4

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 (167) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/AGENTS.md.tpl +3 -5
  3. package/catalog/agent/rules/changeset-release.md +15 -4
  4. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  5. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  6. package/catalog/agent/rules/pre-implementation.md +36 -20
  7. package/catalog/agent/rules/rtk-routing.md +22 -16
  8. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  9. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  10. package/catalog/agent/skills/verify/SKILL.md +12 -12
  11. package/catalog/compose/admin-blocks-registry.json +659 -0
  12. package/dist/esm/audit/hook-surface.js +5 -1
  13. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  14. package/dist/esm/audit/repo-guardrails.js +71 -1
  15. package/dist/esm/blueprint/core/parser.js +40 -16
  16. package/dist/esm/blueprint/core/validation/criteria.js +2 -1
  17. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  18. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  19. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  20. package/dist/esm/blueprint/execution/metadata.js +8 -4
  21. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  22. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  23. package/dist/esm/blueprint/lifecycle/review-provenance.js +148 -40
  24. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  25. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  26. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  27. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  28. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  29. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  30. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  31. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  32. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  33. package/dist/esm/blueprint/trust/promotion.js +19 -19
  34. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  35. package/dist/esm/build/atomic-file-copy.js +44 -0
  36. package/dist/esm/build/cli-mcp-parity.js +3 -2
  37. package/dist/esm/build/package-manifest.js +7 -2
  38. package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
  39. package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
  40. package/dist/esm/cli/commands/audit-core.js +9 -1
  41. package/dist/esm/cli/commands/audit.js +16 -5
  42. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  43. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  44. package/dist/esm/cli/commands/compile.d.ts +13 -0
  45. package/dist/esm/cli/commands/compile.js +182 -62
  46. package/dist/esm/cli/commands/format.js +17 -1
  47. package/dist/esm/cli/commands/gain/index.js +10 -3
  48. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  49. package/dist/esm/cli/commands/init/index.js +29 -48
  50. package/dist/esm/cli/commands/init/mcp-spec.d.ts +54 -4
  51. package/dist/esm/cli/commands/init/mcp-spec.js +126 -22
  52. package/dist/esm/cli/commands/init/merge.js +30 -10
  53. package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
  54. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  55. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  56. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  57. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  58. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  59. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  60. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  61. package/dist/esm/cli/commands/package-manager.js +8 -2
  62. package/dist/esm/cli/commands/pr-help.js +1 -1
  63. package/dist/esm/cli/commands/pr.js +18 -4
  64. package/dist/esm/cli/commands/review.js +60 -6
  65. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  66. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  67. package/dist/esm/cli/commands/worktree/router.js +5 -2
  68. package/dist/esm/cli/direct-provider-launch.js +12 -4
  69. package/dist/esm/compiler/flatten.d.ts +10 -1
  70. package/dist/esm/compiler/flatten.js +14 -4
  71. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  72. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  73. package/dist/esm/compose/compose.d.ts +46 -0
  74. package/dist/esm/compose/compose.js +250 -0
  75. package/dist/esm/compose/escape-html.d.ts +5 -0
  76. package/dist/esm/compose/escape-html.js +12 -0
  77. package/dist/esm/compose/ops-report-render.d.ts +5 -0
  78. package/dist/esm/compose/ops-report-render.js +71 -0
  79. package/dist/esm/compose/ops-report-schema.d.ts +42 -0
  80. package/dist/esm/compose/ops-report-schema.js +60 -0
  81. package/dist/esm/compose/preview.d.ts +14 -0
  82. package/dist/esm/compose/preview.js +32 -0
  83. package/dist/esm/compose/registry-resolve.d.ts +64 -0
  84. package/dist/esm/compose/registry-resolve.js +200 -0
  85. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  86. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  87. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  88. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  89. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  90. package/dist/esm/config/oxlint/oxlintrc.js +18 -21
  91. package/dist/esm/config/oxlint/path-roles.js +53 -0
  92. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  93. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  94. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  95. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  96. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  97. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  98. package/dist/esm/daemon/domains/launch.js +30 -1
  99. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  100. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  101. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  102. package/dist/esm/daemon/handlers/product.js +10 -1
  103. package/dist/esm/daemon/methods.d.ts +21 -1
  104. package/dist/esm/daemon/methods.js +20 -0
  105. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  106. package/dist/esm/daemon/protocol/contract.js +38 -0
  107. package/dist/esm/daemon/server.js +7 -0
  108. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  109. package/dist/esm/errors/wp-error.js +21 -2
  110. package/dist/esm/git/changed-files.d.ts +20 -0
  111. package/dist/esm/git/changed-files.js +63 -0
  112. package/dist/esm/hooks/doctor.d.ts +11 -0
  113. package/dist/esm/hooks/doctor.js +114 -45
  114. package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
  115. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  116. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  117. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  118. package/dist/esm/hooks/status/index.js +1 -1
  119. package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
  120. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  121. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  122. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  123. package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
  124. package/dist/esm/mcp/blueprint/registration.js +1 -1
  125. package/dist/esm/mcp/tools/_names.d.ts +1 -1
  126. package/dist/esm/mcp/tools/_names.js +4 -0
  127. package/dist/esm/mcp/tools/_registry.js +8 -0
  128. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  129. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  130. package/dist/esm/mcp/tools/gain.js +28 -2
  131. package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
  132. package/dist/esm/mcp/tools/release-progress.js +110 -0
  133. package/dist/esm/mcp/tools/worktree.js +65 -3
  134. package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
  135. package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
  136. package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
  137. package/dist/esm/mcp/tools/wp-ui-compose.js +96 -0
  138. package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
  139. package/dist/esm/mcp/tools/wp-ui-preview.js +59 -0
  140. package/dist/esm/package.json +2 -0
  141. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  142. package/dist/esm/review/lifecycle-writers.js +330 -0
  143. package/dist/esm/review/subject.js +66 -32
  144. package/dist/esm/status/snapshot.d.ts +5 -0
  145. package/dist/esm/status/snapshot.js +27 -2
  146. package/dist/esm/test/shard-durations.json +0 -1
  147. package/dist/esm/test-helpers/global-setup.js +38 -3
  148. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  149. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  150. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  151. package/dist/esm/utils/package-root.d.ts +32 -0
  152. package/dist/esm/utils/package-root.js +82 -0
  153. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  154. package/dist/esm/worktrees/git-metadata.js +48 -0
  155. package/package.json +27 -21
  156. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  157. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  158. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  159. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  160. package/dist/esm/config/oxlint/index.d.ts +0 -22
  161. package/dist/esm/config/oxlint/index.js +0 -28
  162. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  163. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  164. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  165. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  166. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  167. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -108,10 +108,22 @@ export function readBlueprintOverviewAtRef(repoRoot, ref, file) {
108
108
  }
109
109
  }
110
110
  }
111
- function workingContentMatchesRef(repoRoot, file, purpose, ref) {
111
+ /**
112
+ * Whether the working-tree document still represents what `ref` holds, so the
113
+ * ref-side review ledger may be consulted.
114
+ *
115
+ * Every `false` branch records a specific reason in `issues`. Without that, the
116
+ * caller reports "0 approvals" with no `Evidence issues:` suffix and no way to
117
+ * tell a substantive plan edit apart from a missing overview, a path outside the
118
+ * repo, or a failed git probe — an opacity that has cost whole debugging
119
+ * sessions on exactly this class of digest divergence.
120
+ */
121
+ function workingContentMatchesRef(repoRoot, file, purpose, ref, issues) {
112
122
  const relative = path.relative(repoRoot, file);
113
- if (relative.startsWith("..") || path.isAbsolute(relative))
123
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
124
+ issues?.push(`Review authority at ${ref} not consulted for ${file}: the blueprint path resolves outside the repository root ${repoRoot}.`);
114
125
  return false;
126
+ }
115
127
  try {
116
128
  if (purpose === "plan") {
117
129
  // Compare normalized plan content, not raw bytes: promotion rewrites
@@ -122,10 +134,16 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
122
134
  // Also resolve by stable slug when the lifecycle path changed (draft→planned)
123
135
  // so pre-commit on a rename does not falsely drop plan authority.
124
136
  const atRef = readBlueprintOverviewAtRef(repoRoot, ref, file);
125
- if (!atRef)
137
+ if (!atRef) {
138
+ issues?.push(`Review authority at ${ref} not consulted for ${relative}: no blueprint overview for this slug exists at ${ref}.`);
126
139
  return false;
140
+ }
127
141
  const working = readFileSync(file);
128
- return normalizePlanSubjectContent(working).equals(normalizePlanSubjectContent(atRef));
142
+ if (normalizePlanSubjectContent(working).equals(normalizePlanSubjectContent(atRef))) {
143
+ return true;
144
+ }
145
+ issues?.push(`Review authority at ${ref} not consulted for ${relative}: the working document's normalized plan content differs from ${ref}, so its plan-subject digest differs and no approval recorded against ${ref} can match. Re-run the plan review gate against the current content, or restore the reviewed content.`);
146
+ return false;
129
147
  }
130
148
  const output = execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
131
149
  cwd: repoRoot,
@@ -133,12 +151,93 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
133
151
  stdio: ["ignore", "pipe", "pipe"],
134
152
  timeout: 5_000,
135
153
  });
136
- return output.trim().length === 0;
154
+ if (output.trim().length === 0)
155
+ return true;
156
+ issues?.push(`Review authority at ${ref} not consulted for ${relative}: the working tree is dirty, so a delivery subject at ${ref} does not cover the current content.`);
157
+ return false;
137
158
  }
138
- catch {
159
+ catch (error) {
160
+ issues?.push(`Review authority at ${ref} not consulted for ${relative}: divergence probe failed: ${error instanceof Error ? error.message : String(error)}`);
139
161
  return false;
140
162
  }
141
163
  }
164
+ /**
165
+ * Candidate delivery subjects for the working path.
166
+ *
167
+ * Two sources, in this order:
168
+ *
169
+ * 1. **The live tree at HEAD.** This mirrors what {@link readReviewAuthorityAtRef}
170
+ * already does for delivery on the ref path: it builds the subject from the
171
+ * ref under audit and matches events by digest, never resolving the commit an
172
+ * event recorded. Doing the same here removes the working path's outlier
173
+ * behaviour.
174
+ * 2. **Reconstruction at each event's `reviewedCommit`**, for events the live
175
+ * subject does not already cover.
176
+ *
177
+ * Why the live tree has to come first: `reviewedCommit` is a *locator*, not the
178
+ * binding. The binding is `subjectDigest`, which content-addresses the reviewed
179
+ * tree. A squash merge destroys the branch commit while preserving its tree, so
180
+ * an approval recorded at the branch tip kept a digest that still describes the
181
+ * post-merge tree exactly — yet resolving its now-orphaned SHA threw, the event
182
+ * was marked compromised, and a genuinely valid approval was discarded. The
183
+ * standing workaround was to hand-append a rebind event after every squash.
184
+ *
185
+ * Matching on the digest first is not a loosening: an event is accepted only
186
+ * when the tree it recorded is byte-for-byte the tree we have. Content that
187
+ * differs still fails, and an event whose commit is gone *and* whose digest
188
+ * matches nothing reachable is still marked unresolvable — fail-closed.
189
+ */
190
+ function collectWorkingDeliverySubjects(input) {
191
+ const { repoRoot, slug, blueprintPath, events, unresolvableEventIds, issues } = input;
192
+ const candidates = new Map();
193
+ // Nothing to resolve against: skip before paying for a whole-tree read. This
194
+ // path also runs for plan-only ledgers, where the old code cost nothing.
195
+ if (!events.some((event) => event.purpose === "delivery"))
196
+ return [];
197
+ let liveDigest = null;
198
+ try {
199
+ const live = createDeliverySubjectAtRef(repoRoot, "HEAD", slug, blueprintPath);
200
+ liveDigest = live.digest;
201
+ candidates.set(live.digest, { scheme: live.scheme, digest: live.digest });
202
+ }
203
+ catch (error) {
204
+ // No HEAD (fresh repo) or no blueprint document at HEAD (first commit of a
205
+ // new blueprint). Not fatal: fall through to per-event reconstruction.
206
+ issues?.push(`Working delivery subject unavailable at HEAD for ${slug}: ${error instanceof Error ? error.message : String(error)}`);
207
+ }
208
+ for (const event of events) {
209
+ if (event.purpose !== "delivery")
210
+ continue;
211
+ // Already covered by the live tree — the reviewed content is exactly what
212
+ // we have, so the recorded commit does not need to exist any more.
213
+ if (liveDigest !== null && event.subjectDigest === liveDigest)
214
+ continue;
215
+ // Scope subject-construction failure to THIS event. A rebase rewrites
216
+ // commit SHAs, so a previously-recorded `reviewedCommit` can become
217
+ // unresolvable; letting that throw would escape to the outer catch and
218
+ // discard every approval, including valid ones for commits that still
219
+ // resolve. Mirror the ref-path's markCompromisedArtifactEvents: record the
220
+ // issue, mark the event compromised, and drop only it.
221
+ let subject;
222
+ try {
223
+ subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, blueprintPath);
224
+ }
225
+ catch (error) {
226
+ issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
227
+ // Compromised (not merely skipped) so the event can never grant approval
228
+ // while still participating in reviewer supersession — see
229
+ // evaluateReviewEvents.
230
+ unresolvableEventIds.add(event.id);
231
+ continue;
232
+ }
233
+ if (subject.digest !== event.subjectDigest) {
234
+ issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
235
+ continue;
236
+ }
237
+ candidates.set(subject.digest, { scheme: subject.scheme, digest: subject.digest });
238
+ }
239
+ return [...candidates.values()];
240
+ }
142
241
  // Working-ledger acceptance: pre-commit validates a blueprint whose new review
143
242
  // events are staged alongside it and therefore not yet at any git ref. An
144
243
  // event still counts only when its subjectDigest matches the digest of the
@@ -147,13 +246,17 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
147
246
  function collectWorkingV2ReviewProvenance(file, purpose, issues) {
148
247
  try {
149
248
  const repoRoot = findGitRoot(file);
150
- if (!repoRoot)
249
+ if (!repoRoot) {
250
+ issues?.push(`Working review authority unavailable for ${file}: no enclosing git repository was found.`);
151
251
  return [];
252
+ }
152
253
  const slug = stableSlugForBlueprintFile(file);
153
254
  const working = readWorkingReviewEvents(file, slug);
255
+ issues?.push(...working.issues);
256
+ // No v2 ledger at all is the normal shape for a blueprint whose authority is
257
+ // a legacy frontmatter approval, so it is not a reason worth reporting.
154
258
  if (working.events.length === 0)
155
259
  return [];
156
- issues?.push(...working.issues);
157
260
  // Events whose recorded `reviewedCommit` no longer resolves (typically
158
261
  // orphaned by a rebase). Collected here rather than mutating
159
262
  // `working.compromisedIds`, which is a ReadonlySet by contract, then merged
@@ -167,37 +270,15 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
167
270
  createLegacyPlanSubjectDigestForContent(slug, readFileSync(file)),
168
271
  ].map((digest) => [digest, { scheme: "wp-blueprint-v1", digest }])).values(),
169
272
  ]
170
- : [
171
- ...new Map(working.events
172
- .filter((event) => event.purpose === "delivery")
173
- .flatMap((event) => {
174
- // Scope subject-construction failure to THIS event. A rebase
175
- // rewrites commit SHAs, so a previously-recorded
176
- // `reviewedCommit` can become unresolvable; letting that throw
177
- // would escape to the outer catch and discard every approval,
178
- // including valid ones for commits that still resolve. Mirror
179
- // the ref-path's markCompromisedArtifactEvents: record the
180
- // issue, mark the event compromised, and drop only it.
181
- let subject;
182
- try {
183
- subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
184
- }
185
- catch (error) {
186
- issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
187
- // Compromised (not merely skipped) so the event can never
188
- // grant approval while still participating in reviewer
189
- // supersession — see evaluateReviewEvents.
190
- unresolvableEventIds.add(event.id);
191
- return [];
192
- }
193
- if (subject.digest !== event.subjectDigest) {
194
- issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
195
- return [];
196
- }
197
- return [[subject.digest, subject]];
198
- })).values(),
199
- ];
200
- return subjects
273
+ : collectWorkingDeliverySubjects({
274
+ repoRoot,
275
+ slug,
276
+ blueprintPath: normalizePath(path.relative(repoRoot, file)),
277
+ events: working.events,
278
+ unresolvableEventIds,
279
+ issues,
280
+ });
281
+ const approvals = subjects
201
282
  .flatMap((subject) => evaluateReviewEvents(working.events, {
202
283
  purpose,
203
284
  blueprintSlug: slug,
@@ -212,6 +293,19 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
212
293
  artifact: event.artifactPath,
213
294
  targetHash: event.subjectDigest,
214
295
  }));
296
+ if (approvals.length === 0) {
297
+ // The ledger has events but none of them approves the CURRENT content.
298
+ // Name both sides of the comparison: this is the digest-divergence
299
+ // failure mode, and reporting it as a bare "0 approvals" is what makes it
300
+ // expensive to diagnose.
301
+ const computed = subjects.length === 0
302
+ ? "no subject could be computed for the current content"
303
+ : `computed subject digest(s) ${subjects.map((subject) => subject.digest).join(", ")}`;
304
+ issues?.push(`No working-tree approval matched the current ${purpose} subject of ${slug}: ${computed}; the ledger's ${working.events.length} event(s) bind ${[
305
+ ...new Set(working.events.map((event) => event.subjectDigest)),
306
+ ].join(", ")}.`);
307
+ }
308
+ return approvals;
215
309
  }
216
310
  catch (error) {
217
311
  issues?.push(`Working review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
@@ -222,9 +316,23 @@ export function collectV2ReviewProvenance(file, purpose, ref = "HEAD", issues) {
222
316
  const repoRoot = findGitRoot(file);
223
317
  if (!repoRoot)
224
318
  return [];
319
+ // Only explain a skipped/unmatched v2 lookup when the blueprint actually has a
320
+ // v2 ledger. A blueprint whose authority is a legacy frontmatter approval has
321
+ // no ledger by design, and narrating the absence for every one of those would
322
+ // bury the one case that matters (a ledger that no longer matches its subject)
323
+ // under warnings that report nothing wrong.
324
+ const explain = existsSync(path.join(path.dirname(file), "review-events.jsonl"))
325
+ ? issues
326
+ : undefined;
225
327
  const workingApprovals = collectWorkingV2ReviewProvenance(file, purpose, issues);
226
- if (!workingContentMatchesRef(repoRoot, file, purpose, ref))
328
+ if (!workingContentMatchesRef(repoRoot, file, purpose, ref, explain)) {
329
+ // The ref-side ledger is deliberately skipped here (its approvals bind a
330
+ // subject the working content no longer matches), so the working set is all
331
+ // the authority there is. Say so: an empty result on this path used to be
332
+ // indistinguishable from "there was never any review".
333
+ explain?.push(`Returning ${workingApprovals.length} working-tree approval(s) for ${file} without ref authority from ${ref}.`);
227
334
  return workingApprovals;
335
+ }
228
336
  try {
229
337
  const authority = readReviewAuthorityAtRef(repoRoot, ref, stableSlugForBlueprintFile(file), purpose);
230
338
  issues?.push(...authority.issues);
@@ -25,6 +25,28 @@
25
25
  export declare const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
26
26
  /** The date that anchor landed, used only by the legacy fallback below. */
27
27
  export declare const ROLLOUT_DATE = "2026-07-12";
28
+ /**
29
+ * The commit that closed the legacy frontmatter approval bypass ("fix(review):
30
+ * bind the legacy frontmatter approval leg to the rollout anchor", 2026-07-25).
31
+ *
32
+ * Until that change, a frontmatter `approvals:` entry naming a strong reviewer
33
+ * satisfied the promotion gate with **no content binding whatsoever**: the gate
34
+ * called `evaluateApprovalGate` without a `targetHash`, so `matchesApproval`
35
+ * skipped its binding clause, and `collectTrackedReviewProvenance` never read or
36
+ * hashed the blueprint body at all. Any such blueprint passed the gate no matter
37
+ * what its plan currently said.
38
+ *
39
+ * 172 already-landed blueprints resolve their approval through that leg, so it
40
+ * is grandfathered rather than deleted: slugs tracked at this anchor keep the
41
+ * legacy leg, and every blueprint created afterwards must produce authority
42
+ * through the content-addressed v2 `review-events.jsonl` ledger. Anchor-tree
43
+ * membership is used for the same reason as {@link ROLLOUT_ANCHOR_COMMIT}: no
44
+ * file content can add a slug to a tree that was written earlier, so the
45
+ * grandfather cannot be forged by editing `created:`.
46
+ */
47
+ export declare const LEGACY_APPROVAL_LEG_ANCHOR_COMMIT = "079be82e39de7099a5bda1261e37598baed8644e";
48
+ /** The date that anchor landed, used only by the non-agent-kit fallback. */
49
+ export declare const LEGACY_APPROVAL_LEG_ROLLOUT_DATE = "2026-07-25";
28
50
  /**
29
51
  * Outcome of consulting the anchor, cached per (repoRoot, anchorCommit).
30
52
  *
@@ -26,12 +26,42 @@ import { execFileSync } from "node:child_process";
26
26
  export const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
27
27
  /** The date that anchor landed, used only by the legacy fallback below. */
28
28
  export const ROLLOUT_DATE = "2026-07-12";
29
+ /**
30
+ * The commit that closed the legacy frontmatter approval bypass ("fix(review):
31
+ * bind the legacy frontmatter approval leg to the rollout anchor", 2026-07-25).
32
+ *
33
+ * Until that change, a frontmatter `approvals:` entry naming a strong reviewer
34
+ * satisfied the promotion gate with **no content binding whatsoever**: the gate
35
+ * called `evaluateApprovalGate` without a `targetHash`, so `matchesApproval`
36
+ * skipped its binding clause, and `collectTrackedReviewProvenance` never read or
37
+ * hashed the blueprint body at all. Any such blueprint passed the gate no matter
38
+ * what its plan currently said.
39
+ *
40
+ * 172 already-landed blueprints resolve their approval through that leg, so it
41
+ * is grandfathered rather than deleted: slugs tracked at this anchor keep the
42
+ * legacy leg, and every blueprint created afterwards must produce authority
43
+ * through the content-addressed v2 `review-events.jsonl` ledger. Anchor-tree
44
+ * membership is used for the same reason as {@link ROLLOUT_ANCHOR_COMMIT}: no
45
+ * file content can add a slug to a tree that was written earlier, so the
46
+ * grandfather cannot be forged by editing `created:`.
47
+ */
48
+ export const LEGACY_APPROVAL_LEG_ANCHOR_COMMIT = "079be82e39de7099a5bda1261e37598baed8644e";
49
+ /** The date that anchor landed, used only by the non-agent-kit fallback. */
50
+ export const LEGACY_APPROVAL_LEG_ROLLOUT_DATE = "2026-07-25";
29
51
  const anchorSlugCache = new Map();
52
+ /**
53
+ * Every git call here is on the audit/promote hot path, so each carries an
54
+ * explicit deadline (parity with `review-provenance.ts`). A wedged git process
55
+ * must not hang the gate; a timeout surfaces as the same failure the `catch`
56
+ * already handles, which for the two membership probes means fail-closed.
57
+ */
58
+ const ANCHOR_GIT_TIMEOUT_MS = 5_000;
30
59
  function anchorCommitExists(repoRoot, anchorCommit) {
31
60
  try {
32
61
  execFileSync("git", ["cat-file", "-e", `${anchorCommit}^{commit}`], {
33
62
  cwd: repoRoot,
34
63
  stdio: ["ignore", "ignore", "ignore"],
64
+ timeout: ANCHOR_GIT_TIMEOUT_MS,
35
65
  });
36
66
  return true;
37
67
  }
@@ -51,6 +81,7 @@ function isShallowRepository(repoRoot) {
51
81
  cwd: repoRoot,
52
82
  encoding: "utf8",
53
83
  stdio: ["ignore", "pipe", "ignore"],
84
+ timeout: ANCHOR_GIT_TIMEOUT_MS,
54
85
  });
55
86
  // A path that is not a git repository at all answers with a non-zero exit,
56
87
  // handled below. Anything else that is not a clean "false" is treated as
@@ -86,7 +117,12 @@ export function blueprintSlugsAtRolloutAnchor(repoRoot, anchorCommit = ROLLOUT_A
86
117
  return isShallowRepository(repoRoot) ? { kind: "unreadable" } : { kind: "not-applicable" };
87
118
  }
88
119
  try {
89
- const stdout = execFileSync("git", ["ls-tree", "-r", "--name-only", anchorCommit, "blueprints/"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
120
+ const stdout = execFileSync("git", ["ls-tree", "-r", "--name-only", anchorCommit, "blueprints/"], {
121
+ cwd: repoRoot,
122
+ encoding: "utf8",
123
+ stdio: ["ignore", "pipe", "ignore"],
124
+ timeout: ANCHOR_GIT_TIMEOUT_MS,
125
+ });
90
126
  const found = new Set();
91
127
  for (const line of stdout.split("\n")) {
92
128
  const match = /^blueprints\/[^/]+\/(.+)$/u.exec(line.trim());
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Single owner of the task-body `**Blocked:** <reason>` line grammar.
3
+ *
4
+ * Both sides of the plan-approval contract read this file: `updateBlockedReason`
5
+ * (`#markdown/helpers.js`) writes the line, and `neutralizeTaskBlockedReason`
6
+ * (`#review/subject.js`) removes it before the plan-subject digest is taken. That
7
+ * pairing is exactly the arrangement the Promotion Gates row grammar already uses
8
+ * (`#trust/gate-row.js`), and for the same reason: when a writer and the
9
+ * normalizer spell the same grammar twice, they drift, the lifecycle stamp
10
+ * survives into the digest, and the plan approval the promotion gates on is
11
+ * silently dismissed.
12
+ *
13
+ * Deliberately dependency-free so any module on either side can import it
14
+ * without risking a cycle.
15
+ */
16
+ /**
17
+ * Regex source for a whole `**Blocked:** …` line, anchored to line bounds.
18
+ *
19
+ * Anchoring is load-bearing in both directions. Blueprint prose legitimately
20
+ * mentions the marker mid-sentence ("add a non-empty **Blocked:** line with the
21
+ * reason") — those mentions are plan substance, and an unanchored pattern would
22
+ * have the writer overwrite them and the normalizer strip them.
23
+ */
24
+ export declare const BLOCKED_LINE_SOURCE: string;
25
+ /**
26
+ * Build a matcher for the blocked line. Mirrors the `buildTaskHeadingRegex`
27
+ * convention in `./task-heading.js`: callers pick the flags they need, but the
28
+ * pattern itself has exactly one definition.
29
+ */
30
+ export declare function buildBlockedLineRegex(flags?: string): RegExp;
31
+ /** True when `line` IS a blocked-reason line, not merely a line that mentions one. */
32
+ export declare function isBlockedLine(line: string): boolean;
33
+ /**
34
+ * Serialize the blocked line. The writer-side counterpart to
35
+ * {@link isBlockedLine}: every emitter goes through here so the written form and
36
+ * the parsed form cannot diverge.
37
+ */
38
+ export declare function formatBlockedLine(reason: string): string;
39
+ /**
40
+ * Read the reason off the first blocked line in a task section, or `undefined`
41
+ * when the section has none.
42
+ *
43
+ * The reader counterpart to {@link formatBlockedLine}. Three parsers used to
44
+ * carry their own unanchored copy of this pattern (`src/blueprint/core/parser.ts`,
45
+ * `src/blueprint/tracked-document/parser.ts`, `src/docs-linter/blueprint-plan.ts`),
46
+ * so a task whose prose merely mentioned the marker was read as genuinely
47
+ * blocked and the lifecycle audit failed the document.
48
+ */
49
+ export declare function extractBlockedReason(section: string): string | undefined;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Single owner of the task-body `**Blocked:** <reason>` line grammar.
3
+ *
4
+ * Both sides of the plan-approval contract read this file: `updateBlockedReason`
5
+ * (`#markdown/helpers.js`) writes the line, and `neutralizeTaskBlockedReason`
6
+ * (`#review/subject.js`) removes it before the plan-subject digest is taken. That
7
+ * pairing is exactly the arrangement the Promotion Gates row grammar already uses
8
+ * (`#trust/gate-row.js`), and for the same reason: when a writer and the
9
+ * normalizer spell the same grammar twice, they drift, the lifecycle stamp
10
+ * survives into the digest, and the plan approval the promotion gates on is
11
+ * silently dismissed.
12
+ *
13
+ * Deliberately dependency-free so any module on either side can import it
14
+ * without risking a cycle.
15
+ */
16
+ /**
17
+ * Regex source for a whole `**Blocked:** …` line, anchored to line bounds.
18
+ *
19
+ * Anchoring is load-bearing in both directions. Blueprint prose legitimately
20
+ * mentions the marker mid-sentence ("add a non-empty **Blocked:** line with the
21
+ * reason") — those mentions are plan substance, and an unanchored pattern would
22
+ * have the writer overwrite them and the normalizer strip them.
23
+ */
24
+ export const BLOCKED_LINE_SOURCE = String.raw `^\*\*Blocked:\*\*.*$`;
25
+ /**
26
+ * Build a matcher for the blocked line. Mirrors the `buildTaskHeadingRegex`
27
+ * convention in `./task-heading.js`: callers pick the flags they need, but the
28
+ * pattern itself has exactly one definition.
29
+ */
30
+ export function buildBlockedLineRegex(flags = "imu") {
31
+ return new RegExp(BLOCKED_LINE_SOURCE, flags);
32
+ }
33
+ const SINGLE_LINE_MATCHER = buildBlockedLineRegex("iu");
34
+ /** True when `line` IS a blocked-reason line, not merely a line that mentions one. */
35
+ export function isBlockedLine(line) {
36
+ return SINGLE_LINE_MATCHER.test(line);
37
+ }
38
+ const MARKER_LENGTH = "**Blocked:**".length;
39
+ /**
40
+ * Serialize the blocked line. The writer-side counterpart to
41
+ * {@link isBlockedLine}: every emitter goes through here so the written form and
42
+ * the parsed form cannot diverge.
43
+ */
44
+ export function formatBlockedLine(reason) {
45
+ return `**Blocked:** ${reason}`;
46
+ }
47
+ /**
48
+ * Read the reason off the first blocked line in a task section, or `undefined`
49
+ * when the section has none.
50
+ *
51
+ * The reader counterpart to {@link formatBlockedLine}. Three parsers used to
52
+ * carry their own unanchored copy of this pattern (`src/blueprint/core/parser.ts`,
53
+ * `src/blueprint/tracked-document/parser.ts`, `src/docs-linter/blueprint-plan.ts`),
54
+ * so a task whose prose merely mentioned the marker was read as genuinely
55
+ * blocked and the lifecycle audit failed the document.
56
+ */
57
+ export function extractBlockedReason(section) {
58
+ const match = buildBlockedLineRegex().exec(section);
59
+ if (!match)
60
+ return undefined;
61
+ const reason = match[0].slice(MARKER_LENGTH).trim();
62
+ return reason === "" ? undefined : reason;
63
+ }
@@ -8,6 +8,7 @@
8
8
  * produces identical output (for the same inputs).
9
9
  */
10
10
  import { escapeRegex } from "#utils/string";
11
+ import { BLOCKED_LINE_SOURCE, buildBlockedLineRegex, formatBlockedLine } from "./blocked-line.js";
11
12
  import { buildTaskHeaderRegexForId, buildTaskSectionBoundaryRegex, TASK_HEADING_PREFIX_PATTERN, } from "./task-heading.js";
12
13
  function buildTaskPattern(taskId) {
13
14
  return new RegExp(`(####\\s+${TASK_HEADING_PREFIX_PATTERN}Task\\s+${escapeRegex(taskId)}[:\\s].*?)(?=\\n####|\\n###|$)`, "s");
@@ -65,7 +66,11 @@ export function updateBlockedReason(content, taskId, reason) {
65
66
  const taskPattern = buildTaskPattern(taskId);
66
67
  return content.replace(taskPattern, (section) => {
67
68
  const trimmedReason = reason.trim();
68
- const blockedPattern = /\n?\*\*Blocked:\*\*\s*.*(?:\n+)?/i;
69
+ // Grammar comes from ./blocked-line.js, which the plan-subject normalizer
70
+ // consumes too. Line-anchored on purpose: a task whose prose mentions
71
+ // `**Blocked:**` mid-sentence is plan substance, and the previous
72
+ // unanchored pattern would have overwritten it.
73
+ const blockedPattern = new RegExp(String.raw `\n?${BLOCKED_LINE_SOURCE}\n*`, "imu");
69
74
  if (!trimmedReason) {
70
75
  // Preserve the section's trailing whitespace: trimEnd on the whole
71
76
  // section would also delete the blank line separating this task from the
@@ -76,9 +81,10 @@ export function updateBlockedReason(content, taskId, reason) {
76
81
  .replace(/\n{3,}/g, "\n\n")
77
82
  .trimEnd() + trailingWhitespace);
78
83
  }
79
- const blockedLine = `**Blocked:** ${trimmedReason}`;
80
- if (/\*\*Blocked:\*\*/i.test(section)) {
81
- return section.replace(/\*\*Blocked:\*\*\s*.+/i, blockedLine);
84
+ const blockedLine = formatBlockedLine(trimmedReason);
85
+ const existingLine = buildBlockedLineRegex();
86
+ if (existingLine.test(section)) {
87
+ return section.replace(existingLine, blockedLine);
82
88
  }
83
89
  return insertAfterStatusLine(section, blockedLine);
84
90
  });
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Git-Native SSoT: Checkbox state derives status, no external state.
8
8
  */
9
+ import { extractBlockedReason } from "#markdown/blocked-line";
9
10
  import { isTaskHeaderLine } from "#markdown/task-heading";
10
11
  /**
11
12
  * Extract checkbox status from a markdown section and derive task state.
@@ -100,10 +101,9 @@ export function extractDepends(section) {
100
101
  * ```
101
102
  */
102
103
  export function extractBlocked(section) {
103
- const blockedMatch = section.match(/\*\*Blocked:\*\*\s*(.+)/i);
104
- if (!blockedMatch?.[1])
104
+ const blockedText = extractBlockedReason(section);
105
+ if (blockedText === undefined)
105
106
  return undefined;
106
- const blockedText = blockedMatch[1].trim();
107
107
  // Handle missing/empty reason gracefully
108
108
  if (blockedText === "" || blockedText.toLowerCase() === "none") {
109
109
  return undefined;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Single owner of the Trust Dossier "Promotion Gates" table row grammar.
3
+ *
4
+ * Two callers must agree on this grammar byte-for-byte:
5
+ *
6
+ * - the lifecycle WRITER `updateGateLastResult` (`./promotion.ts`), which stamps
7
+ * the `Last result` cell during `wp blueprint promote`; and
8
+ * - the plan-subject digest NORMALIZER `neutralizePromotionGateResults`
9
+ * (`#review/subject.js`), which replaces that same cell with a placeholder so
10
+ * the stamp is never hashed into a plan approval's subject.
11
+ *
12
+ * They each used to implement the grammar independently, and diverged on four
13
+ * axes at once — cell count (writer 4 or 5, normalizer 4 only), section scope
14
+ * (writer any table row in the document, normalizer inside `### Promotion Gates`
15
+ * only), trailing pipe (writer `trim().startsWith("|")` alone, normalizer both
16
+ * pipes), and trailing whitespace after the closing pipe (writer sliced the
17
+ * UNTRIMMED line, so it mis-counted a 4-cell row as 5 and appended a spurious
18
+ * empty cell). Every divergence made `digest(doc) !== digest(writer(doc))`,
19
+ * which destroys the plan approval the promotion is gating on. One owner,
20
+ * consumed from both sides, is the only structural fix; anything else lets them
21
+ * drift again.
22
+ *
23
+ * The grammar deliberately matches what `parsePromotionGatesTable` in
24
+ * `./dossier.ts` already enforces on committed documents: a row is the trimmed
25
+ * line starting AND ending with a pipe, cells are split on `|` and trimmed, and
26
+ * the accepted widths are 4 (legacy) and 5 (opt-in `Defer` column). A row this
27
+ * module rejects is a row the dossier parser reports as `malformed table row`,
28
+ * so it can never be a live gate — skipping it is the fail-safe direction.
29
+ */
30
+ /** Zero-based index of the lifecycle-owned `Last result` cell. */
31
+ export declare const LAST_RESULT_CELL_INDEX = 3;
32
+ /**
33
+ * Cells of one Promotion Gates table row, trimmed, or null when the line is not
34
+ * a row this grammar owns (no pipes, a single pipe, or an unsupported width).
35
+ */
36
+ export declare function parsePromotionGateCells(line: string): readonly string[] | null;
37
+ /**
38
+ * Canonical row bytes. Whitespace around a row (leading indent, padding inside
39
+ * cells, whitespace after the closing pipe) is not plan substance, so both
40
+ * callers re-emit this single canonical form. That is what keeps a stamped row
41
+ * and its normalized counterpart byte-identical, and it is exactly the form the
42
+ * normalizer has always emitted for a 4-cell row, so committed plan digests are
43
+ * unaffected.
44
+ */
45
+ export declare function formatPromotionGateRow(cells: readonly string[]): string;
46
+ /** Copy `cells` with the lifecycle-owned `Last result` cell set to `value`. */
47
+ export declare function setLastResultCell(cells: readonly string[], value: string): readonly string[];
48
+ /**
49
+ * Rewrite every Promotion Gates table row (header, separator, and data rows
50
+ * alike) inside the `### Promotion Gates` subsection through `transform`.
51
+ * Returning the received `cells` reference unchanged leaves the line
52
+ * byte-identical; returning a new array re-emits the canonical row. Every line
53
+ * outside the section is always byte-identical.
54
+ */
55
+ export declare function mapPromotionGateRows(text: string, transform: (cells: readonly string[]) => readonly string[]): string;