@webpresso/agent-kit 3.1.26 → 3.1.30

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 (150) hide show
  1. package/catalog/AGENTS.md.tpl +7 -11
  2. package/catalog/agent/commands/verify.md +2 -2
  3. package/catalog/agent/rules/changeset-release.md +4 -2
  4. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  5. package/catalog/agent/rules/ci-test-perf.md +5 -1
  6. package/catalog/agent/rules/cmd-execution.md +9 -0
  7. package/catalog/agent/rules/pre-implementation.md +2 -1
  8. package/catalog/agent/rules/test-scan-perf.md +80 -0
  9. package/catalog/agent/rules/workflow-skills-routing.md +9 -4
  10. package/catalog/agent/skills/autopilot/SKILL.md +48 -11
  11. package/catalog/agent/skills/claude/SKILL.md +7 -0
  12. package/catalog/agent/skills/codex/SKILL.md +7 -0
  13. package/catalog/agent/skills/fix/SKILL.md +36 -8
  14. package/catalog/agent/skills/grok/SKILL.md +7 -0
  15. package/catalog/agent/skills/investigate/SKILL.md +12 -1
  16. package/catalog/agent/skills/pll/SKILL.md +53 -48
  17. package/catalog/agent/skills/team/SKILL.md +26 -9
  18. package/catalog/agent/skills/ultragoal/SKILL.md +147 -19
  19. package/catalog/agent/skills/verify/SKILL.md +89 -51
  20. package/dist/esm/audit/blueprint-lifecycle-sql.d.ts +8 -0
  21. package/dist/esm/audit/blueprint-lifecycle-sql.js +25 -1
  22. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  23. package/dist/esm/audit/registry.d.ts +4 -4
  24. package/dist/esm/audit/registry.js +3 -0
  25. package/dist/esm/audit/roadmap-links.js +6 -0
  26. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  27. package/dist/esm/audit/test-scan-perf.js +275 -0
  28. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  29. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  30. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  31. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  32. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  33. package/dist/esm/ci/act-replay.js +110 -7
  34. package/dist/esm/ci/act-runner.d.ts +4 -0
  35. package/dist/esm/ci/act-runner.js +85 -26
  36. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  37. package/dist/esm/ci/act-worktree-git.js +219 -0
  38. package/dist/esm/ci/native-session-memory-cache.d.ts +9 -1
  39. package/dist/esm/ci/native-session-memory-cache.js +9 -1
  40. package/dist/esm/ci/vitest-ci-shards.d.ts +6 -4
  41. package/dist/esm/ci/vitest-ci-shards.js +6 -4
  42. package/dist/esm/cli/auto-update/guard-tombstone.d.ts +25 -0
  43. package/dist/esm/cli/auto-update/guard-tombstone.js +67 -0
  44. package/dist/esm/cli/auto-update/installer.d.ts +4 -17
  45. package/dist/esm/cli/auto-update/installer.js +6 -57
  46. package/dist/esm/cli/auto-update/self-invocation.d.ts +37 -0
  47. package/dist/esm/cli/auto-update/self-invocation.js +62 -0
  48. package/dist/esm/cli/commands/audit.js +4 -0
  49. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  50. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  51. package/dist/esm/cli/commands/ci.d.ts +8 -1
  52. package/dist/esm/cli/commands/ci.js +80 -21
  53. package/dist/esm/cli/commands/dash/index.js +3 -3
  54. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  55. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  56. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  57. package/dist/esm/cli/commands/dash/plan-once-attention.d.ts +32 -0
  58. package/dist/esm/cli/commands/dash/plan-once-attention.js +50 -0
  59. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  60. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  61. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  62. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  63. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  64. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  65. package/dist/esm/cli/commands/dash/plan-once-schema.d.ts +73 -0
  66. package/dist/esm/cli/commands/dash/plan-once-schema.js +119 -0
  67. package/dist/esm/cli/commands/dash/plan-once.d.ts +142 -0
  68. package/dist/esm/cli/commands/dash/plan-once.js +261 -0
  69. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  70. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  71. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  72. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  73. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  74. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  75. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  76. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  77. package/dist/esm/cli/commands/dash/tui/ipc-protocol.d.ts +87 -0
  78. package/dist/esm/cli/commands/dash/tui/ipc-protocol.js +128 -0
  79. package/dist/esm/cli/commands/dash/tui/native-host.d.ts +34 -0
  80. package/dist/esm/cli/commands/dash/tui/native-host.js +256 -0
  81. package/dist/esm/cli/commands/dash/tui/native-path.d.ts +11 -0
  82. package/dist/esm/cli/commands/dash/tui/native-path.js +41 -0
  83. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  84. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  85. package/dist/esm/cli/commands/dash/tui/runtime.js +311 -4
  86. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  87. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  88. package/dist/esm/cli/commands/format.js +0 -2
  89. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  90. package/dist/esm/cli/commands/init/config.js +1 -4
  91. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  92. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  93. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  94. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  95. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  96. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  97. package/dist/esm/cli/commands/init/scaffolders/agent-kit-global/index.js +1 -1
  98. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  99. package/dist/esm/cli/commands/logs.d.ts +15 -0
  100. package/dist/esm/cli/commands/logs.js +192 -3
  101. package/dist/esm/cli/commands/package-manager.js +2 -1
  102. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  103. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  104. package/dist/esm/cli/commands/review.js +5 -8
  105. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  106. package/dist/esm/cli/commands/secrets.js +4 -1
  107. package/dist/esm/cli/commands/self-install-guard.d.ts +14 -32
  108. package/dist/esm/cli/commands/self-install-guard.js +17 -63
  109. package/dist/esm/cli/direct-provider-launch.js +25 -1
  110. package/dist/esm/hooks/doctor.d.ts +15 -0
  111. package/dist/esm/hooks/doctor.js +56 -0
  112. package/dist/esm/hooks/guard-switch/index.js +8 -4
  113. package/dist/esm/hooks/post-tool/lint-after-edit.js +6 -4
  114. package/dist/esm/hooks/precompact/index.js +5 -3
  115. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  116. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  117. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  118. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  119. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  120. package/dist/esm/hooks/sessionstart/index.d.ts +10 -0
  121. package/dist/esm/hooks/sessionstart/index.js +61 -4
  122. package/dist/esm/hooks/shared/types.d.ts +11 -0
  123. package/dist/esm/hooks/shared/types.js +21 -0
  124. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  125. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  126. package/dist/esm/mcp/tools/audit.js +10 -0
  127. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  128. package/dist/esm/mcp/tools/ci-act.js +9 -11
  129. package/dist/esm/paths/state-root.js +9 -0
  130. package/dist/esm/review/authority.js +17 -2
  131. package/dist/esm/review/availability.js +18 -5
  132. package/dist/esm/review/subject.d.ts +15 -0
  133. package/dist/esm/review/subject.js +105 -10
  134. package/dist/esm/runtime/executor.d.ts +1 -0
  135. package/dist/esm/runtime/executor.js +2 -1
  136. package/dist/esm/secret-gate/runner.d.ts +1 -0
  137. package/dist/esm/secret-gate/runner.js +4 -1
  138. package/dist/esm/session-memory/current-session.js +2 -0
  139. package/dist/esm/session-memory/native-runtime.js +5 -3
  140. package/dist/esm/session-memory/native-warm-markers.d.ts +18 -0
  141. package/dist/esm/session-memory/native-warm-markers.js +22 -0
  142. package/dist/esm/session-memory/sync/types.d.ts +4 -0
  143. package/dist/esm/session-memory/types.d.ts +3 -1
  144. package/dist/esm/session-memory/types.js +2 -0
  145. package/dist/esm/ultragoal/runtime.js +44 -2
  146. package/dist/esm/worktrees/current.d.ts +1 -0
  147. package/dist/esm/worktrees/current.js +1 -0
  148. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  149. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  150. package/package.json +17 -14
@@ -369,6 +369,27 @@ function changedBlueprintScope(candidates) {
369
369
  }
370
370
  return { slugs };
371
371
  }
372
+ /**
373
+ * When `--affected` supplies an explicit file set (pre-commit staged files or
374
+ * branch ACMR list), only keep blueprint transition candidates that touch
375
+ * those paths. Untracked WIP drafts outside the set must not fail the commit.
376
+ *
377
+ * `affectedFiles === undefined` keeps the full porcelain candidate map (CLI
378
+ * `wp audit blueprint-lifecycle --changed-only` without a staged filter).
379
+ */
380
+ function filterTransitionCandidatesByAffectedFiles(candidates, affectedFiles) {
381
+ if (affectedFiles === undefined)
382
+ return candidates;
383
+ const allowed = new Set(affectedFiles.map((file) => normalizeGitPath(file)));
384
+ const filtered = new Map();
385
+ for (const [currentPath, candidate] of candidates) {
386
+ const paths = [currentPath, ...candidate.previousPaths].map(normalizeGitPath);
387
+ if (paths.some((p) => allowed.has(p))) {
388
+ filtered.set(currentPath, candidate);
389
+ }
390
+ }
391
+ return filtered;
392
+ }
372
393
  function violationTouchesBlueprintScope(violation, scope) {
373
394
  if (!violation.file)
374
395
  return false;
@@ -422,7 +443,10 @@ export async function auditBlueprintLifecycleSql(cwd = process.cwd(), options =
422
443
  const changedCandidateMap = gitHistoryAvailable
423
444
  ? listChangedBlueprintCandidates(cwd, { baseRef: options.baseRef })
424
445
  : null;
425
- const changedScope = changedCandidateMap === null ? null : changedBlueprintScope(changedCandidateMap);
446
+ const scopedCandidateMap = changedCandidateMap === null
447
+ ? null
448
+ : filterTransitionCandidatesByAffectedFiles(changedCandidateMap, options.affectedFiles);
449
+ const changedScope = scopedCandidateMap === null ? null : changedBlueprintScope(scopedCandidateMap);
426
450
  // Review-evidence append-only enforcement (base-vs-head ledger prefix check).
427
451
  // Runs BEFORE the changed-only early returns: a PR that only rewrites a
428
452
  // review-events.jsonl touches zero blueprint documents, so changedScope is
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { relative } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
3
4
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
4
5
  const TITLE = "Blueprint PR coverage";
5
6
  const BLUEPRINT_EXEMPT_PATTERN = /^Blueprint-exempt:\s*(\S.*)$/im;
@@ -39,14 +40,33 @@ export function auditBlueprintPrCoverage(rootDirectory = process.cwd(), options
39
40
  const configuredRoot = normalizeRoot(normalizePath(relative(rootDirectory, resolveBlueprintRoot(rootDirectory))));
40
41
  const blueprintRoots = [...new Set(["blueprints", configuredRoot].filter(Boolean))];
41
42
  const finalEntries = entries.filter((entry) => entry.status !== "D");
42
- const activePaths = finalEntries.filter((entry) => {
43
+ const activeEntries = finalEntries.flatMap((entry) => {
43
44
  const location = parseBlueprintLocation(entry.path, blueprintRoots);
44
- return location !== null && ACTIVE_LIFECYCLE_DIRECTORIES.has(location.lifecycle);
45
+ if (!location || !ACTIVE_LIFECYCLE_DIRECTORIES.has(location.lifecycle))
46
+ return [];
47
+ return [{ entry, location }];
45
48
  });
46
- if (activePaths.length > 0) {
47
- return fail(`non-doc landing PR leaves changed active blueprint content at ${activePaths
48
- .map((entry) => entry.path)
49
- .join(", ")}; move the owner blueprint to completed before merge`, changedFiles.length);
49
+ const ownerActive = activeEntries.filter(({ location }) => location.slug === ownerSlug);
50
+ if (ownerActive.length > 0) {
51
+ return fail(`non-doc landing PR leaves owner blueprint "${ownerSlug}" in active lifecycle at ${ownerActive
52
+ .map(({ entry }) => entry.path)
53
+ .join(", ")}; move completed/${ownerSlug}/_overview.md (or .md) into the PR and drop active-lifecycle owner paths`, changedFiles.length);
54
+ }
55
+ // Parent-roadmap program trackers may co-edit on a child land PR while still
56
+ // active (draft/planned). Executable blueprints (type: blueprint) may not.
57
+ const nonOwnerActive = activeEntries.filter(({ location }) => location.slug !== ownerSlug);
58
+ const programTrackers = [];
59
+ const blockingNonOwner = [];
60
+ for (const { entry, location } of nonOwnerActive) {
61
+ if (isParentRoadmapDocument(rootDirectory, entry.path, location)) {
62
+ programTrackers.push(entry.path);
63
+ }
64
+ else {
65
+ blockingNonOwner.push(entry.path);
66
+ }
67
+ }
68
+ if (blockingNonOwner.length > 0) {
69
+ return fail(`non-doc landing PR changes active non-owner blueprint content at ${blockingNonOwner.join(", ")}; remove those paths from the PR or complete those blueprints first (owner ${ownerSlug} is separate)`, changedFiles.length);
50
70
  }
51
71
  const matchingCompletedOwner = finalEntries.some((entry) => {
52
72
  const location = parseBlueprintLocation(entry.path, blueprintRoots);
@@ -55,6 +75,9 @@ export function auditBlueprintPrCoverage(rootDirectory = process.cwd(), options
55
75
  if (!matchingCompletedOwner) {
56
76
  return fail(`non-doc PR on ${headRef} must change its completed owner blueprint at completed/${ownerSlug}/_overview.md (or completed/${ownerSlug}.md)`, changedFiles.length);
57
77
  }
78
+ if (programTrackers.length > 0) {
79
+ return passWithWarning(`[warn] co-edited parent-roadmap program tracker(s) on non-doc land: ${programTrackers.join(", ")}`, changedFiles.length);
80
+ }
58
81
  return pass(changedFiles.length);
59
82
  }
60
83
  function pass(checked) {
@@ -134,6 +157,44 @@ function parseBlueprintLocation(filePath, roots) {
134
157
  }
135
158
  return null;
136
159
  }
160
+ /**
161
+ * True when the changed path belongs to a `type: parent-roadmap` document.
162
+ * Nested non-overview files under a parent-roadmap folder inherit the overview
163
+ * type so review artifacts co-travel. Missing paths (unit-test fakes) return
164
+ * false → fail closed as executable blueprints.
165
+ */
166
+ function isParentRoadmapDocument(rootDirectory, filePath, location) {
167
+ const normalized = normalizePath(filePath);
168
+ const overviewCandidates = [
169
+ // Prefer the changed overview when it is the document itself.
170
+ ...(location.ownerDocument ? [normalized] : []),
171
+ // folder overview: …/<lifecycle>/<slug>/_overview.md
172
+ normalized.replace(new RegExp(`/(draft|planned|in-progress|parked|completed|archived)/${escapeRegex(location.slug)}(?:/.*)?$`, "u"), `/$1/${location.slug}/_overview.md`),
173
+ // flat overview: …/<lifecycle>/<slug>.md
174
+ normalized.replace(new RegExp(`/(draft|planned|in-progress|parked|completed|archived)/${escapeRegex(location.slug)}(?:/.*|\\.md)?$`, "u"), `/$1/${location.slug}.md`),
175
+ ];
176
+ const seen = new Set();
177
+ for (const candidate of overviewCandidates) {
178
+ if (seen.has(candidate))
179
+ continue;
180
+ seen.add(candidate);
181
+ const abs = join(rootDirectory, candidate);
182
+ if (!existsSync(abs))
183
+ continue;
184
+ try {
185
+ const head = readFileSync(abs, "utf8").slice(0, 4_096);
186
+ if (/^type:\s*['"]?parent-roadmap['"]?\s*$/mu.test(head))
187
+ return true;
188
+ }
189
+ catch {
190
+ // unreadable → fail closed
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+ function escapeRegex(value) {
196
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
197
+ }
137
198
  function isDependabotDependencyOnlyUpdate(cwd, options, changedFiles) {
138
199
  if (!changedFiles.every(isDependabotDependencyFile))
139
200
  return false;
@@ -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", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "legacy-workflow-identity", "rules", "skills"];
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", "test-scan-perf", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "legacy-workflow-identity", "rules", "skills"];
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-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" | "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" | "toolchain-isolation" | "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", "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", "session-memory-hardcut", "worktree-main-ownership", "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"];
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-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" | "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" | "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", "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", "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"];
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];
@@ -16,6 +16,7 @@ const affectedSafeKinds = [
16
16
  "no-link-protocol",
17
17
  "no-first-party-mjs",
18
18
  "no-math-random",
19
+ "test-scan-perf",
19
20
  ];
20
21
  const mcpKindIds = [
21
22
  "test-smells",
@@ -56,6 +57,7 @@ const mcpKindIds = [
56
57
  "secrets-config",
57
58
  "consumer-agent-kit-dependency",
58
59
  "ci-test-perf",
60
+ "test-scan-perf",
59
61
  "session-memory-hardcut",
60
62
  "worktree-main-ownership",
61
63
  "sast",
@@ -130,6 +132,7 @@ const repoKinds = [
130
132
  "secrets-config",
131
133
  "consumer-agent-kit-dependency",
132
134
  "ci-test-perf",
135
+ "test-scan-perf",
133
136
  "worktree-main-ownership",
134
137
  "sast",
135
138
  "harness-surfaces",
@@ -58,7 +58,13 @@ export function auditRoadmapLinks(rootDirectory = process.cwd(), options = {}) {
58
58
  message: "Parent-roadmap execution-wave maps may list local child blueprints only; move cross-repo references to Cross-Plan References and use GitHub links there",
59
59
  });
60
60
  }
61
+ // Archived parent-roadmaps are non-executable program records. They may
62
+ // document completed children without a live execution-wave map or local
63
+ // children still declaring parent_roadmap. Do not fail CI on that residue.
61
64
  if (waveMapChildren.size === 0 && localChildren.length === 0) {
65
+ if (roadmap.status === "archived") {
66
+ continue;
67
+ }
62
68
  violations.push({
63
69
  file: roadmap.file,
64
70
  message: "Roadmap declares no children in its wave map",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Guardrail: keep tests from silently growing multi-second / multi-ten-second
3
+ * full-repo filesystem scans, and from "fixing" that cost with elevated
4
+ * per-test timeouts.
5
+ *
6
+ * The private-UI cutover anti-regression suite originally walked the monorepo
7
+ * with readdirSync/statSync/readFileSync and needed a 30s timeout. Prefer
8
+ * `git grep` (tracked files) or a bounded fixture tree instead.
9
+ *
10
+ * Opt-out: `// test-scan-perf-allow` on the same line or previous line as the
11
+ * walk/timeout site, or `// test-scan-perf-allow-file` anywhere in the file.
12
+ */
13
+ import type { RepoAuditResult, RepoAuditViolation } from "./repo-guardrails.js";
14
+ export declare function isTestScanPerfTargetPath(relativePath: string): boolean;
15
+ export declare function findTestScanPerfViolationsInText(relativePath: string, text: string): RepoAuditViolation[];
16
+ export declare function auditTestScanPerf(rootDirectory?: string, options?: {
17
+ affectedFiles?: readonly string[];
18
+ }): RepoAuditResult;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Guardrail: keep tests from silently growing multi-second / multi-ten-second
3
+ * full-repo filesystem scans, and from "fixing" that cost with elevated
4
+ * per-test timeouts.
5
+ *
6
+ * The private-UI cutover anti-regression suite originally walked the monorepo
7
+ * with readdirSync/statSync/readFileSync and needed a 30s timeout. Prefer
8
+ * `git grep` (tracked files) or a bounded fixture tree instead.
9
+ *
10
+ * Opt-out: `// test-scan-perf-allow` on the same line or previous line as the
11
+ * walk/timeout site, or `// test-scan-perf-allow-file` anywhere in the file.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
15
+ import { join, relative } from "node:path";
16
+ const TITLE = "test-scan-perf";
17
+ const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
18
+ const E2E_FILE_PATTERN = /\.e2e\./u;
19
+ const SKIP_DIRS = new Set([
20
+ ".git",
21
+ "node_modules",
22
+ "dist",
23
+ "coverage",
24
+ ".turbo",
25
+ ".webpresso",
26
+ ".agent",
27
+ ".agents",
28
+ ".claude",
29
+ ".codex",
30
+ ".cursor",
31
+ ".opencode",
32
+ ".omx",
33
+ "playwright-report",
34
+ "test-results",
35
+ "logs",
36
+ "_worktrees",
37
+ ]);
38
+ const ALLOW_LINE_PATTERN = /test-scan-perf-allow(?:-timeout|-walk)?\b/iu;
39
+ const ALLOW_FILE_PATTERN = /test-scan-perf-allow-file\b/iu;
40
+ /** Recursive walk helper that also reads file contents (content scan). */
41
+ const RECURSIVE_WALK_HINT = /(?:function\s+walk\b|const\s+walk\s*=|let\s+walk\s*=|var\s+walk\s*=)/u;
42
+ const RECURSIVE_CALL_HINT = /\bwalk\s*\(\s*(?:full|dir|current|directory|entryPath|sub|next)\b/u;
43
+ const READDIR_HINT = /\breaddirSync\b/u;
44
+ const READ_FILE_HINT = /\breadFileSync\b/u;
45
+ /**
46
+ * True when the file acquires the *workspace* root (not a mkdtemp fixture)
47
+ * and feeds that root into a recursive walk / file-finder helper.
48
+ */
49
+ function hasWorkspaceRootScanStart(code) {
50
+ const assignsWorkspaceRoot = /\bfindRepoRoot\s*\(/u.test(code) ||
51
+ /(?:const|let|var)\s+(?:repoRoot|REPO_ROOT)\s*=\s*process\.cwd\s*\(/u.test(code) ||
52
+ /(?:const|let|var)\s+root\s*=\s*(?:findRepoRoot\s*\(|process\.cwd\s*\()/u.test(code);
53
+ if (!assignsWorkspaceRoot)
54
+ return false;
55
+ // Direct walk(repoRoot|REPO_ROOT) or helper(repoRoot|REPO_ROOT) that owns the walk.
56
+ if (/\b(?:walk|findFiles|list\w*|scan\w*|collect\w*)\s*\(\s*(?:repoRoot|REPO_ROOT)\b/u.test(code)) {
57
+ return true;
58
+ }
59
+ // walk(root) only when `root` itself is the workspace root assignment.
60
+ if (/(?:const|let|var)\s+root\s*=\s*(?:findRepoRoot\s*\(|process\.cwd\s*\()/u.test(code) &&
61
+ /\b(?:walk|findFiles|list\w*|scan\w*|collect\w*)\s*\(\s*root\b/u.test(code)) {
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ const MIN_ELEVATED_TIMEOUT_MS = 30_000;
67
+ export function isTestScanPerfTargetPath(relativePath) {
68
+ const normalized = relativePath.replaceAll("\\", "/");
69
+ if (!TEST_FILE_PATTERN.test(normalized))
70
+ return false;
71
+ if (E2E_FILE_PATTERN.test(normalized))
72
+ return false;
73
+ // Audit self-tests and fixtures intentionally include positive examples.
74
+ if (normalized.includes("/__fixtures__/"))
75
+ return false;
76
+ if (normalized.endsWith("/test-scan-perf.test.ts"))
77
+ return false;
78
+ return true;
79
+ }
80
+ function stripComments(source) {
81
+ return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/[^\n]*/gu, "$1");
82
+ }
83
+ function stripAllowedLines(source) {
84
+ const lines = source.split(/\r?\n/u);
85
+ return lines.map((line, index) => (lineAllowed(lines, index) ? "" : line)).join("\n");
86
+ }
87
+ function lineAllowed(lines, index) {
88
+ const current = lines[index] ?? "";
89
+ if (ALLOW_LINE_PATTERN.test(current))
90
+ return true;
91
+ if (index > 0 && ALLOW_LINE_PATTERN.test(lines[index - 1] ?? ""))
92
+ return true;
93
+ return false;
94
+ }
95
+ function hasRepoRootContentWalk(code) {
96
+ if (!READDIR_HINT.test(code) || !READ_FILE_HINT.test(code))
97
+ return false;
98
+ if (!RECURSIVE_WALK_HINT.test(code))
99
+ return false;
100
+ const recursiveCall = RECURSIVE_CALL_HINT.test(code) || /\bwalk\s*\(\s*join\s*\(/u.test(code);
101
+ if (!recursiveCall)
102
+ return false;
103
+ return hasWorkspaceRootScanStart(code);
104
+ }
105
+ function parseTimeoutMs(raw) {
106
+ const digits = raw.replaceAll("_", "");
107
+ if (!/^\d+$/u.test(digits))
108
+ return null;
109
+ const value = Number(digits);
110
+ return Number.isFinite(value) ? value : null;
111
+ }
112
+ function findElevatedTimeoutLines(text) {
113
+ const hits = [];
114
+ const lines = text.split(/\r?\n/u);
115
+ // Line-oriented: `{ timeout: 30000 }`, trailing `, 30000)`, or a multi-line
116
+ // `it(\n "…",\n () => {…},\n 30_000,\n)` form.
117
+ for (let index = 0; index < lines.length; index += 1) {
118
+ const line = lines[index] ?? "";
119
+ if (lineAllowed(lines, index))
120
+ continue;
121
+ const optionMatch = line.match(/timeout\s*:\s*([0-9_]+)/u);
122
+ if (optionMatch?.[1]) {
123
+ const ms = parseTimeoutMs(optionMatch[1]);
124
+ if (ms !== null && ms >= MIN_ELEVATED_TIMEOUT_MS) {
125
+ hits.push({ line: index + 1, ms });
126
+ continue;
127
+ }
128
+ }
129
+ // Same-line trailing timeout: `}, 30_000)` / `, 30000)`
130
+ const trailingMatch = line.match(/,\s*([0-9_]+)\s*\)\s*;?\s*$/u);
131
+ // Own-line timeout before closing paren: ` 30_000,` / ` 30000`
132
+ const ownLineMatch = line.match(/^\s*([0-9_]+)\s*,?\s*$/u);
133
+ const raw = trailingMatch?.[1] ?? ownLineMatch?.[1];
134
+ if (!raw)
135
+ continue;
136
+ const ms = parseTimeoutMs(raw);
137
+ const windowStart = Math.max(0, index - 16);
138
+ const windowEnd = Math.min(lines.length, index + 3);
139
+ const window = lines.slice(windowStart, windowEnd).join("\n");
140
+ if (ms !== null &&
141
+ ms >= MIN_ELEVATED_TIMEOUT_MS &&
142
+ /\b(?:it|test)(?:\.only|\.each)?\s*\(/u.test(window)) {
143
+ hits.push({ line: index + 1, ms });
144
+ }
145
+ }
146
+ // Deduplicate by line.
147
+ const seen = new Set();
148
+ return hits.filter((hit) => {
149
+ if (seen.has(hit.line))
150
+ return false;
151
+ seen.add(hit.line);
152
+ return true;
153
+ });
154
+ }
155
+ export function findTestScanPerfViolationsInText(relativePath, text) {
156
+ if (!isTestScanPerfTargetPath(relativePath))
157
+ return [];
158
+ if (ALLOW_FILE_PATTERN.test(text))
159
+ return [];
160
+ const violations = [];
161
+ const code = stripComments(stripAllowedLines(text));
162
+ const hasSlowWalk = hasRepoRootContentWalk(code);
163
+ if (hasSlowWalk) {
164
+ violations.push({
165
+ file: relativePath,
166
+ ruleId: "repo-root-recursive-content-scan",
167
+ severity: "error",
168
+ message: `${relativePath}: recursive readdir/readFile content scan from the repo root. ` +
169
+ `Use \`git grep -l\` (tracked files) or a bounded fixture tree instead of walking ` +
170
+ `the whole workspace. Opt out with \`// test-scan-perf-allow\` on the walk site ` +
171
+ `or \`// test-scan-perf-allow-file\` once with justification.`,
172
+ });
173
+ }
174
+ // Elevated timeouts are only flagged when co-located with a repo-root FS walk —
175
+ // that is the "band-aid for a slow scan" pattern. Legitimate long e2e/subprocess
176
+ // timeouts without a root walk are out of scope.
177
+ if (hasSlowWalk) {
178
+ for (const hit of findElevatedTimeoutLines(text)) {
179
+ violations.push({
180
+ file: relativePath,
181
+ line: hit.line,
182
+ ruleId: "timeout-band-aid-for-scan",
183
+ severity: "error",
184
+ message: `${relativePath}:${hit.line}: per-test timeout ${hit.ms}ms co-located with a ` +
185
+ `repo-root recursive FS scan. Fix the scan (git-grep / bounded tree) instead of ` +
186
+ `raising the timeout. Opt out with \`// test-scan-perf-allow\` on that line.`,
187
+ });
188
+ }
189
+ }
190
+ return violations;
191
+ }
192
+ function listTestFilesViaGit(root) {
193
+ try {
194
+ const stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
195
+ encoding: "utf8",
196
+ maxBuffer: 16 * 1024 * 1024,
197
+ });
198
+ return stdout
199
+ .split("\0")
200
+ .map((entry) => entry.trim())
201
+ .filter(Boolean)
202
+ .filter((entry) => isTestScanPerfTargetPath(entry));
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ function walkTestFiles(dir, root, out) {
209
+ let entries;
210
+ try {
211
+ entries = readdirSync(dir);
212
+ }
213
+ catch {
214
+ return;
215
+ }
216
+ for (const entry of entries) {
217
+ if (SKIP_DIRS.has(entry))
218
+ continue;
219
+ const full = join(dir, entry);
220
+ let st;
221
+ try {
222
+ st = statSync(full);
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ if (st.isDirectory()) {
228
+ walkTestFiles(full, root, out);
229
+ continue;
230
+ }
231
+ if (!st.isFile())
232
+ continue;
233
+ const rel = relative(root, full).replaceAll("\\", "/");
234
+ if (isTestScanPerfTargetPath(rel))
235
+ out.push(rel);
236
+ }
237
+ }
238
+ function listTestFiles(root, affectedFiles) {
239
+ if (affectedFiles !== undefined) {
240
+ return affectedFiles
241
+ .map((file) => file.replaceAll("\\", "/"))
242
+ .filter((file) => isTestScanPerfTargetPath(file));
243
+ }
244
+ const fromGit = listTestFilesViaGit(root);
245
+ if (fromGit)
246
+ return fromGit;
247
+ const out = [];
248
+ walkTestFiles(root, root, out);
249
+ return out;
250
+ }
251
+ export function auditTestScanPerf(rootDirectory = process.cwd(), options = {}) {
252
+ const root = rootDirectory;
253
+ if (!existsSync(root)) {
254
+ return { ok: true, title: TITLE, checked: 0, violations: [] };
255
+ }
256
+ const files = listTestFiles(root, options.affectedFiles);
257
+ const violations = [];
258
+ for (const rel of files) {
259
+ const full = join(root, rel);
260
+ let text;
261
+ try {
262
+ text = readFileSync(full, "utf8");
263
+ }
264
+ catch {
265
+ continue;
266
+ }
267
+ violations.push(...findTestScanPerfViolationsInText(rel, text));
268
+ }
269
+ return {
270
+ ok: violations.length === 0,
271
+ title: TITLE,
272
+ checked: files.length,
273
+ violations,
274
+ };
275
+ }
@@ -35,5 +35,14 @@ export interface BlueprintLifecycleResult {
35
35
  progress: string;
36
36
  targetStatus: LifecycleBlueprintStatus;
37
37
  }
38
+ /**
39
+ * Update only the named frontmatter keys, byte-preserving every other line.
40
+ * Review authority binds plan approvals to normalized blueprint bytes
41
+ * (`normalizePlanSubjectContent`), so lifecycle writers must never re-serialize
42
+ * fields they do not own — a whole-document YAML round-trip (quote-style or
43
+ * folding changes to an untouched `title:`) would strand committed approvals
44
+ * mid-lifecycle. Falls back to a gray-matter round-trip only when the document
45
+ * has no frontmatter block at all.
46
+ */
38
47
  export declare function setBlueprintFrontmatterFields(markdown: string, updates: Record<string, string | string[] | undefined>): string;
39
48
  export declare function applyBlueprintLifecycle(markdown: string, slug: string, intent: BlueprintLifecycleIntent): BlueprintLifecycleResult;
@@ -1,4 +1,5 @@
1
1
  import matter from "gray-matter";
2
+ import { stringify as stringifyYaml } from "yaml";
2
3
  import { parseBlueprint } from "#core/parser";
3
4
  import { lifecycleBlueprintStatusSchema } from "#core/schema";
4
5
  import { updateBlockedReason, updateTaskStatus } from "#markdown/helpers";
@@ -6,17 +7,63 @@ import { applyVerification, assertAllTasksHaveCanonicalPassingEvidence } from "#
6
7
  function todayIsoDate() {
7
8
  return new Date().toISOString().split("T")[0] ?? new Date().toISOString();
8
9
  }
10
+ function serializeFrontmatterEntry(key, value) {
11
+ // lineWidth: 0 disables folding so long scalars stay on one line. gray-matter
12
+ // parses frontmatter with js-yaml, which resolves plain date-like scalars
13
+ // ("2026-07-21") into Date objects — quote a string whenever gray-matter
14
+ // would not round-trip its plain form back to the same string.
15
+ const plain = stringifyYaml({ [key]: value }, { lineWidth: 0 }).trimEnd();
16
+ if (typeof value === "string" && matter(`---\n${plain}\n---\n`).data[key] !== value) {
17
+ return stringifyYaml({ [key]: value }, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE", lineWidth: 0 })
18
+ .trimEnd()
19
+ .split("\n");
20
+ }
21
+ return plain.split("\n");
22
+ }
23
+ /**
24
+ * Update only the named frontmatter keys, byte-preserving every other line.
25
+ * Review authority binds plan approvals to normalized blueprint bytes
26
+ * (`normalizePlanSubjectContent`), so lifecycle writers must never re-serialize
27
+ * fields they do not own — a whole-document YAML round-trip (quote-style or
28
+ * folding changes to an untouched `title:`) would strand committed approvals
29
+ * mid-lifecycle. Falls back to a gray-matter round-trip only when the document
30
+ * has no frontmatter block at all.
31
+ */
9
32
  export function setBlueprintFrontmatterFields(markdown, updates) {
10
- const parsed = matter(markdown);
33
+ const match = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/u.exec(markdown);
34
+ if (!match) {
35
+ const parsed = matter(markdown);
36
+ for (const [key, value] of Object.entries(updates)) {
37
+ if (value === undefined || value === "") {
38
+ delete parsed.data[key];
39
+ }
40
+ else {
41
+ parsed.data[key] = value;
42
+ }
43
+ }
44
+ return matter.stringify(parsed.content, parsed.data);
45
+ }
46
+ const lines = (match[2] ?? "").split("\n");
11
47
  for (const [key, value] of Object.entries(updates)) {
12
- if (value === undefined || value === "") {
13
- delete parsed.data[key];
48
+ const keyPattern = new RegExp(String.raw `^${key}:`, "u");
49
+ const start = lines.findIndex((line) => keyPattern.test(line));
50
+ let removeCount = 0;
51
+ if (start !== -1) {
52
+ removeCount = 1;
53
+ while (start + removeCount < lines.length &&
54
+ /^[ \t]/u.test(lines[start + removeCount] ?? "")) {
55
+ removeCount += 1;
56
+ }
57
+ }
58
+ const replacement = value === undefined || value === "" ? [] : serializeFrontmatterEntry(key, value);
59
+ if (start !== -1) {
60
+ lines.splice(start, removeCount, ...replacement);
14
61
  }
15
- else {
16
- parsed.data[key] = value;
62
+ else if (replacement.length > 0) {
63
+ lines.push(...replacement);
17
64
  }
18
65
  }
19
- return matter.stringify(parsed.content, parsed.data);
66
+ return `${match[1]}${lines.join("\n")}${match[3]}${markdown.slice(match[0].length)}`;
20
67
  }
21
68
  function assertExecutableStatus(status) {
22
69
  const parsed = lifecycleBlueprintStatusSchema.safeParse(status);
@@ -36,6 +36,12 @@ export interface TrackedReviewProvenanceOptions {
36
36
  readonly targetHash?: string;
37
37
  }
38
38
  export declare function normalizeTrackedReviewApprovals(approvals: unknown): TrackedReviewApproval[];
39
+ /**
40
+ * Read `_overview.md` for a blueprint at `ref`. Prefer the working-tree path;
41
+ * if missing (lifecycle move draft→planned before the promote commit), resolve
42
+ * by stable slug under any lifecycle directory at that ref.
43
+ */
44
+ export declare function readBlueprintOverviewAtRef(repoRoot: string, ref: string, file: string): Buffer | null;
39
45
  export declare function collectV2ReviewProvenance(file: string, purpose: ReviewEvent["purpose"], ref?: string, issues?: string[]): readonly ValidTrackedReview[];
40
46
  export declare function auditReviewEventAppendOnly(cwd: string, baseRef: string, targetRef?: string): readonly string[];
41
47
  export declare function parseTrackedReviewRecords(markdown: string): TrackedReviewRecord[];