@peterxiaoyang/superspec 0.1.37 → 0.1.39

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 (41) hide show
  1. package/dist/cli.js +45 -6
  2. package/dist/code_review.d.ts +18 -2
  3. package/dist/code_review.js +473 -81
  4. package/dist/format.d.ts +46 -4
  5. package/dist/format.js +311 -26
  6. package/dist/git_state.d.ts +36 -0
  7. package/dist/git_state.js +174 -0
  8. package/dist/job_validity.d.ts +16 -0
  9. package/dist/job_validity.js +37 -0
  10. package/dist/next.js +45 -355
  11. package/dist/phase_plan.d.ts +97 -0
  12. package/dist/phase_plan.js +582 -0
  13. package/dist/record.d.ts +2 -2
  14. package/dist/record.js +67 -31
  15. package/dist/review.d.ts +3 -2
  16. package/dist/review.js +66 -33
  17. package/dist/review_job_gates.d.ts +20 -0
  18. package/dist/review_job_gates.js +85 -0
  19. package/dist/store.d.ts +10 -0
  20. package/dist/store.js +53 -3
  21. package/dist/sync.js +7 -9
  22. package/dist/task.js +87 -9
  23. package/dist/task_evidence.d.ts +10 -0
  24. package/dist/task_evidence.js +126 -0
  25. package/dist/transition.d.ts +1 -1
  26. package/dist/transition.js +449 -337
  27. package/dist/types.d.ts +81 -1
  28. package/dist/workflow_profile.d.ts +11 -0
  29. package/dist/workflow_profile.js +39 -0
  30. package/package.json +1 -1
  31. package/templates/workflow/prompts/architect.md +17 -27
  32. package/templates/workflow/prompts/code-reviewer.md +13 -2
  33. package/templates/workflow/prompts/critic.md +62 -61
  34. package/templates/workflow/prompts/executor.md +1 -1
  35. package/templates/workflow/prompts/explore.md +38 -26
  36. package/templates/workflow/prompts/test-engineer.md +18 -32
  37. package/templates/workflow/prompts/verifier.md +5 -3
  38. package/templates/workflow/skills/superspec-apply/SKILL.md +34 -11
  39. package/templates/workflow/skills/superspec-explore/SKILL.md +65 -64
  40. package/templates/workflow/skills/superspec-propose/SKILL.md +64 -43
  41. package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
@@ -1,92 +1,22 @@
1
1
  // SuperSpec code-reviewer gate helpers.
2
- import { execFileSync } from "node:child_process";
3
- import { existsSync, readdirSync, statSync } from "node:fs";
4
- import { join, extname } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
5
4
  import { findLatestEvent, sha256File, sha256Text } from "./store.js";
5
+ import { REVIEW_CODE_REVIEW_GATE } from "./review_job_gates.js";
6
+ import { codeFileContentSha, currentGitHead, diffFingerprints, dirtyCodePaths, gitLines, isCodeLikePath, projectHasReadableDirectory, walkCodeFiles, } from "./git_state.js";
7
+ import { parseExecutionRequirements, parseTestContractEntries } from "./format.js";
6
8
  export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
7
9
  export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
10
+ export const TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX = "test_coverage_exemption:";
8
11
  export const CODE_REVIEW_DECISION_ANSWER_LABELS = {
9
12
  reopen_propose: "回到计划阶段",
10
13
  reopen_apply: "回到实现阶段",
11
14
  dismiss: "驳回该问题",
12
15
  };
13
- const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
14
- const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
15
- const CODE_EXTENSIONS = new Set([
16
- ".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
17
- ".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
18
- ".swift", ".toml", ".ts", ".tsx", ".yaml", ".yml",
19
- ]);
20
- const CODE_BASENAMES = new Set([
21
- "Dockerfile", "Makefile", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
22
- "tsconfig.json", "tsconfig.build.json", "eslint.config.js", "vite.config.ts", "webpack.config.js",
23
- ]);
24
- const WALK_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".superspec", ".omx"]);
25
- function normalizeGitPath(rawPath) {
26
- const trimmed = rawPath.trim();
27
- const unquoted = trimmed.startsWith('"') && trimmed.endsWith('"')
28
- ? trimmed.slice(1, -1).replace(/\\"/g, '"')
29
- : trimmed;
30
- const renamed = unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
31
- return renamed.replace(/\\/g, "/");
32
- }
33
- function gitChangedPaths(projectRoot) {
34
- try {
35
- const output = execFileSync("git", ["-C", projectRoot, "status", "--porcelain", "--untracked-files=all"], {
36
- encoding: "utf8",
37
- stdio: ["ignore", "pipe", "ignore"],
38
- });
39
- const paths = output
40
- .split("\n")
41
- .map(line => line.trimEnd())
42
- .filter(Boolean)
43
- .map(line => normalizeGitPath(line.slice(3)))
44
- .filter(Boolean);
45
- return { ok: true, paths: [...new Set(paths)].sort() };
46
- }
47
- catch (err) {
48
- return { ok: false, reason: err instanceof Error ? err.message : "git status failed" };
49
- }
50
- }
51
- function isProcessOrOrdinaryDoc(path) {
52
- if (path.startsWith(".superspec/") || path.startsWith(".omx/"))
53
- return true;
54
- if (path.includes("/.superspec/") || path.includes("/.omx/"))
55
- return true;
56
- if (PROCESS_DOC_RE.test(path) || PROCESS_ARTIFACT_RE.test(path))
57
- return true;
58
- return extname(path).toLowerCase() === ".md";
59
- }
60
- export function isCodeLikePath(path) {
61
- const normalized = path.replace(/\\/g, "/");
62
- if (!normalized || isProcessOrOrdinaryDoc(normalized))
63
- return false;
64
- const base = normalized.split("/").pop() ?? normalized;
65
- if (CODE_BASENAMES.has(base))
66
- return true;
67
- return CODE_EXTENSIONS.has(extname(base).toLowerCase());
68
- }
69
- function walkCodeFiles(root, dir = root, out = []) {
70
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
71
- if (entry.isDirectory()) {
72
- if (WALK_SKIP_DIRS.has(entry.name))
73
- continue;
74
- walkCodeFiles(root, join(dir, entry.name), out);
75
- continue;
76
- }
77
- if (!entry.isFile())
78
- continue;
79
- const full = join(dir, entry.name);
80
- const rel = full.slice(root.length + 1).replace(/\\/g, "/");
81
- if (isCodeLikePath(rel))
82
- out.push(rel);
83
- }
84
- return out;
85
- }
86
16
  export function scanCodeChanges(projectRoot) {
87
- const git = gitChangedPaths(projectRoot);
17
+ const git = dirtyCodePaths(projectRoot);
88
18
  if (!git.ok) {
89
- const paths = existsSync(projectRoot) && statSync(projectRoot).isDirectory()
19
+ const paths = projectHasReadableDirectory(projectRoot)
90
20
  ? walkCodeFiles(projectRoot).sort()
91
21
  : [];
92
22
  return {
@@ -104,8 +34,168 @@ export function scanCodeChanges(projectRoot) {
104
34
  reason: paths.length > 0 ? "检测到代码类改动" : "没有代码类改动",
105
35
  };
106
36
  }
37
+ export function currentCodeReviewWorkingPaths(projectRoot, events, extraIgnoredPaths = []) {
38
+ const ignored = new Set(extraIgnoredPaths.map(path => path.replace(/\\/g, "/")));
39
+ for (const path of knownCodeReviewReportPaths(projectRoot, events))
40
+ ignored.add(path);
41
+ return scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path));
42
+ }
43
+ function uniqSorted(paths) {
44
+ return [...new Set(paths)].filter(isCodeLikePath).sort();
45
+ }
46
+ function normalizeKnownPath(path) {
47
+ return path.trim().replace(/\\/g, "/");
48
+ }
49
+ export function knownCodeReviewReportPaths(projectRoot, events) {
50
+ const paths = new Set();
51
+ for (const ev of events) {
52
+ if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
53
+ continue;
54
+ const payload = ev.payload;
55
+ if (payload.role !== "code-reviewer")
56
+ continue;
57
+ if (typeof payload.report_digest !== "string" || payload.report_digest.trim() === "")
58
+ continue;
59
+ if (typeof payload.report_path === "string" && payload.report_path.trim() !== "") {
60
+ const reportPath = normalizeKnownPath(payload.report_path);
61
+ if (sha256File(join(projectRoot, reportPath)) === payload.report_digest) {
62
+ paths.add(reportPath);
63
+ }
64
+ }
65
+ }
66
+ return paths;
67
+ }
68
+ function excludeKnownPaths(paths, ignored) {
69
+ if (ignored.size === 0)
70
+ return paths;
71
+ return paths.filter(path => !ignored.has(path));
72
+ }
73
+ function firstStartApplyHead(events) {
74
+ for (const ev of events) {
75
+ if (ev.event_type !== "transition_commit")
76
+ continue;
77
+ const payload = ev.payload;
78
+ if (payload.transition !== "start-apply")
79
+ continue;
80
+ if (!Object.prototype.hasOwnProperty.call(payload, "apply_start_head")) {
81
+ return { present: false, head: null, reason: "missing_apply_start_head" };
82
+ }
83
+ return {
84
+ present: true,
85
+ head: typeof payload.apply_start_head === "string" ? payload.apply_start_head : null,
86
+ reason: typeof payload.apply_start_head_reason === "string" ? payload.apply_start_head_reason : "missing_apply_start_head",
87
+ };
88
+ }
89
+ return { present: false, head: null, reason: "没有 apply_start_head" };
90
+ }
91
+ function latestReviewedHead(events) {
92
+ for (let i = events.length - 1; i >= 0; i--) {
93
+ const ev = events[i];
94
+ if (ev.event_type !== "transition_commit")
95
+ continue;
96
+ const payload = ev.payload;
97
+ const gate = payload.code_review_gate;
98
+ if (!gate)
99
+ continue;
100
+ if (gate.decision === "passed") {
101
+ if (typeof gate.current_head !== "string" || gate.current_head.trim() === "")
102
+ continue;
103
+ return { present: true, head: gate.current_head };
104
+ }
105
+ if (gate.decision === "skipped") {
106
+ if (typeof gate.head !== "string" || gate.head.trim() === "")
107
+ continue;
108
+ return { present: true, head: gate.head };
109
+ }
110
+ }
111
+ return { present: false, head: null };
112
+ }
113
+ export function selectCodeReviewBase(events) {
114
+ const reviewed = latestReviewedHead(events);
115
+ if (reviewed.present)
116
+ return { base_head: reviewed.head, kind: "reviewed", reason: "latest_code_review_gate" };
117
+ const firstStart = firstStartApplyHead(events);
118
+ if (!firstStart.present)
119
+ return { base_head: null, kind: "history_missing", reason: firstStart.reason };
120
+ if (firstStart.head == null)
121
+ return { base_head: null, kind: "empty_tree", reason: firstStart.reason };
122
+ return { base_head: firstStart.head, kind: "start_apply", reason: "first_start_apply" };
123
+ }
124
+ function scanCodeReviewScopeFromBase(projectRoot, base, ignoredPaths = new Set()) {
125
+ const currentHead = currentGitHead(projectRoot);
126
+ let scopeReliable = true;
127
+ let scopeReason = base.kind === "history_missing" ? base.reason : "ok";
128
+ let committedPaths = [];
129
+ if (base.kind !== "history_missing") {
130
+ if (base.base_head && currentHead.head) {
131
+ const diff = gitLines(projectRoot, ["diff", "--name-only", `${base.base_head}..HEAD`]);
132
+ if (diff.ok)
133
+ committedPaths = excludeKnownPaths(uniqSorted(diff.lines), ignoredPaths);
134
+ else {
135
+ scopeReliable = false;
136
+ scopeReason = `git diff ${base.base_head}..HEAD failed: ${diff.reason}`;
137
+ committedPaths = null;
138
+ }
139
+ }
140
+ else if (base.kind === "empty_tree" && currentHead.head) {
141
+ const tree = gitLines(projectRoot, ["ls-tree", "-r", "--name-only", "HEAD"]);
142
+ if (tree.ok)
143
+ committedPaths = excludeKnownPaths(uniqSorted(tree.lines), ignoredPaths);
144
+ else {
145
+ scopeReliable = false;
146
+ scopeReason = `git ls-tree HEAD failed: ${tree.reason}`;
147
+ committedPaths = null;
148
+ }
149
+ }
150
+ }
151
+ const staged = gitLines(projectRoot, ["diff", "--name-only", "--cached"]);
152
+ const unstaged = gitLines(projectRoot, ["diff", "--name-only"]);
153
+ const untracked = gitLines(projectRoot, ["ls-files", "--others", "--exclude-standard"]);
154
+ if (!staged.ok || !unstaged.ok || !untracked.ok) {
155
+ scopeReliable = false;
156
+ scopeReason = [scopeReason, !staged.ok ? staged.reason : "", !unstaged.ok ? unstaged.reason : "", !untracked.ok ? untracked.reason : ""]
157
+ .filter(Boolean)
158
+ .join("; ");
159
+ }
160
+ const worktreePaths = excludeKnownPaths(uniqSorted([...(staged.ok ? staged.lines : []), ...(unstaged.ok ? unstaged.lines : [])]), ignoredPaths);
161
+ const untrackedPaths = excludeKnownPaths(uniqSorted(untracked.ok ? untracked.lines : []), ignoredPaths);
162
+ const fallbackPaths = projectHasReadableDirectory(projectRoot)
163
+ ? excludeKnownPaths(walkCodeFiles(projectRoot).sort(), ignoredPaths)
164
+ : [];
165
+ const reviewPaths = committedPaths == null || !scopeReliable
166
+ ? uniqSorted([...fallbackPaths, ...worktreePaths, ...untrackedPaths])
167
+ : uniqSorted([...committedPaths, ...worktreePaths, ...untrackedPaths]);
168
+ return {
169
+ base_head: base.base_head,
170
+ current_head: currentHead.head,
171
+ scope_reliable: scopeReliable,
172
+ scope_reason: scopeReason || base.reason,
173
+ committed_paths: committedPaths,
174
+ worktree_paths: worktreePaths,
175
+ untracked_paths: untrackedPaths,
176
+ review_paths: reviewPaths,
177
+ };
178
+ }
179
+ export function scanCodeReviewScope(projectRoot, events) {
180
+ return scanCodeReviewScopeFromBase(projectRoot, selectCodeReviewBase(events), knownCodeReviewReportPaths(projectRoot, events));
181
+ }
182
+ export function scanCodeChangesForReview(projectRoot, events) {
183
+ const scope = scanCodeReviewScope(projectRoot, events);
184
+ const hasCodeChanges = !scope.scope_reliable ||
185
+ scope.committed_paths == null ||
186
+ scope.committed_paths.length > 0 ||
187
+ scope.worktree_paths.length > 0 ||
188
+ scope.untracked_paths.length > 0;
189
+ return {
190
+ reliable: scope.scope_reliable,
191
+ hasCodeChanges,
192
+ paths: scope.review_paths,
193
+ reason: hasCodeChanges ? "检测到代码类改动" : "没有代码类改动",
194
+ scope,
195
+ };
196
+ }
107
197
  export function codeReviewBoundFiles(projectRoot, paths) {
108
- return paths.map(path => ({ path, sha: sha256File(join(projectRoot, path)) ?? "sha256:missing" }));
198
+ return paths.map(path => ({ path, sha: codeFileContentSha(projectRoot, path) ?? "sha256:missing" }));
109
199
  }
110
200
  function samePathSet(left, right) {
111
201
  const a = [...new Set(left)].sort();
@@ -115,13 +205,32 @@ function samePathSet(left, right) {
115
205
  export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
116
206
  if (!isCodeReviewerJob(job))
117
207
  return null;
208
+ const frozenScope = job.packet_context?.code_review_scope;
209
+ if (frozenScope) {
210
+ const currentHead = currentGitHead(projectRoot);
211
+ if (frozenScope.current_head !== currentHead.head) {
212
+ return `代码审查创建后的 HEAD 已变化(原记录:${frozenScope.current_head ?? "<none>"};当前:${currentHead.head ?? "<none>"})`;
213
+ }
214
+ const currentWorkingPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
215
+ const frozenWorkingPaths = uniqSorted([...frozenScope.worktree_paths, ...frozenScope.untracked_paths]);
216
+ if (!samePathSet(frozenWorkingPaths, currentWorkingPaths)) {
217
+ return `代码审查范围已变化:工作区范围已变化(原范围:${frozenWorkingPaths.join(", ") || "<none>"};当前范围:${currentWorkingPaths.join(", ") || "<none>"})`;
218
+ }
219
+ for (const bound of job.boundFiles) {
220
+ const currentSha = codeFileContentSha(projectRoot, bound.path) ?? "sha256:missing";
221
+ if (currentSha !== bound.sha) {
222
+ return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
223
+ }
224
+ }
225
+ return null;
226
+ }
118
227
  const scanPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
119
228
  const boundPaths = job.boundFiles.map(file => file.path);
120
229
  if (!samePathSet(boundPaths, scanPaths)) {
121
230
  return `代码审查范围已变化(原范围:${boundPaths.join(", ") || "<none>"};当前范围:${scanPaths.join(", ") || "<none>"})`;
122
231
  }
123
232
  for (const bound of job.boundFiles) {
124
- const currentSha = sha256File(join(projectRoot, bound.path)) ?? "sha256:missing";
233
+ const currentSha = codeFileContentSha(projectRoot, bound.path) ?? "sha256:missing";
125
234
  if (currentSha !== bound.sha) {
126
235
  return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
127
236
  }
@@ -131,8 +240,198 @@ export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
131
240
  export function codeReviewPacketDigest(input) {
132
241
  return sha256Text(JSON.stringify(input));
133
242
  }
243
+ export function codeReviewPacketContext(changeRoot, projectRoot, scope, events) {
244
+ const taskExecutionIndex = taskExecutionIndexFromEvents(projectRoot, events);
245
+ // changed_paths 未知(快照缺失)或不完整(committed 段 diff 失败)的 task
246
+ // 都进入 unknown_attribution_tasks,提示 code-reviewer 扩大对照范围
247
+ const unknownAttributionTasks = taskExecutionIndex
248
+ .filter(item => item.changed_paths == null || item.changed_paths_partial_reason != null)
249
+ .map(item => item.task_id)
250
+ .sort();
251
+ const attributedPaths = new Set();
252
+ for (const item of taskExecutionIndex) {
253
+ for (const path of item.changed_paths ?? [])
254
+ attributedPaths.add(path);
255
+ }
256
+ return {
257
+ code_review_scope: scope,
258
+ coverage_exemption_refs: coverageExemptionRefs(changeRoot, events),
259
+ task_execution_index: taskExecutionIndex,
260
+ unattributed_paths: scope.review_paths.filter(path => !attributedPaths.has(path)).sort(),
261
+ unknown_attribution_tasks: unknownAttributionTasks,
262
+ };
263
+ }
264
+ export function effectiveCoverageExemptionRefsFromEvents(events) {
265
+ const latest = new Map();
266
+ for (const ev of events) {
267
+ if (ev.event_type !== "user_decision_recorded")
268
+ continue;
269
+ const payload = ev.payload;
270
+ if (payload.accepted === false)
271
+ continue;
272
+ if (typeof payload.scope !== "string" || !payload.scope.startsWith(TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX))
273
+ continue;
274
+ const testId = payload.scope.slice(TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX.length);
275
+ if (!/^TEST-[A-Za-z0-9_-]+$/.test(testId))
276
+ continue;
277
+ if (typeof payload.answer !== "string" || payload.answer.trim() === "")
278
+ continue;
279
+ latest.set(testId, {
280
+ test_id: testId,
281
+ event_id: ev.event_id,
282
+ event_digest: ev.event_digest,
283
+ answer: payload.answer.trim(),
284
+ });
285
+ }
286
+ return [...latest.values()].sort((a, b) => a.test_id.localeCompare(b.test_id));
287
+ }
288
+ function currentTaskDeclaredTestIds(changeRoot) {
289
+ const tasksPath = join(changeRoot, "tasks.md");
290
+ if (!existsSync(tasksPath))
291
+ return new Set();
292
+ return new Set(parseExecutionRequirements(readFileSync(tasksPath, "utf8")).flatMap(item => item.contract.tests));
293
+ }
294
+ function currentTestContractIds(changeRoot) {
295
+ const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
296
+ if (!existsSync(testContractPath))
297
+ return [];
298
+ const parsed = parseTestContractEntries(readFileSync(testContractPath, "utf8"));
299
+ return parsed.ok ? parsed.entries.map(entry => entry.test_id).sort() : [];
300
+ }
301
+ export function missingCoverageExemptionTestIds(changeRoot, events) {
302
+ const declared = currentTaskDeclaredTestIds(changeRoot);
303
+ const effective = new Set(effectiveCoverageExemptionRefsFromEvents(events).map(ref => ref.test_id));
304
+ return currentTestContractIds(changeRoot)
305
+ .filter(testId => !declared.has(testId) && !effective.has(testId))
306
+ .sort();
307
+ }
308
+ function coverageExemptionRefs(changeRoot, events) {
309
+ const declared = currentTaskDeclaredTestIds(changeRoot);
310
+ const unbound = new Set(currentTestContractIds(changeRoot).filter(testId => !declared.has(testId)));
311
+ return effectiveCoverageExemptionRefsFromEvents(events)
312
+ .filter(ref => unbound.has(ref.test_id))
313
+ .sort((a, b) => a.test_id.localeCompare(b.test_id));
314
+ }
315
+ function boundaryFromPayload(payload) {
316
+ const boundary = payload.boundary_snapshot;
317
+ if (!boundary || typeof boundary !== "object" || Array.isArray(boundary))
318
+ return null;
319
+ const obj = boundary;
320
+ if (!Array.isArray(obj.dirty_files))
321
+ return null;
322
+ if (typeof obj.dirty_files_reason === "string")
323
+ return null;
324
+ return {
325
+ head: typeof obj.head === "string" ? obj.head : null,
326
+ ...(typeof obj.head_reason === "string" ? { head_reason: obj.head_reason } : {}),
327
+ dirty_files: obj.dirty_files,
328
+ };
329
+ }
330
+ function changedPathsBetweenSnapshots(projectRoot, start, completed) {
331
+ if (!start || !completed)
332
+ return null;
333
+ // 两端 dirty_files 用共享指纹原语对比,再过滤出代码类路径
334
+ const changed = new Set(diffFingerprints(start.dirty_files, completed.dirty_files).filter(isCodeLikePath));
335
+ let partialReason = null;
336
+ if (start.head && completed.head && start.head !== completed.head) {
337
+ const diff = gitLines(projectRoot, ["diff", "--name-only", `${start.head}..${completed.head}`]);
338
+ if (diff.ok) {
339
+ // 方案要求 committed 段只收代码文件,uniqSorted 内含 isCodeLikePath 过滤
340
+ for (const path of uniqSorted(diff.lines))
341
+ changed.add(path);
342
+ }
343
+ else {
344
+ // diff 失败不丢弃 dirty 侧的确定事实,只标记 committed 段缺失
345
+ partialReason = `git diff ${start.head}..${completed.head} failed: ${diff.reason}`;
346
+ }
347
+ }
348
+ return { paths: [...changed].sort(), partial_reason: partialReason };
349
+ }
350
+ function testEvidenceForAttempt(events, attempt) {
351
+ const declaredTests = attempt.contract_mode === true ? attempt.contract?.tests ?? [] : [];
352
+ const eventsByTest = new Map();
353
+ for (const ev of events) {
354
+ if (ev.event_type !== "test_run_recorded")
355
+ continue;
356
+ const payload = ev.payload;
357
+ if (payload.attempt_id !== attempt.attempt_id || typeof payload.test_id !== "string")
358
+ continue;
359
+ if (declaredTests.length > 0 && !declaredTests.includes(payload.test_id))
360
+ continue;
361
+ const list = eventsByTest.get(payload.test_id) ?? [];
362
+ list.push(ev);
363
+ eventsByTest.set(payload.test_id, list);
364
+ }
365
+ const testIds = declaredTests.length > 0 ? declaredTests : [...eventsByTest.keys()].sort();
366
+ const evidence = [];
367
+ for (const testId of testIds) {
368
+ let red = null;
369
+ let green = null;
370
+ let pairedRed = null;
371
+ for (const ev of eventsByTest.get(testId) ?? []) {
372
+ const payload = ev.payload;
373
+ if (payload.semantic_status === "expected_failure" && typeof payload.exit_code === "number" && payload.exit_code !== 0) {
374
+ if (!red)
375
+ red = ev;
376
+ continue;
377
+ }
378
+ const isGreen = (payload.semantic_status === "expected_success" || payload.semantic_status === "characterization_pass") && payload.exit_code === 0;
379
+ if (isGreen && !green) {
380
+ green = ev;
381
+ pairedRed = red;
382
+ }
383
+ }
384
+ if (!green && !pairedRed)
385
+ continue;
386
+ evidence.push({
387
+ test_id: testId,
388
+ ...(pairedRed ? { red_event_ref: pairedRed.event_id, red_event_digest: pairedRed.event_digest } : {}),
389
+ ...(green ? { green_event_ref: green.event_id, green_event_digest: green.event_digest } : {}),
390
+ });
391
+ }
392
+ return evidence.sort((a, b) => String(a.test_id).localeCompare(String(b.test_id)));
393
+ }
394
+ function taskExecutionIndexFromEvents(projectRoot, events) {
395
+ const attempts = new Map();
396
+ const entries = [];
397
+ for (const ev of events) {
398
+ if (ev.event_type === "task_started") {
399
+ const attempt = ev.payload;
400
+ if (typeof attempt.attempt_id === "string") {
401
+ attempts.set(attempt.attempt_id, {
402
+ attempt,
403
+ boundary: boundaryFromPayload(ev.payload),
404
+ });
405
+ }
406
+ }
407
+ else if (ev.event_type === "task_completed") {
408
+ const payload = ev.payload;
409
+ if (typeof payload.task_id !== "string" || typeof payload.attempt_id !== "string")
410
+ continue;
411
+ const started = attempts.get(payload.attempt_id);
412
+ const attempt = started?.attempt;
413
+ const effectiveContract = attempt?.contract_mode === true ? attempt.contract ?? null : null;
414
+ const changedResult = changedPathsBetweenSnapshots(projectRoot, started?.boundary ?? null, boundaryFromPayload(ev.payload));
415
+ entries.push({
416
+ task_id: payload.task_id,
417
+ attempt_id: payload.attempt_id,
418
+ changed_paths: changedResult ? changedResult.paths : null,
419
+ ...(changedResult?.partial_reason ? { changed_paths_partial_reason: changedResult.partial_reason } : {}),
420
+ contract: effectiveContract,
421
+ declared_tests: effectiveContract?.tests ?? [],
422
+ scope_note: payload.scope_note && typeof payload.scope_note === "object" && !Array.isArray(payload.scope_note)
423
+ ? payload.scope_note
424
+ : null,
425
+ test_evidence: attempt ? testEvidenceForAttempt(events, attempt) : [],
426
+ task_completed_event_ref: ev.event_id,
427
+ });
428
+ }
429
+ }
430
+ entries.sort((a, b) => a.task_id.localeCompare(b.task_id) || a.attempt_id.localeCompare(b.attempt_id));
431
+ return entries;
432
+ }
134
433
  function isCodeReviewerJob(job) {
135
- return job.role === "code-reviewer" && job.created_from_transition === "review-ready";
434
+ return job.role === "code-reviewer" && REVIEW_CODE_REVIEW_GATE.isJobForGate(job);
136
435
  }
137
436
  function codeReviewResultKind(value) {
138
437
  return value === "invalid_report" || value === "non_actionable_report" || value === "review_failed"
@@ -321,3 +620,96 @@ export function latestApplyDoneToReviewGate(events) {
321
620
  export function requiresFinalVerifierForCurrentReview(events) {
322
621
  return latestApplyDoneToReviewGate(events) != null;
323
622
  }
623
+ function findJobInEvents(events, jobId) {
624
+ for (const ev of events) {
625
+ if (ev.event_type !== "transition_commit")
626
+ continue;
627
+ const jobs = ev.payload.new_jobs ?? [];
628
+ const job = jobs.find(item => item.job_id === jobId);
629
+ if (job)
630
+ return job;
631
+ }
632
+ return null;
633
+ }
634
+ function latestApplyDoneToReviewGatePayload(events) {
635
+ for (let i = events.length - 1; i >= 0; i--) {
636
+ const ev = events[i];
637
+ if (ev.event_type !== "transition_commit")
638
+ continue;
639
+ const payload = ev.payload;
640
+ if (payload.transition !== "review-ready" || payload.from_state !== "apply_done" || payload.to_state !== "review")
641
+ continue;
642
+ const gate = payload.code_review_gate;
643
+ if (!gate || (gate.decision !== "passed" && gate.decision !== "skipped"))
644
+ return null;
645
+ return {
646
+ decision: gate.decision,
647
+ ...(typeof gate.job_id === "string" ? { job_id: gate.job_id } : {}),
648
+ ...(typeof gate.current_head === "string" || gate.current_head === null ? { current_head: gate.current_head } : {}),
649
+ ...(typeof gate.head === "string" || gate.head === null ? { head: gate.head } : {}),
650
+ };
651
+ }
652
+ return null;
653
+ }
654
+ export function computeCodeStateCheck(projectRoot, events, ignoredCodePaths = []) {
655
+ const gate = latestApplyDoneToReviewGatePayload(events);
656
+ const currentHead = currentGitHead(projectRoot);
657
+ const baselineHead = gate?.decision === "passed" ? gate.current_head ?? null : gate?.head ?? null;
658
+ const changed = new Set();
659
+ const ignored = new Set(ignoredCodePaths);
660
+ const reviewedJob = gate?.decision === "passed" && gate.job_id ? findJobInEvents(events, gate.job_id) : null;
661
+ for (const ev of events) {
662
+ if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
663
+ continue;
664
+ const payload = ev.payload;
665
+ if (typeof payload.report_path === "string")
666
+ ignored.add(payload.report_path);
667
+ }
668
+ let scopeReason = gate ? "ok" : "missing_code_review_gate";
669
+ if (baselineHead && currentHead.head && baselineHead !== currentHead.head) {
670
+ const diff = gitLines(projectRoot, ["diff", "--name-only", `${baselineHead}..HEAD`]);
671
+ if (diff.ok) {
672
+ for (const path of uniqSorted(diff.lines))
673
+ changed.add(path);
674
+ }
675
+ else {
676
+ scopeReason = `git diff ${baselineHead}..HEAD failed: ${diff.reason}`;
677
+ }
678
+ }
679
+ else if (!baselineHead && gate && currentHead.head) {
680
+ const tree = gitLines(projectRoot, ["ls-tree", "-r", "--name-only", "HEAD"]);
681
+ if (tree.ok) {
682
+ for (const path of uniqSorted(tree.lines))
683
+ changed.add(path);
684
+ }
685
+ else {
686
+ scopeReason = `git ls-tree HEAD failed: ${tree.reason}`;
687
+ }
688
+ }
689
+ // bound 基线与当前磁盘状态共用 {path,status,sha256} 指纹原语对比:
690
+ // 基线是 code review 时点的 bound 文件指纹;当前侧取 bound 路径与脏代码文件的并集。
691
+ // gate skipped 时基线为空,任何脏代码文件都会作为差异列出。
692
+ const baselineFingerprints = (reviewedJob?.boundFiles ?? [])
693
+ .map(bound => ({ path: bound.path, status: "modified", sha256: bound.sha }));
694
+ const baselinePaths = new Set(baselineFingerprints.map(file => file.path));
695
+ // ignored(审查报告文件等)只豁免 bound 集合之外的新脏文件,bound 文件本身的变化仍需暴露
696
+ const currentPaths = new Set([
697
+ ...baselinePaths,
698
+ ...scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path)),
699
+ ]);
700
+ // 缺失文件用与 bound 基线一致的 "sha256:missing" 占位:review 时点就缺失、现在仍缺失的文件不算差异
701
+ const currentFingerprints = [...currentPaths].map(path => ({
702
+ path,
703
+ status: "modified",
704
+ sha256: codeFileContentSha(projectRoot, path) ?? "sha256:missing",
705
+ }));
706
+ for (const path of diffFingerprints(baselineFingerprints, currentFingerprints))
707
+ changed.add(path);
708
+ return {
709
+ baseline_head: baselineHead,
710
+ current_head: currentHead.head,
711
+ head_matches: baselineHead === currentHead.head,
712
+ changed_paths: [...changed].filter(isCodeLikePath).sort(),
713
+ scope_reason: scopeReason,
714
+ };
715
+ }
package/dist/format.d.ts CHANGED
@@ -1,5 +1,14 @@
1
+ import type { ExecutionContract } from "./types.ts";
1
2
  /** 从 discovery.md 提取"待确认问题"段内的未确认项数量 */
2
3
  export declare function countDiscoveryOpenQuestions(content: string): number;
4
+ export interface DiscoveryChainCoverageCheck {
5
+ ok: boolean;
6
+ message: string;
7
+ present: boolean;
8
+ }
9
+ export declare function splitMarkdownTableRow(line: string): string[];
10
+ /** 轻量校验 discovery.md 的链路五要素段。只校验结构和阻塞未知,不判断业务真假。 */
11
+ export declare function validateDiscoveryChainCoverage(content: string): DiscoveryChainCoverageCheck;
3
12
  /** 完整校验 discovery.md:存在 + 非空 + 无未确认问题 */
4
13
  export declare function validateDiscovery(changeRoot: string): {
5
14
  ok: boolean;
@@ -22,18 +31,51 @@ export interface ParsedTask {
22
31
  tddRequired: boolean;
23
32
  noTddReason: string | null;
24
33
  }
34
+ export interface ParsedExecutionRequirement {
35
+ taskId: string;
36
+ lineIdx: number;
37
+ contract: ExecutionContract;
38
+ errors: string[];
39
+ }
40
+ export interface TestContractEntry {
41
+ test_id: string;
42
+ scenario: string;
43
+ invariant: string;
44
+ }
45
+ export type TestContractParseResult = {
46
+ ok: true;
47
+ entries: TestContractEntry[];
48
+ } | {
49
+ ok: false;
50
+ entries: [];
51
+ message: string;
52
+ };
25
53
  /** 解析 tasks.md 的全部任务行 */
26
54
  export declare function parseTasksMd(content: string): ParsedTask[];
55
+ export declare function hasTaskBoundExecutionRequirements(content: string): boolean;
56
+ export declare function parseExecutionRequirements(content: string): ParsedExecutionRequirement[];
57
+ export declare function orphanExecutionRequirementErrors(content: string): string[];
58
+ export declare function executionRequirementForTask(content: string, taskId: string): ParsedExecutionRequirement | null;
59
+ export declare function adoptedContractForTask(content: string, taskId: string, contractMode: boolean): {
60
+ parsed: ParsedExecutionRequirement | null;
61
+ contract: ExecutionContract | null;
62
+ };
63
+ export declare function isReviewFixTaskId(taskId: string): boolean;
64
+ export declare function isCharacterizationTask(task: ParsedTask): boolean;
65
+ export declare function parseTestContractEntries(content: string): TestContractParseResult;
66
+ export interface ExecutionRequirementValidation {
67
+ ok: boolean;
68
+ mode: boolean;
69
+ contracts: ParsedExecutionRequirement[];
70
+ errors: string[];
71
+ }
72
+ export declare function validateExecutionRequirements(content: string, testContractContent: string | null): ExecutionRequirementValidation;
27
73
  /** 返回未完成任务 */
28
74
  export declare function pendingTasksInContent(content: string): ParsedTask[];
29
75
  /** 在 tasks.md 中按 taskId 精确查找任务(词边界,不误判子串) */
30
76
  export declare function findTaskInLines(lines: string[], taskId: string): number;
31
77
  /** tasks.md 结构指纹(复选框归一化) */
32
78
  export declare function tasksStructureDigest(content: string, sha256Text: (s: string) => string): string;
33
- export declare function validateTestRunInput(tr: Record<string, unknown>): {
34
- ok: boolean;
35
- message: string;
36
- };
37
79
  export declare function validateUserDecision(d: Record<string, unknown>): {
38
80
  ok: boolean;
39
81
  message: string;