@webpresso/agent-kit 3.2.0 → 3.3.1

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 (103) hide show
  1. package/bin/_run.js +4 -1
  2. package/catalog/agent/rules/pre-implementation.md +15 -0
  3. package/dist/esm/audit/blueprint-trust.js +14 -10
  4. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  5. package/dist/esm/audit/changeset-evidence.js +30 -0
  6. package/dist/esm/audit/changeset-required.d.ts +10 -0
  7. package/dist/esm/audit/changeset-required.js +73 -0
  8. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  9. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  10. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +6 -1
  13. package/dist/esm/blueprint/core/parser.js +61 -4
  14. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  15. package/dist/esm/blueprint/core/schema.js +26 -0
  16. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  17. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  18. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  21. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  22. package/dist/esm/blueprint/sync/client.js +1 -1
  23. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  24. package/dist/esm/blueprint/trust/dossier.js +40 -6
  25. package/dist/esm/blueprint/trust/promotion.js +12 -0
  26. package/dist/esm/build/cli-mcp-parity.js +5 -0
  27. package/dist/esm/cli/cli.d.ts +1 -1
  28. package/dist/esm/cli/cli.js +9 -0
  29. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  30. package/dist/esm/cli/commands/agent-launch.js +31 -20
  31. package/dist/esm/cli/commands/audit.js +4 -0
  32. package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
  33. package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
  34. package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
  35. package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
  36. package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
  37. package/dist/esm/cli/commands/blueprint/router.js +5 -2
  38. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  39. package/dist/esm/cli/commands/init/convergence-apply.d.ts +87 -0
  40. package/dist/esm/cli/commands/init/convergence-apply.js +189 -23
  41. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  42. package/dist/esm/cli/commands/init/index.js +2 -2
  43. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  44. package/dist/esm/cli/commands/init/package-root.js +10 -2
  45. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  46. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  47. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  48. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  49. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  50. package/dist/esm/cli/commands/opencode-probe.d.ts +118 -0
  51. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  52. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  53. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  54. package/dist/esm/cli/commands/quality-runner.js +8 -4
  55. package/dist/esm/cli/commands/review.js +27 -5
  56. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  57. package/dist/esm/cli/commands/rust-check.js +110 -0
  58. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  59. package/dist/esm/cli/commands/typecheck.js +30 -1
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +14 -0
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +138 -2
  62. package/dist/esm/cli/commands/worktree/router.js +10 -0
  63. package/dist/esm/hooks/doctor.d.ts +2 -0
  64. package/dist/esm/hooks/doctor.js +37 -1
  65. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  66. package/dist/esm/hooks/shared/types.js +27 -1
  67. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  68. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  69. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  70. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  71. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  72. package/dist/esm/mcp/tools/audit.js +12 -0
  73. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  74. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  75. package/dist/esm/package.json +2 -0
  76. package/dist/esm/platform/client.d.ts +7 -1
  77. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  78. package/dist/esm/pr-description/contract.js +24 -4
  79. package/dist/esm/review/authority.js +14 -9
  80. package/dist/esm/review/availability.d.ts +11 -0
  81. package/dist/esm/review/availability.js +18 -3
  82. package/dist/esm/review/execution/adapters.d.ts +11 -1
  83. package/dist/esm/review/execution/adapters.js +10 -1
  84. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  85. package/dist/esm/review/execution/review-checkout.d.ts +6 -0
  86. package/dist/esm/review/execution/review-checkout.js +61 -16
  87. package/dist/esm/review/execution/supervisor.js +31 -4
  88. package/dist/esm/review/execution/types.d.ts +1 -0
  89. package/dist/esm/review/subject.d.ts +13 -0
  90. package/dist/esm/review/subject.js +42 -4
  91. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  92. package/dist/esm/rust/affected-manifests.js +69 -0
  93. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  94. package/dist/esm/status/snapshot.js +2 -6
  95. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  96. package/dist/esm/test-helpers/global-setup.js +18 -15
  97. package/dist/esm/typecheck/planner.d.ts +9 -1
  98. package/dist/esm/typecheck/planner.js +31 -0
  99. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  100. package/dist/esm/worktrees/orphan-scan.js +211 -0
  101. package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
  102. package/dist/esm/worktrees/owner-dirt.js +104 -0
  103. package/package.json +14 -12
package/bin/_run.js CHANGED
@@ -382,10 +382,13 @@ function runtimeSourceRequiresSourceLaunch(sourceRootDir, builtRootDir) {
382
382
  return false;
383
383
  }
384
384
 
385
- function wpSourceRequiresSourceLaunch(repoRoot) {
385
+ export function wpSourceRequiresSourceLaunch(repoRoot) {
386
386
  const sourceRoots = [
387
387
  ["src", "cli"],
388
388
  ["src", "tool-runtime"],
389
+ ["src", "mcp"],
390
+ ["src", "blueprint"],
391
+ ["src", "audit"],
389
392
  ];
390
393
 
391
394
  return sourceRoots.some((segments) =>
@@ -320,6 +320,21 @@ checkout target. Local cleanup and primary resync cannot run from GitHub Actions
320
320
  do not claim landing complete until this cleanup succeeds or is recorded as a
321
321
  blocker with evidence.
322
322
 
323
+ **Owner dirt precondition:** the owner worktree must have empty
324
+ `git status --porcelain` before merge-cleanup. If you ran `wp blueprint new` and
325
+ left `blueprints/draft/<slug>/` uncommitted (or staged only), either:
326
+
327
+ 1. **Land the draft** — include `blueprints/draft/<slug>/` (and complete the
328
+ lifecycle) in the PR, or
329
+ 2. **Abandon the scaffold** — `wp blueprint abandon <slug> --confirm` (preview
330
+ without `--confirm`), then re-run merge-cleanup.
331
+
332
+ `Blueprint-exempt` is for work that **never created a blueprint**. Do not
333
+ scaffold a draft only to exempt it. For multi-file exempt follow-ups, use a
334
+ **non-`bp/`** worktree prefix (`exempt/<name>` or `chore/<name>`) via
335
+ `wp worktree new` — never `bp/<slug>`, which pre-claims the owner branch and
336
+ collides with `wp blueprint start`.
337
+
323
338
  When the primary checkout has user-owned edits or is still on a topic branch,
324
339
  prefer:
325
340
 
@@ -4,15 +4,16 @@ import matter from "gray-matter";
4
4
  import { parseTrustDossier } from "#trust/dossier.js";
5
5
  import { validateBlueprintTrust } from "#trust/validator.js";
6
6
  import { parseBlueprintDocumentRelativePath } from "#utils/document-paths.js";
7
+ import { stableSlugForBlueprintFile } from "#lifecycle/review-provenance.js";
8
+ import { isBlueprintExemptFromRollout, ROLLOUT_DATE } from "#lifecycle/rollout-anchor.js";
7
9
  const EXECUTABLE_DIRS = ["planned", "in-progress", "completed"];
8
- const TRUST_DOSSIER_ROLLOUT_DATE = "2026-07-12";
9
10
  export function auditBlueprintTrust(rootDirectory = process.cwd()) {
10
11
  const violations = [];
11
12
  let checked = 0;
12
13
  for (const file of findExecutableBlueprints(rootDirectory)) {
13
14
  checked += 1;
14
15
  const markdown = readFileSync(path.join(rootDirectory, file), "utf8");
15
- const grandfatherMissingDossier = isPreContractBlueprintMissingDossier(markdown);
16
+ const grandfatherMissingDossier = isPreContractBlueprintMissingDossier(markdown, rootDirectory, file);
16
17
  const status = readStatus(markdown);
17
18
  const normalizedStatus = isBlueprintTrustStatus(status) ? status : "planned";
18
19
  if (!isBlueprintTrustStatus(status)) {
@@ -40,20 +41,23 @@ export function auditBlueprintTrust(rootDirectory = process.cwd()) {
40
41
  }
41
42
  return { ok: violations.length === 0, title: "Blueprint trust", checked, violations };
42
43
  }
43
- function isPreContractBlueprintMissingDossier(markdown) {
44
+ function isPreContractBlueprintMissingDossier(markdown, repoRoot, file) {
44
45
  const parsedDossier = parseTrustDossier(markdown);
45
46
  if (parsedDossier.violations.length !== 1 ||
46
47
  parsedDossier.violations[0]?.section !== "Trust Dossier" ||
47
48
  parsedDossier.violations[0]?.message !== "missing Trust Dossier section") {
48
49
  return false;
49
50
  }
50
- const created = matter(markdown).data["created"];
51
- const createdDate = created instanceof Date
52
- ? created.toISOString().slice(0, 10)
53
- : typeof created === "string" && /^\d{4}-\d{2}-\d{2}$/u.test(created)
54
- ? created
55
- : null;
56
- return createdDate !== null && createdDate < TRUST_DOSSIER_ROLLOUT_DATE;
51
+ // Was a compare against frontmatter `created`, which the blueprint own
52
+ // author controls -- writing an old date suppressed the missing-dossier
53
+ // violation outright. Presence at the rollout anchor commit is the
54
+ // unforgeable equivalent; see `#lifecycle/rollout-anchor.js`. Fails closed.
55
+ return isBlueprintExemptFromRollout({
56
+ repoRoot,
57
+ slug: stableSlugForBlueprintFile(file),
58
+ created: matter(markdown).data["created"],
59
+ rolloutDate: ROLLOUT_DATE,
60
+ });
57
61
  }
58
62
  export function findExecutableBlueprints(rootDirectory) {
59
63
  const root = path.join(rootDirectory, "blueprints");
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared "does this change carry release evidence" predicate.
3
+ *
4
+ * Single source of truth for the `changeset-missing` rule described in
5
+ * `catalog/agent/rules/changeset-release.md`: release-visible changes need a
6
+ * `.changeset/*.md` entry (or an all-docs change, or a `Changeset-exempt:`
7
+ * trailer). Consumed by both the live-PR readiness evaluator
8
+ * (`#status/snapshot.js`) and the local branch-scoped audit
9
+ * (`#audit/changeset-required.js`) so the two verdicts can never diverge.
10
+ */
11
+ export declare const CHANGESET_EXEMPT_PATTERN: RegExp;
12
+ /**
13
+ * True when `files` carries release evidence for its own change: every
14
+ * changed file is documentation, one of the changed files is itself a
15
+ * `.changeset/*.md` entry, or one of `exemptionTexts` (PR body, commit
16
+ * messages, ...) carries a `Changeset-exempt: <reason>` trailer.
17
+ *
18
+ * Mirrors the original `wp_pr_status` behavior exactly: an empty `files` list
19
+ * is NOT treated as evidence (fails closed) — callers that consider "no
20
+ * changed files" a legitimate, non-suspicious state (e.g. a freshly rebased
21
+ * local branch) must short-circuit before calling this function.
22
+ */
23
+ export declare function hasChangesetEvidence(files: readonly string[], exemptionTexts: readonly string[]): boolean;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared "does this change carry release evidence" predicate.
3
+ *
4
+ * Single source of truth for the `changeset-missing` rule described in
5
+ * `catalog/agent/rules/changeset-release.md`: release-visible changes need a
6
+ * `.changeset/*.md` entry (or an all-docs change, or a `Changeset-exempt:`
7
+ * trailer). Consumed by both the live-PR readiness evaluator
8
+ * (`#status/snapshot.js`) and the local branch-scoped audit
9
+ * (`#audit/changeset-required.js`) so the two verdicts can never diverge.
10
+ */
11
+ const CHANGESET_FILE_PATTERN = /^\.changeset\/[^/]+\.md$/u;
12
+ export const CHANGESET_EXEMPT_PATTERN = /Changeset-exempt:\s*\S/iu;
13
+ /**
14
+ * True when `files` carries release evidence for its own change: every
15
+ * changed file is documentation, one of the changed files is itself a
16
+ * `.changeset/*.md` entry, or one of `exemptionTexts` (PR body, commit
17
+ * messages, ...) carries a `Changeset-exempt: <reason>` trailer.
18
+ *
19
+ * Mirrors the original `wp_pr_status` behavior exactly: an empty `files` list
20
+ * is NOT treated as evidence (fails closed) — callers that consider "no
21
+ * changed files" a legitimate, non-suspicious state (e.g. a freshly rebased
22
+ * local branch) must short-circuit before calling this function.
23
+ */
24
+ export function hasChangesetEvidence(files, exemptionTexts) {
25
+ if (files.length > 0 && files.every((file) => file.endsWith(".md")))
26
+ return true;
27
+ if (files.some((file) => CHANGESET_FILE_PATTERN.test(file)))
28
+ return true;
29
+ return exemptionTexts.some((text) => CHANGESET_EXEMPT_PATTERN.test(text));
30
+ }
@@ -0,0 +1,10 @@
1
+ import type { RepoAuditResult } from "./repo-guardrails.js";
2
+ export interface ChangesetRequiredOptions {
3
+ /** Base ref for the branch diff. Defaults to `origin/${GITHUB_BASE_REF:-main}`. */
4
+ readonly baseRef?: string;
5
+ /** Test/adapter seam for an already-resolved changed-file list. */
6
+ readonly changedFiles?: readonly string[];
7
+ /** Test/adapter seam for already-resolved commit messages. */
8
+ readonly commitMessages?: readonly string[];
9
+ }
10
+ export declare function auditChangesetRequired(rootDirectory?: string, options?: ChangesetRequiredOptions): RepoAuditResult;
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { defaultBranchBaseRef, getBranchChangedFiles } from "#git/changed-files";
3
+ import { hasChangesetEvidence } from "./changeset-evidence.js";
4
+ /**
5
+ * `wp audit changeset-required` — the local, branch-scoped mirror of the
6
+ * `changeset-missing` blocker `wp_pr_status` computes against a live PR (see
7
+ * `#status/snapshot.js`). Both call the same `hasChangesetEvidence` predicate
8
+ * so a locally-green `wp ci-preflight` can never diverge from the landing
9
+ * verdict (`catalog/agent/rules/ci-cost-local-first.md`).
10
+ *
11
+ * Unlike the PR-based check (which reads `pr.files`/`pr.body` from the GitHub
12
+ * API), this audit resolves its own branch diff and scans local commit
13
+ * messages for a `Changeset-exempt: <reason>` trailer, so it works fully
14
+ * offline pre-push.
15
+ */
16
+ const TITLE = "Changeset required";
17
+ const GIT_LOG_PROBE_OPTIONS = { timeout: 1_500, maxBuffer: 1024 * 1024 };
18
+ export function auditChangesetRequired(rootDirectory = process.cwd(), options = {}) {
19
+ const baseRef = options.baseRef?.trim() || defaultBranchBaseRef();
20
+ let changedFiles;
21
+ if (options.changedFiles) {
22
+ changedFiles = options.changedFiles;
23
+ }
24
+ else {
25
+ const resolved = resolveChangedFiles(rootDirectory, baseRef);
26
+ if (resolved.error)
27
+ return fail(resolved.error, 0);
28
+ changedFiles = resolved.files;
29
+ }
30
+ if (changedFiles.length === 0) {
31
+ return passWithWarning(`[warn] changeset-required: no changed files vs ${baseRef} — skipping`, 0);
32
+ }
33
+ const exemptionMessages = options.commitMessages ?? readCommitMessages(rootDirectory, baseRef);
34
+ if (hasChangesetEvidence(changedFiles, exemptionMessages)) {
35
+ return pass(changedFiles.length);
36
+ }
37
+ return fail("release-visible changes require a .changeset/*.md entry (`wp run changeset`) " +
38
+ "or a `Changeset-exempt: <reason>` commit trailer", changedFiles.length);
39
+ }
40
+ function pass(checked) {
41
+ return { ok: true, title: TITLE, checked, violations: [] };
42
+ }
43
+ function passWithWarning(message, checked) {
44
+ return { ok: true, title: TITLE, checked, violations: [{ message }] };
45
+ }
46
+ function fail(message, checked) {
47
+ return { ok: false, title: TITLE, checked, violations: [{ message }] };
48
+ }
49
+ function resolveChangedFiles(cwd, baseRef) {
50
+ const result = getBranchChangedFiles(cwd, baseRef);
51
+ if (result.degraded) {
52
+ return {
53
+ files: [],
54
+ error: `unable to resolve changed files vs ${baseRef} (${result.reason}); ` +
55
+ "refusing to skip changeset enforcement",
56
+ };
57
+ }
58
+ return { files: result.files };
59
+ }
60
+ function readCommitMessages(cwd, baseRef) {
61
+ const result = spawnSync("git", ["log", "--format=%B%x00", `${baseRef}...HEAD`], {
62
+ cwd,
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ ...GIT_LOG_PROBE_OPTIONS,
66
+ });
67
+ if (result.status !== 0 || result.error)
68
+ return [];
69
+ return result.stdout
70
+ .split("\0")
71
+ .map((entry) => entry.trim())
72
+ .filter(Boolean);
73
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared content-absence detection for the `wp audit guardrails` CI wiring
3
+ * contract. Both the audit (`ci-guardrails-wiring.ts`) and the self-healing
4
+ * injector (`scaffold-base-kit.ts`'s `ensureGuardrailsCiStep`) must agree on
5
+ * what counts as a "live" test-run step to anchor guardrails after — keeping
6
+ * that detection in one place is what keeps the two from silently diverging.
7
+ *
8
+ * Prior to this module, the audit matched the test-run pattern against whole
9
+ * file content (including inside `#`-commented lines) while the injector's
10
+ * anchor scan already skipped comment lines. A workflow whose only test-run
11
+ * mention was commented out therefore tripped the audit red with no anchor
12
+ * for the injector to repair — an unrepairable-red state (tech-debt h-016).
13
+ */
14
+ /** True when `content` invokes `wp audit guardrails` anywhere (including
15
+ * inside comments — see the pattern's doc comment above). */
16
+ export declare function hasGuardrailsInvocation(content: string): boolean;
17
+ /**
18
+ * Scans `content` line by line, skipping commented-out lines, tracking the
19
+ * most recent step-start line (`- ...`), and returning the index of the step
20
+ * that owns the first non-commented line matching the live test-run pattern.
21
+ * Returns -1 when no such line exists.
22
+ *
23
+ * This is the single source of truth for "is there a live test-run step to
24
+ * anchor a guardrails step before" — used by the audit (as a boolean) and by
25
+ * the injector (as an insertion index).
26
+ */
27
+ export declare function findLiveTestRunAnchor(content: string): number;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared content-absence detection for the `wp audit guardrails` CI wiring
3
+ * contract. Both the audit (`ci-guardrails-wiring.ts`) and the self-healing
4
+ * injector (`scaffold-base-kit.ts`'s `ensureGuardrailsCiStep`) must agree on
5
+ * what counts as a "live" test-run step to anchor guardrails after — keeping
6
+ * that detection in one place is what keeps the two from silently diverging.
7
+ *
8
+ * Prior to this module, the audit matched the test-run pattern against whole
9
+ * file content (including inside `#`-commented lines) while the injector's
10
+ * anchor scan already skipped comment lines. A workflow whose only test-run
11
+ * mention was commented out therefore tripped the audit red with no anchor
12
+ * for the injector to repair — an unrepairable-red state (tech-debt h-016).
13
+ */
14
+ /**
15
+ * The guardrails invocation, keyed on the command itself (not the `wp`
16
+ * prefix nor a marker comment) so `wp audit guardrails`,
17
+ * `./bin/wp audit guardrails`, and the `--affected`/`--full` variants all
18
+ * count as wired.
19
+ *
20
+ * NOTE: this intentionally matches inside comments too — it is a
21
+ * whole-content test, not line-scanned. A commented-out `wp audit
22
+ * guardrails` line therefore still counts as "wired" by both the audit and
23
+ * the injector. This is an inherent limitation of content-absence
24
+ * pattern-matching (neither side parses YAML as executable steps) rather
25
+ * than a bug unique to either detector — see tech-debt h-016. Do not "fix"
26
+ * this by line-scanning here without first solving real YAML-step parsing;
27
+ * a half-parsed heuristic would be worse than the documented limitation.
28
+ */
29
+ const GUARDRAILS_INVOCATION_PATTERN = /\baudit guardrails\b/u;
30
+ /**
31
+ * Lines that run the default test suite — the signal that a workflow is a
32
+ * quality-CI pipeline that owes a guardrails step. Comment lines are
33
+ * excluded: a commented-out test-run mention is not a live test run.
34
+ */
35
+ const TEST_RUN_PATTERN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
36
+ function isCommentLine(line) {
37
+ return line.trim().startsWith("#");
38
+ }
39
+ /** True when `content` invokes `wp audit guardrails` anywhere (including
40
+ * inside comments — see the pattern's doc comment above). */
41
+ export function hasGuardrailsInvocation(content) {
42
+ return GUARDRAILS_INVOCATION_PATTERN.test(content);
43
+ }
44
+ /**
45
+ * Scans `content` line by line, skipping commented-out lines, tracking the
46
+ * most recent step-start line (`- ...`), and returning the index of the step
47
+ * that owns the first non-commented line matching the live test-run pattern.
48
+ * Returns -1 when no such line exists.
49
+ *
50
+ * This is the single source of truth for "is there a live test-run step to
51
+ * anchor a guardrails step before" — used by the audit (as a boolean) and by
52
+ * the injector (as an insertion index).
53
+ */
54
+ export function findLiveTestRunAnchor(content) {
55
+ const lines = content.split("\n");
56
+ let lastStepStart = -1;
57
+ for (let index = 0; index < lines.length; index += 1) {
58
+ // Safe: index is bounded by the loop condition against lines.length.
59
+ const line = lines[index];
60
+ if (isCommentLine(line))
61
+ continue;
62
+ if (/^\s*-\s/u.test(line))
63
+ lastStepStart = index;
64
+ if (TEST_RUN_PATTERN.test(line))
65
+ return lastStepStart;
66
+ }
67
+ return -1;
68
+ }
@@ -20,19 +20,9 @@
20
20
  */
21
21
  import { existsSync, readdirSync, readFileSync } from "node:fs";
22
22
  import { join, relative } from "node:path";
23
+ import { findLiveTestRunAnchor, hasGuardrailsInvocation } from "./ci-guardrails-detection.js";
23
24
  const WORKFLOW_DIR = ".github/workflows";
24
25
  const WORKFLOW_FILE_PATTERN = /\.(ya?ml)$/iu;
25
- /** The guardrails invocation, keyed on the command itself (not the `wp` prefix
26
- * nor a marker comment) so `wp audit guardrails`, `./bin/wp audit guardrails`,
27
- * and the `--affected`/`--full` variants all count as wired. */
28
- const GUARDRAILS_INVOCATION_PATTERN = /\baudit guardrails\b/u;
29
- /**
30
- * Lines that run the default test suite — the signal that a workflow is a
31
- * quality-CI pipeline that owes a guardrails step. Mirrors the notion of a
32
- * test-running workflow used by `ci-test-perf.ts`; kept local so the two audits
33
- * stay independent.
34
- */
35
- const TEST_RUN_PATTERN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
36
26
  function walkWorkflowFiles(dir) {
37
27
  if (!existsSync(dir))
38
28
  return [];
@@ -56,9 +46,9 @@ export function auditCiGuardrailsWiring(rootDirectory = process.cwd()) {
56
46
  let anyWorkflowWiresGuardrails = false;
57
47
  for (const file of files) {
58
48
  const content = readFileSync(file, "utf8");
59
- if (GUARDRAILS_INVOCATION_PATTERN.test(content))
49
+ if (hasGuardrailsInvocation(content))
60
50
  anyWorkflowWiresGuardrails = true;
61
- if (TEST_RUN_PATTERN.test(content)) {
51
+ if (findLiveTestRunAnchor(content) !== -1) {
62
52
  testWorkflows.push(relative(rootDirectory, file).replace(/\\/gu, "/"));
63
53
  }
64
54
  }
@@ -14,11 +14,11 @@ export interface AuditKindDescriptor {
14
14
  }
15
15
  export declare const SCRIPT_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e"];
16
16
  export declare const SPECIAL_AUDIT_KIND_IDS: readonly ["bundle-budget", "commit-message", "blueprint-pr-coverage", "mutation", "guardrails", "quality"];
17
- export declare const REPO_AUDIT_KIND_IDS: readonly ["catalog-drift", "package-surface", "command-surface", "reference-parity-matrix", "blueprint-readme-drift", "blueprint-lifecycle", "blueprint-trust", "roadmap-links", "docs-frontmatter", "agents", "vision", "tech-debt", "supported-agent-clis", "no-relative-parent-imports", "no-link-protocol", "no-relative-package-scripts", "test-isolation", "bucket-boundary", "skill-sizes", "broken-refs", "memory-rotation", "gitignore-agent-surfaces", "memory-unified", "compile-drift", "no-legacy-cli-bin", "architecture-drift", "cloudflare-deploy-contract", "toolchain-isolation", "absolute-path-policy", "no-first-party-mjs", "outside-review-runtime-ownership", "no-math-random", "agent-cost", "blueprint-db-consistency", "tech-debt-cadence", "cross-repo-correlation", "ai-contracts", "atomic-state-writes", "hook-surface", "hook-vendor-drift", "session-memory-hardcut", "open-source-licenses", "secrets-policy", "no-dev-vars", "secret-provider-quarantine", "github-actions-secrets", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "ci-guardrails-wiring", "test-scan-perf", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "legacy-workflow-identity", "rules", "skills", "typescript-version"];
17
+ export declare const REPO_AUDIT_KIND_IDS: readonly ["catalog-drift", "package-surface", "command-surface", "reference-parity-matrix", "blueprint-readme-drift", "blueprint-lifecycle", "blueprint-trust", "roadmap-links", "docs-frontmatter", "agents", "vision", "tech-debt", "supported-agent-clis", "no-relative-parent-imports", "no-link-protocol", "no-relative-package-scripts", "test-isolation", "bucket-boundary", "skill-sizes", "broken-refs", "memory-rotation", "gitignore-agent-surfaces", "memory-unified", "compile-drift", "no-legacy-cli-bin", "architecture-drift", "cloudflare-deploy-contract", "toolchain-isolation", "absolute-path-policy", "no-first-party-mjs", "outside-review-runtime-ownership", "no-math-random", "agent-cost", "blueprint-db-consistency", "tech-debt-cadence", "cross-repo-correlation", "ai-contracts", "atomic-state-writes", "hook-surface", "hook-vendor-drift", "session-memory-hardcut", "open-source-licenses", "secrets-policy", "no-dev-vars", "secret-provider-quarantine", "github-actions-secrets", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "ci-guardrails-wiring", "test-scan-perf", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "legacy-workflow-identity", "rules", "skills", "typescript-version", "changeset-required"];
18
18
  export declare const AUDIT_KIND_DESCRIPTORS: readonly AuditKindDescriptor[];
19
- export declare const AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", ...("absolute-path-policy" | "agent-cost" | "agents" | "ai-contracts" | "architecture-drift" | "atomic-state-writes" | "blueprint-db-consistency" | "blueprint-lifecycle" | "blueprint-pr-coverage" | "blueprint-readme-drift" | "blueprint-trust" | "broken-refs" | "bucket-boundary" | "bundle-budget" | "catalog-drift" | "ci-guardrails-wiring" | "ci-test-perf" | "cloudflare-deploy-contract" | "command-surface" | "commit-message" | "compile-drift" | "consumer-agent-kit-dependency" | "cross-repo-correlation" | "docs-frontmatter" | "github-actions-secrets" | "gitignore-agent-surfaces" | "guardrails" | "harness-overlay-evidence" | "harness-surfaces" | "hook-surface" | "hook-vendor-drift" | "host-substitution-risk" | "legacy-workflow-identity" | "memory-rotation" | "memory-unified" | "mutation" | "no-dev-vars" | "no-first-party-mjs" | "no-legacy-cli-bin" | "no-link-protocol" | "no-math-random" | "no-relative-package-scripts" | "no-relative-parent-imports" | "open-source-licenses" | "outside-review-runtime-ownership" | "package-surface" | "quality" | "reference-parity-matrix" | "roadmap-links" | "rules" | "sast" | "secret-provider-quarantine" | "secrets-config" | "secrets-policy" | "security-quality-regressions" | "session-memory-hardcut" | "skill-sizes" | "skills" | "supported-agent-clis" | "tech-debt" | "tech-debt-cadence" | "test-isolation" | "test-scan-perf" | "toolchain-isolation" | "typescript-version" | "vision" | "weakness-mining" | "worktree-main-ownership")[]];
20
- export declare const MCP_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "typescript-version", "ci-guardrails-wiring", "sast"];
21
- export declare const AFFECTED_SAFE_AUDIT_KIND_IDS: readonly ["blueprint-readme-drift", "blueprint-lifecycle", "bucket-boundary", "skill-sizes", "broken-refs", "no-dev-vars", "absolute-path-policy", "secret-provider-quarantine", "secrets-config", "docs-frontmatter", "no-relative-parent-imports", "no-link-protocol", "no-first-party-mjs", "no-math-random", "test-scan-perf", "ci-guardrails-wiring"];
19
+ export declare const AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", ...("absolute-path-policy" | "agent-cost" | "agents" | "ai-contracts" | "architecture-drift" | "atomic-state-writes" | "blueprint-db-consistency" | "blueprint-lifecycle" | "blueprint-pr-coverage" | "blueprint-readme-drift" | "blueprint-trust" | "broken-refs" | "bucket-boundary" | "bundle-budget" | "catalog-drift" | "changeset-required" | "ci-guardrails-wiring" | "ci-test-perf" | "cloudflare-deploy-contract" | "command-surface" | "commit-message" | "compile-drift" | "consumer-agent-kit-dependency" | "cross-repo-correlation" | "docs-frontmatter" | "github-actions-secrets" | "gitignore-agent-surfaces" | "guardrails" | "harness-overlay-evidence" | "harness-surfaces" | "hook-surface" | "hook-vendor-drift" | "host-substitution-risk" | "legacy-workflow-identity" | "memory-rotation" | "memory-unified" | "mutation" | "no-dev-vars" | "no-first-party-mjs" | "no-legacy-cli-bin" | "no-link-protocol" | "no-math-random" | "no-relative-package-scripts" | "no-relative-parent-imports" | "open-source-licenses" | "outside-review-runtime-ownership" | "package-surface" | "quality" | "reference-parity-matrix" | "roadmap-links" | "rules" | "sast" | "secret-provider-quarantine" | "secrets-config" | "secrets-policy" | "security-quality-regressions" | "session-memory-hardcut" | "skill-sizes" | "skills" | "supported-agent-clis" | "tech-debt" | "tech-debt-cadence" | "test-isolation" | "test-scan-perf" | "toolchain-isolation" | "typescript-version" | "vision" | "weakness-mining" | "worktree-main-ownership")[]];
20
+ export declare const MCP_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "typescript-version", "ci-guardrails-wiring", "sast", "changeset-required"];
21
+ export declare const AFFECTED_SAFE_AUDIT_KIND_IDS: readonly ["blueprint-readme-drift", "blueprint-lifecycle", "bucket-boundary", "skill-sizes", "broken-refs", "no-dev-vars", "absolute-path-policy", "secret-provider-quarantine", "secrets-config", "docs-frontmatter", "no-relative-parent-imports", "no-link-protocol", "no-first-party-mjs", "no-math-random", "test-scan-perf", "ci-guardrails-wiring", "changeset-required"];
22
22
  export type AuditKindId = (typeof AUDIT_KIND_IDS)[number];
23
23
  export type RepoAuditKindId = (typeof REPO_AUDIT_KIND_IDS)[number];
24
24
  export type MCPAuditKindId = (typeof MCP_AUDIT_KIND_IDS)[number];
@@ -18,6 +18,7 @@ const affectedSafeKinds = [
18
18
  "no-math-random",
19
19
  "test-scan-perf",
20
20
  "ci-guardrails-wiring",
21
+ "changeset-required",
21
22
  ];
22
23
  const mcpKindIds = [
23
24
  "test-smells",
@@ -65,6 +66,7 @@ const mcpKindIds = [
65
66
  "typescript-version",
66
67
  "ci-guardrails-wiring",
67
68
  "sast",
69
+ "changeset-required",
68
70
  ];
69
71
  const affectedSafe = new Set(affectedSafeKinds);
70
72
  const mcpKinds = new Set(mcpKindIds);
@@ -148,6 +150,7 @@ const repoKinds = [
148
150
  "rules",
149
151
  "skills",
150
152
  "typescript-version",
153
+ "changeset-required",
151
154
  ];
152
155
  function descriptor(id, kindClass) {
153
156
  return {
@@ -158,7 +161,9 @@ function descriptor(id, kindClass) {
158
161
  gateClass: signalKinds.has(id) ? "signal" : "blocking",
159
162
  scopeSafety: affectedSafe.has(id) ? "affected-safe" : "full-scan-only",
160
163
  summaryLabel: id === "sast" ? "SAST" : id,
161
- ...(["sast", "host-substitution-risk"].includes(id) ? { includedInGuardrails: false } : {}),
164
+ ...(["sast", "host-substitution-risk", "changeset-required"].includes(id)
165
+ ? { includedInGuardrails: false }
166
+ : {}),
162
167
  ...(["blueprint-readme-drift", "docs-frontmatter"].includes(id) ? { fixable: true } : {}),
163
168
  };
164
169
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import matter from "gray-matter";
8
8
  import { planFrontmatterSchema, taskStatusSchema } from "#core/schema";
9
+ import { buildTaskHeadingRegex } from "#markdown/task-heading";
9
10
  function parseWithSchema(markdown, schema) {
10
11
  const { data: rawData, content } = matter(markdown);
11
12
  const data = schema.parse(rawData);
@@ -75,7 +76,7 @@ export function serializeBlueprint(blueprint) {
75
76
  }
76
77
  const { tasks: _tasks, task_statuses: _task_statuses, ...cleanedData } = data;
77
78
  const serialized = quoteGeneratedProgressScalar(matter.stringify(content, cleanedData), blueprint.progress);
78
- return preserveOriginalFrontmatterScalars(serialized, blueprint.raw);
79
+ return preserveOriginalFrontmatterMultiline(preserveOriginalFrontmatterScalars(serialized, blueprint.raw), blueprint.raw);
79
80
  }
80
81
  // Frontmatter keys serializeBlueprint intentionally rewrites; every other
81
82
  // scalar must survive a write byte-for-byte.
@@ -99,8 +100,8 @@ function quoteGeneratedProgressScalar(serialized, progress) {
99
100
  // re-quote on a write silently invalidates a recorded review approval. Restore
100
101
  // the ORIGINAL single-line scalar verbatim for every field this serializer does
101
102
  // not own, so a status/progress write never disturbs `title`/`owner`/`created`
102
- // etc. Multi-line and array fields (no inline value) are left to js-yaml, which
103
- // dumps them deterministically. Generalizes the earlier progress-only patch.
103
+ // etc. Multi-line/array fields are restored separately by
104
+ // preserveOriginalFrontmatterMultiline. Generalizes the earlier progress-only patch.
104
105
  function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
105
106
  const originalFrontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(originalRaw)?.[1] ?? "";
106
107
  const originalScalars = new Map();
@@ -124,6 +125,59 @@ function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
124
125
  .join("\n");
125
126
  return `${serialized.slice(0, block.index)}${block[1]}${rebuilt}${block[3]}${serialized.slice(block.index + block[0].length)}`;
126
127
  }
128
+ // Same residual as scalars, for multi-line blocks (YAML arrays/maps under a
129
+ // key with no inline value). `matter.stringify` re-dumps arrays and drops
130
+ // item quotes (`- "review"` -> `- review`). MCP put and the CLI disagree on
131
+ // that quoting; restore the ORIGINAL multi-line block for every non-owned key.
132
+ // Allow optional CR on the key line so CRLF checkouts still match.
133
+ const FRONTMATTER_MULTILINE_KEY = /^([A-Za-z0-9_]+):[ \t]*\r?$/;
134
+ function preserveOriginalFrontmatterMultiline(serialized, originalRaw) {
135
+ const originalFrontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(originalRaw)?.[1] ?? "";
136
+ const originalBlocks = new Map();
137
+ const lines = originalFrontmatter.split("\n");
138
+ for (let index = 0; index < lines.length;) {
139
+ const match = FRONTMATTER_MULTILINE_KEY.exec(lines[index]);
140
+ if (!match) {
141
+ index += 1;
142
+ continue;
143
+ }
144
+ const key = match[1];
145
+ if (SERIALIZE_OWNED_FRONTMATTER_KEYS.has(key)) {
146
+ index += 1;
147
+ continue;
148
+ }
149
+ // Strip a trailing CR so joined blocks stay LF-normalized like the rest of the pipeline.
150
+ const blockLines = [lines[index].replace(/\r$/, "")];
151
+ index += 1;
152
+ while (index < lines.length && /^[ \t]/.test(lines[index])) {
153
+ blockLines.push(lines[index].replace(/\r$/, ""));
154
+ index += 1;
155
+ }
156
+ if (blockLines.length > 1) {
157
+ originalBlocks.set(key, blockLines.join("\n"));
158
+ }
159
+ }
160
+ if (originalBlocks.size === 0)
161
+ return serialized;
162
+ const wrapper = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(serialized);
163
+ if (!wrapper)
164
+ return serialized;
165
+ let body = wrapper[2];
166
+ for (const [key, originalBlock] of originalBlocks) {
167
+ // Match the serialized multi-line block. A trailing newline is optional when
168
+ // the block is the last key in the frontmatter body (wrapper[2] does not
169
+ // include the newline before `---`).
170
+ const pattern = new RegExp(String.raw `^${key}:[ \t]*\n(?:[ \t][^\n]*\n?)*`, "mu");
171
+ if (!pattern.test(body))
172
+ continue;
173
+ // Replacer function: avoid `$` re-interpretation of originalBlock contents,
174
+ // and only re-append a trailing newline when the match consumed one — otherwise
175
+ // a last-key `tags:` block gains a blank line before `---` and the plan digest
176
+ // flips on the first lifecycle write after MCP put (B1).
177
+ body = body.replace(pattern, (matched) => matched.endsWith("\n") ? `${originalBlock}\n` : originalBlock);
178
+ }
179
+ return `${serialized.slice(0, wrapper.index)}${wrapper[1]}${body}${wrapper[3]}${serialized.slice(wrapper.index + wrapper[0].length)}`;
180
+ }
127
181
  function extractCheckboxStatus(section) {
128
182
  const checkboxRegex = /^- \[([ x])\]/gm;
129
183
  const matches = Array.from(section.matchAll(checkboxRegex));
@@ -178,7 +232,10 @@ function validateTaskFormat(content) {
178
232
  }
179
233
  }
180
234
  function extractTasks(content) {
181
- const taskRegex = /^####\s+(?:\[[^\]]+\]\s+)?Task\s+(\d+(?:\.\d+)+):\s*(.+)$/gm;
235
+ // Built from #markdown/task-heading's shared pattern (also consumed by
236
+ // #core/schema's taskIdSchema) so the reader and the MCP put-task writer
237
+ // cannot drift apart on what counts as a valid task ID.
238
+ const taskRegex = buildTaskHeadingRegex();
182
239
  const matches = Array.from(content.matchAll(taskRegex));
183
240
  validateTaskFormat(content);
184
241
  return matches.map((match, index) => {
@@ -77,6 +77,7 @@ export declare const crossRepoDependencySchema: z.ZodObject<{
77
77
  planned: "planned";
78
78
  }>>;
79
79
  }, z.core.$strip>;
80
+ export declare const taskIdSchema: z.ZodString;
80
81
  /**
81
82
  * Plan frontmatter schema.
82
83
  *
@@ -11,6 +11,7 @@ import { z } from "zod";
11
11
  */
12
12
  import { executionBackendSchema } from "#types/execution-backend.js";
13
13
  export { executionBackendSchema } from "#types/execution-backend.js";
14
+ import { TASK_ID_PATTERN as TASK_HEADING_TASK_ID_PATTERN } from "#markdown/task-heading";
14
15
  /**
15
16
  * Valid plan status values.
16
17
  * Maps to plan lifecycle: draft/planned/parked → in-progress → completed/archived.
@@ -58,6 +59,31 @@ export const crossRepoDependencySchema = z.object({
58
59
  slug: z.string().min(1),
59
60
  require_status: planStatusSchema.optional(),
60
61
  });
62
+ /**
63
+ * Task-ID validator for MCP writer boundaries (e.g. `wp_blueprint_put`).
64
+ *
65
+ * Built from `#markdown/task-heading`'s `TASK_ID_PATTERN` — the existing
66
+ * single source of truth for the numeric-dotted task-ID shape (e.g. "1.1",
67
+ * "2.3.1") already consumed by `#core/parser`'s `extractTasks()` (via
68
+ * `buildTaskHeadingRegex()`) and by `verification.ts` / `markdown/helpers.ts`.
69
+ * Validating writer-supplied IDs against the same pattern the reader uses
70
+ * means the writer (MCP `put`) and the reader (parser) cannot drift apart —
71
+ * a non-numeric-dotted ID like "task-1" is rejected before it ever reaches
72
+ * markdown, instead of being silently unparseable afterward.
73
+ */
74
+ const TASK_ID_ANCHORED_PATTERN = new RegExp(`^${TASK_HEADING_TASK_ID_PATTERN}$`);
75
+ export const taskIdSchema = z
76
+ .string()
77
+ .min(1)
78
+ .superRefine((value, ctx) => {
79
+ if (!TASK_ID_ANCHORED_PATTERN.test(value)) {
80
+ ctx.addIssue({
81
+ code: z.ZodIssueCode.custom,
82
+ message: `Task IDs must be numeric dotted (e.g. "1.1", "2.3.1") — the blueprint parser only ` +
83
+ `recognizes "#### Task <numeric-dotted-id>:" headers. Use "1.1", got "${value}".`,
84
+ });
85
+ }
86
+ });
61
87
  /**
62
88
  * Plan frontmatter schema.
63
89
  *
@@ -12,7 +12,8 @@ import { scanBlueprintDirectory } from "#service/scanner";
12
12
  import { resolveBlueprintRoot } from "#utils/blueprint-root";
13
13
  import { parseBlueprintDocumentRelativePath } from "#utils/document-paths.js";
14
14
  import { relativeBlueprintSlug } from "#lifecycle/local.js";
15
- import { collectV2ReviewProvenance, collectTrackedReviewProvenance, normalizeTrackedReviewApprovals, } from "#lifecycle/review-provenance.js";
15
+ import { collectV2ReviewProvenance, collectTrackedReviewProvenance, normalizeTrackedReviewApprovals, stableSlugForBlueprintFile, } from "#lifecycle/review-provenance.js";
16
+ import { isBlueprintExemptFromRollout, ROLLOUT_DATE } from "#lifecycle/rollout-anchor.js";
16
17
  import { MAINTAINER_OVERRIDE_REVIEWER } from "#review/events.js";
17
18
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
18
19
  function isBlueprintOverview(file) {
@@ -51,14 +52,25 @@ function readLifecycleAuditFrontmatter(raw) {
51
52
  * blueprints that predate those changed-state gates.
52
53
  */
53
54
  const APPROVAL_GATED_STATUSES = new Set(["planned"]);
54
- const COMPLETED_APPROVAL_GATE_ROLLOUT_DATE = "2026-07-12";
55
- function predatesCompletedApprovalGate(created) {
56
- const date = created instanceof Date
57
- ? created.toISOString().slice(0, 10)
58
- : typeof created === "string" && /^\d{4}-\d{2}-\d{2}$/u.test(created)
59
- ? created
60
- : null;
61
- return date !== null && date < COMPLETED_APPROVAL_GATE_ROLLOUT_DATE;
55
+ /**
56
+ * True when this blueprint predates the completed-approval gate and is exempt.
57
+ *
58
+ * Inside this repository the rollout anchor commit decides and the
59
+ * author-supplied `created` is ignored, which closes a real bypass: `created`
60
+ * is written by the same author the gate constrains, so an old date used to
61
+ * disable the gate outright -- on the CI audit path and on the unconditional
62
+ * `assertCompletedApprovalGate` write path, letting a blueprint reach
63
+ * `completed` with no approval at all. Outside this repository the anchor
64
+ * cannot answer the question and the legacy date compare still applies. See
65
+ * `#lifecycle/rollout-anchor.js` for the full resolution order.
66
+ */
67
+ function predatesCompletedApprovalGate(file, created) {
68
+ return isBlueprintExemptFromRollout({
69
+ repoRoot: findGitRoot(file),
70
+ slug: stableSlugForBlueprintFile(file),
71
+ created,
72
+ rolloutDate: ROLLOUT_DATE,
73
+ });
62
74
  }
63
75
  /**
64
76
  * Promotion gate: a blueprint past `draft` must carry either one provenance-backed
@@ -144,7 +156,7 @@ export function validateApprovalGate(file, frontmatter, options = {}) {
144
156
  const status = typeof frontmatter.status === "string" ? frontmatter.status : "";
145
157
  if (status === "completed" &&
146
158
  options.completedApprovalGate === true &&
147
- predatesCompletedApprovalGate(frontmatter.created)) {
159
+ predatesCompletedApprovalGate(file, frontmatter.created)) {
148
160
  return [];
149
161
  }
150
162
  const gateApplies = APPROVAL_GATED_STATUSES.has(status) ||