@webpresso/agent-kit 3.1.28 → 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 (98) hide show
  1. package/catalog/AGENTS.md.tpl +4 -4
  2. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  3. package/catalog/agent/rules/ci-test-perf.md +5 -1
  4. package/catalog/agent/rules/cmd-execution.md +9 -0
  5. package/catalog/agent/rules/pre-implementation.md +2 -1
  6. package/catalog/agent/rules/test-scan-perf.md +80 -0
  7. package/catalog/agent/rules/workflow-skills-routing.md +4 -2
  8. package/catalog/agent/skills/fix/SKILL.md +3 -0
  9. package/catalog/agent/skills/verify/SKILL.md +18 -0
  10. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +3 -0
  13. package/dist/esm/audit/roadmap-links.js +6 -0
  14. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  15. package/dist/esm/audit/test-scan-perf.js +275 -0
  16. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  17. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  18. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  20. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  21. package/dist/esm/ci/act-replay.js +110 -7
  22. package/dist/esm/ci/act-runner.d.ts +4 -0
  23. package/dist/esm/ci/act-runner.js +85 -26
  24. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  25. package/dist/esm/ci/act-worktree-git.js +219 -0
  26. package/dist/esm/cli/commands/audit.js +3 -0
  27. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  28. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  29. package/dist/esm/cli/commands/ci.d.ts +8 -1
  30. package/dist/esm/cli/commands/ci.js +80 -21
  31. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  32. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  33. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  34. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  35. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  36. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  37. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  38. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  39. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  40. package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
  41. package/dist/esm/cli/commands/dash/plan-once.js +41 -4
  42. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  43. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  44. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  45. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  46. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  47. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  48. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  49. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  50. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  51. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  52. package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
  53. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  54. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  55. package/dist/esm/cli/commands/format.js +0 -2
  56. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  57. package/dist/esm/cli/commands/init/config.js +1 -4
  58. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  59. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  60. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  63. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  64. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  65. package/dist/esm/cli/commands/logs.d.ts +15 -0
  66. package/dist/esm/cli/commands/logs.js +192 -3
  67. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  68. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  69. package/dist/esm/cli/commands/review.js +5 -8
  70. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  71. package/dist/esm/cli/commands/secrets.js +4 -1
  72. package/dist/esm/cli/direct-provider-launch.js +25 -1
  73. package/dist/esm/hooks/doctor.d.ts +15 -0
  74. package/dist/esm/hooks/doctor.js +56 -0
  75. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  76. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  77. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  78. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  79. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  80. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  81. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  82. package/dist/esm/mcp/tools/audit.js +10 -0
  83. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  84. package/dist/esm/mcp/tools/ci-act.js +9 -11
  85. package/dist/esm/paths/state-root.js +3 -0
  86. package/dist/esm/review/authority.js +17 -2
  87. package/dist/esm/review/availability.js +18 -5
  88. package/dist/esm/review/subject.d.ts +15 -0
  89. package/dist/esm/review/subject.js +105 -10
  90. package/dist/esm/runtime/executor.d.ts +1 -0
  91. package/dist/esm/runtime/executor.js +2 -1
  92. package/dist/esm/secret-gate/runner.d.ts +1 -0
  93. package/dist/esm/secret-gate/runner.js +4 -1
  94. package/dist/esm/worktrees/current.d.ts +1 -0
  95. package/dist/esm/worktrees/current.js +1 -0
  96. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  97. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  98. package/package.json +14 -14
@@ -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[];
@@ -1,9 +1,9 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { readReviewAuthorityAtRef } from "#review/authority.js";
5
- import { parseReviewEventLog } from "#review/events.js";
6
- import { normalizePlanSubjectContent } from "#review/subject.js";
4
+ import { readReviewAuthorityAtRef, readWorkingReviewEvents } from "#review/authority.js";
5
+ import { evaluateReviewEvents, parseReviewEventLog } from "#review/events.js";
6
+ import { createPlanSubjectDigestForContent, normalizePlanSubjectContent } from "#review/subject.js";
7
7
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
8
8
  const REVIEW_ENTRY_MARKER = "<!-- wp:review-entry ";
9
9
  function normalizePath(file) {
@@ -52,6 +52,53 @@ function stableSlugForBlueprintFile(file) {
52
52
  }
53
53
  return path.basename(path.dirname(file));
54
54
  }
55
+ /**
56
+ * Read `_overview.md` for a blueprint at `ref`. Prefer the working-tree path;
57
+ * if missing (lifecycle move draft→planned before the promote commit), resolve
58
+ * by stable slug under any lifecycle directory at that ref.
59
+ */
60
+ export function readBlueprintOverviewAtRef(repoRoot, ref, file) {
61
+ const relative = normalizePath(path.relative(repoRoot, file));
62
+ if (relative.startsWith("..") || path.isAbsolute(relative))
63
+ return null;
64
+ try {
65
+ return execFileSync("git", ["show", `${ref}:${relative}`], {
66
+ cwd: repoRoot,
67
+ encoding: "buffer",
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ timeout: 5_000,
70
+ });
71
+ }
72
+ catch {
73
+ // Path may have moved across lifecycle dirs (draft→planned) while the
74
+ // promote commit is still uncommitted. Stable slug is the authority key.
75
+ const slug = stableSlugForBlueprintFile(file);
76
+ if (!slug || slug.includes(".."))
77
+ return null;
78
+ try {
79
+ const names = execFileSync("git", ["ls-tree", "-r", "--name-only", ref], {
80
+ cwd: repoRoot,
81
+ encoding: "utf8",
82
+ stdio: ["ignore", "pipe", "pipe"],
83
+ timeout: 5_000,
84
+ });
85
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
86
+ const pattern = new RegExp(`(?:^|/)(?:draft|planned|parked|in-progress|completed|archived)/${escaped}/_overview\\.md$`, "u");
87
+ const hits = names.split("\n").filter((name) => pattern.test(normalizePath(name)));
88
+ if (hits.length !== 1)
89
+ return null;
90
+ return execFileSync("git", ["show", `${ref}:${hits[0]}`], {
91
+ cwd: repoRoot,
92
+ encoding: "buffer",
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ timeout: 5_000,
95
+ });
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ }
55
102
  function workingContentMatchesRef(repoRoot, file, purpose, ref) {
56
103
  const relative = path.relative(repoRoot, file);
57
104
  if (relative.startsWith("..") || path.isAbsolute(relative))
@@ -63,12 +110,11 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
63
110
  // in the working tree before the promotion commit exists, and that
64
111
  // window must not strip a legitimately promoted blueprint of its plan
65
112
  // authority. Substantive edits still differ after normalization.
66
- const atRef = execFileSync("git", ["show", `${ref}:${normalizePath(relative)}`], {
67
- cwd: repoRoot,
68
- encoding: "buffer",
69
- stdio: ["ignore", "pipe", "pipe"],
70
- timeout: 5_000,
71
- });
113
+ // Also resolve by stable slug when the lifecycle path changed (draft→planned)
114
+ // so pre-commit on a rename does not falsely drop plan authority.
115
+ const atRef = readBlueprintOverviewAtRef(repoRoot, ref, file);
116
+ if (!atRef)
117
+ return false;
72
118
  const working = readFileSync(file);
73
119
  return normalizePlanSubjectContent(working).equals(normalizePlanSubjectContent(atRef));
74
120
  }
@@ -84,27 +130,62 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
84
130
  return false;
85
131
  }
86
132
  }
133
+ // Working-ledger acceptance: pre-commit validates a blueprint whose new review
134
+ // events are staged alongside it and therefore not yet at any git ref. An
135
+ // event still counts only when its subjectDigest matches the digest of the
136
+ // exact working bytes being committed and its artifact hash-verifies on disk —
137
+ // content-addressing keeps this sound before the commit exists.
138
+ function collectWorkingV2ReviewProvenance(file, issues) {
139
+ try {
140
+ const slug = stableSlugForBlueprintFile(file);
141
+ const working = readWorkingReviewEvents(file, slug);
142
+ if (working.events.length === 0)
143
+ return [];
144
+ issues?.push(...working.issues);
145
+ const digest = createPlanSubjectDigestForContent(slug, readFileSync(file));
146
+ const evaluation = evaluateReviewEvents(working.events, {
147
+ purpose: "plan",
148
+ blueprintSlug: slug,
149
+ subjectScheme: "wp-blueprint-v1",
150
+ subjectDigest: digest,
151
+ }, working.compromisedIds);
152
+ return evaluation.approvals
153
+ .filter((event) => event.artifactPath)
154
+ .map((event) => ({
155
+ reviewer: event.reviewer,
156
+ artifact: event.artifactPath,
157
+ targetHash: event.subjectDigest,
158
+ }));
159
+ }
160
+ catch (error) {
161
+ issues?.push(`Working review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
162
+ return [];
163
+ }
164
+ }
87
165
  export function collectV2ReviewProvenance(file, purpose, ref = "HEAD", issues) {
88
166
  const repoRoot = findGitRoot(file);
89
167
  if (!repoRoot)
90
168
  return [];
169
+ const workingApprovals = purpose === "plan" ? collectWorkingV2ReviewProvenance(file, issues) : [];
91
170
  if (!workingContentMatchesRef(repoRoot, file, purpose, ref))
92
- return [];
171
+ return workingApprovals;
93
172
  try {
94
173
  const authority = readReviewAuthorityAtRef(repoRoot, ref, stableSlugForBlueprintFile(file), purpose);
95
174
  issues?.push(...authority.issues);
96
- return authority.approvals.map((event) => ({
175
+ const refApprovals = authority.approvals.map((event) => ({
97
176
  reviewer: event.reviewer,
98
177
  artifact: event.artifactPath,
99
178
  targetHash: event.subjectDigest,
100
179
  }));
180
+ const seen = new Set(refApprovals.map((approval) => approval.reviewer));
181
+ return [...refApprovals, ...workingApprovals.filter((entry) => !seen.has(entry.reviewer))];
101
182
  }
102
183
  catch (error) {
103
184
  // Container-level failure (unparseable ledger, duplicate ids, cross-slug
104
185
  // events, unresolved commit): fail closed for the whole file, but surface
105
186
  // the reason instead of silently reporting zero authority.
106
187
  issues?.push(`Review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
107
- return [];
188
+ return workingApprovals;
108
189
  }
109
190
  }
110
191
  function reviewEventLogsAtRef(cwd, ref) {
@@ -67,10 +67,14 @@ export function updateBlockedReason(content, taskId, reason) {
67
67
  const trimmedReason = reason.trim();
68
68
  const blockedPattern = /\n?\*\*Blocked:\*\*\s*.*(?:\n+)?/i;
69
69
  if (!trimmedReason) {
70
- return section
70
+ // Preserve the section's trailing whitespace: trimEnd on the whole
71
+ // section would also delete the blank line separating this task from the
72
+ // next heading, changing bytes the writer does not own.
73
+ const trailingWhitespace = section.slice(section.trimEnd().length);
74
+ return (section
71
75
  .replace(blockedPattern, "\n")
72
76
  .replace(/\n{3,}/g, "\n\n")
73
- .trimEnd();
77
+ .trimEnd() + trailingWhitespace);
74
78
  }
75
79
  const blockedLine = `**Blocked:** ${trimmedReason}`;
76
80
  if (/\*\*Blocked:\*\*/i.test(section)) {
@@ -31,8 +31,105 @@ export function buildReplayWorkflowSource(sourceYaml, options) {
31
31
  : basename(options.workflowPath);
32
32
  workflow.name = `replay: ${originalName}`;
33
33
  workflow.on = normalizeWorkflowOnSection(workflow.on, options.eventName);
34
+ workflow.jobs = rewriteReplayJobs(workflow.jobs, options.eventName);
34
35
  return stringify(workflow);
35
36
  }
37
+ function rewriteReplayJobs(jobs, eventName) {
38
+ if (typeof jobs !== "object" || jobs === null || Array.isArray(jobs)) {
39
+ return jobs;
40
+ }
41
+ return Object.fromEntries(Object.entries(jobs).map(([jobId, job]) => [
42
+ jobId,
43
+ rewriteReplayJob(job, eventName),
44
+ ]));
45
+ }
46
+ function rewriteReplayJob(job, eventName) {
47
+ if (typeof job !== "object" || job === null || Array.isArray(job)) {
48
+ return job;
49
+ }
50
+ const jobRecord = { ...job };
51
+ if (Array.isArray(jobRecord.steps)) {
52
+ jobRecord.steps = jobRecord.steps.map((step) => rewriteReplayStep(step, eventName));
53
+ }
54
+ return jobRecord;
55
+ }
56
+ function rewriteReplayStep(step, eventName) {
57
+ if (!isRecord(step) || !isSameRepoCheckoutStep(step, eventName)) {
58
+ return step;
59
+ }
60
+ const rewritten = {
61
+ name: typeof step.name === "string" ? step.name : "Use prepared local replay workspace",
62
+ run: [
63
+ 'echo "wp ci act replay: using prepared local workspace instead of actions/checkout"',
64
+ "test -d .git",
65
+ "git rev-parse --is-inside-work-tree",
66
+ "git rev-parse --verify HEAD",
67
+ ].join("\n"),
68
+ "working-directory": "${{ github.workspace }}",
69
+ };
70
+ copyOptionalStepField(step, rewritten, "id");
71
+ copyOptionalStepField(step, rewritten, "if");
72
+ copyOptionalStepField(step, rewritten, "timeout-minutes");
73
+ copyOptionalStepField(step, rewritten, "continue-on-error");
74
+ return rewritten;
75
+ }
76
+ function isSameRepoCheckoutStep(step, eventName) {
77
+ if (typeof step.uses !== "string" || !/^actions\/checkout(?:@|$)/u.test(step.uses)) {
78
+ return false;
79
+ }
80
+ if (step.env !== undefined && step.env !== null) {
81
+ return false;
82
+ }
83
+ const withConfig = step.with;
84
+ if (withConfig === undefined || withConfig === null) {
85
+ return true;
86
+ }
87
+ if (!isRecord(withConfig)) {
88
+ return false;
89
+ }
90
+ if (hasAnyCheckoutInputExcept(withConfig, ["ref"])) {
91
+ return false;
92
+ }
93
+ return isCurrentReplayRef(withConfig.ref, eventName);
94
+ }
95
+ function isCurrentReplayRef(value, eventName) {
96
+ if (value === undefined || value === null || String(value).trim().length === 0) {
97
+ return true;
98
+ }
99
+ const normalized = normalizeGithubExpression(String(value));
100
+ if (normalized === null)
101
+ return false;
102
+ if (normalized === "github.sha") {
103
+ return true;
104
+ }
105
+ if (normalized ===
106
+ "github.event_name=='pull_request'&&github.event.pull_request.base.sha||github.sha") {
107
+ return eventName !== "pull_request";
108
+ }
109
+ return false;
110
+ }
111
+ function normalizeGithubExpression(value) {
112
+ const match = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u.exec(value.trim());
113
+ if (!match?.[1])
114
+ return null;
115
+ return match[1].replace(/"/gu, "'").replace(/\s+/gu, "");
116
+ }
117
+ function hasAnyCheckoutInputExcept(withConfig, allowedKeys) {
118
+ const allowed = new Set(allowedKeys);
119
+ return Object.entries(withConfig).some(([key, value]) => {
120
+ if (allowed.has(key))
121
+ return false;
122
+ return value !== undefined && value !== null && String(value).trim().length > 0;
123
+ });
124
+ }
125
+ function copyOptionalStepField(source, target, field) {
126
+ if (source[field] !== undefined) {
127
+ target[field] = source[field];
128
+ }
129
+ }
130
+ function isRecord(value) {
131
+ return typeof value === "object" && value !== null && !Array.isArray(value);
132
+ }
36
133
  export function createReplayWorkflow(options) {
37
134
  const sourcePath = resolve(options.cwd, options.workflowPath);
38
135
  const sourceYaml = readFileSync(sourcePath, "utf8");
@@ -42,11 +139,17 @@ export function createReplayWorkflow(options) {
42
139
  });
43
140
  const dir = mkdtempSync(join(tmpdir(), "wp-ci-act-replay-"));
44
141
  const workflowPath = join(dir, "workflow.yml");
45
- writeFileSync(workflowPath, generatedYaml, "utf8");
46
- return {
47
- workflowPath,
48
- cleanup() {
49
- rmSync(dir, { recursive: true, force: true });
50
- },
51
- };
142
+ try {
143
+ writeFileSync(workflowPath, generatedYaml, "utf8");
144
+ return {
145
+ workflowPath,
146
+ cleanup() {
147
+ rmSync(dir, { recursive: true, force: true });
148
+ },
149
+ };
150
+ }
151
+ catch (error) {
152
+ rmSync(dir, { recursive: true, force: true });
153
+ throw error;
154
+ }
52
155
  }