@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
package/dist/record.js CHANGED
@@ -2,9 +2,10 @@
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
- import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
- import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, codeReviewJobStaleReason, normalizeCodeReviewDecisionAnswer, scanCodeChanges, } from "./code_review.js";
5
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
6
+ import { invalidReasonForSubmittedReport } from "./job_validity.js";
7
7
  import { jobSubmitArgv } from "./job_action.js";
8
+ import { REVIEW_DOC_PATHS } from "./review.js";
8
9
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
9
10
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
10
11
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -187,6 +188,7 @@ function codeReviewRejectEventPayload(input) {
187
188
  report_digest: input.reportDigest,
188
189
  result_kind: input.resultKind,
189
190
  reason: input.reason,
191
+ ...(input.reportPath ? { report_path: input.reportPath } : {}),
190
192
  ...(input.rawRef ?? {}),
191
193
  };
192
194
  if (input.resultKind === "review_failed" && input.parsedReport) {
@@ -220,22 +222,6 @@ function projectRelativePath(projectRoot, path) {
220
222
  return null;
221
223
  return rel;
222
224
  }
223
- function staleBoundFilesChecks(job, projectRoot, changeRoot, ignoredCodePaths = []) {
224
- const checks = [];
225
- if (job.role === "code-reviewer") {
226
- const ignored = new Set(ignoredCodePaths);
227
- const currentPaths = scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path));
228
- const reason = codeReviewJobStaleReason(projectRoot, job, currentPaths);
229
- return reason ? [reason] : [];
230
- }
231
- for (const bf of job.boundFiles) {
232
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
233
- if (currentSha !== bf.sha) {
234
- checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
235
- }
236
- }
237
- return checks;
238
- }
239
225
  /** 从 events 中查找 job(H4 修复:job 只在 transition_commit 的 new_jobs payload 里) */
240
226
  function findJob(events, jobId) {
241
227
  for (const ev of events) {
@@ -312,12 +298,14 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
312
298
  checks.push("报告必须是有效 JSON");
313
299
  }
314
300
  const reportPath = reportFile ? projectRelativePath(projectRoot, reportFile) : null;
315
- checks.push(...staleBoundFilesChecks(job, projectRoot, changeRoot, reportPath ? [reportPath] : []));
316
- const reviewStaleReason = job.role === "code-reviewer"
317
- ? null
318
- : reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
319
- if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
320
- checks.push(reviewStaleReason);
301
+ const invalidReason = invalidReasonForSubmittedReport(job, {
302
+ projectRoot,
303
+ changeRoot,
304
+ events,
305
+ reportPath,
306
+ });
307
+ if (invalidReason) {
308
+ checks.push(invalidReason);
321
309
  }
322
310
  if (!reportContent.trim()) {
323
311
  checks.push("报告内容为空");
@@ -337,11 +325,13 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
337
325
  reason: checks.join("; "),
338
326
  parsedReport,
339
327
  rawRef,
328
+ reportPath,
340
329
  })
341
330
  : {
342
331
  job_id: jobId,
343
332
  role: job.role,
344
333
  report_digest: reportDigest,
334
+ ...(reportPath ? { report_path: reportPath } : {}),
345
335
  reason: checks.join("; "),
346
336
  }),
347
337
  });
@@ -362,6 +352,8 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
362
352
  parsedReport,
363
353
  rawRef,
364
354
  }));
355
+ if (reportPath)
356
+ rejectEvent.payload.report_path = reportPath;
365
357
  appendEvent(projectRoot, change, rejectEvent);
366
358
  return {
367
359
  event_type: "job_rejected",
@@ -389,6 +381,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
389
381
  reason,
390
382
  parsedReport,
391
383
  rawRef,
384
+ reportPath,
392
385
  })));
393
386
  return {
394
387
  event_type: "job_rejected",
@@ -415,6 +408,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
415
408
  reason,
416
409
  parsedReport,
417
410
  rawRef,
411
+ reportPath,
418
412
  })));
419
413
  return {
420
414
  event_type: "job_rejected",
@@ -430,6 +424,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
430
424
  role: job.role,
431
425
  report_digest: reportDigest,
432
426
  accepted_at: new Date().toISOString(),
427
+ ...(reportPath ? { report_path: reportPath } : {}),
433
428
  ...rawRef,
434
429
  });
435
430
  appendEvent(projectRoot, change, acceptEvent);
@@ -508,7 +503,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
508
503
  reason: "missing_scope_or_answer",
509
504
  input_digest: inputDigest,
510
505
  }));
511
- return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scopeanswer" };
506
+ return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少决策范围(scope)或答复内容(answer" };
512
507
  }
513
508
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
514
509
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
@@ -561,7 +556,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
561
556
  return {
562
557
  event_type: "user_decision_recorded",
563
558
  accepted: true,
564
- message: `用户决策已登记:${decision.scope}`,
559
+ message: `用户决策已登记:决策范围(scope)=${decision.scope}`,
565
560
  };
566
561
  }
567
562
  /** record user-decision:登记用户决策 */
@@ -620,6 +615,31 @@ export function jobsList(projectRoot, change) {
620
615
  }
621
616
  return { open, accepted, rejected };
622
617
  }
618
+ function packetFieldDescriptions() {
619
+ return {
620
+ job_id: "工作项 ID,用于提交本次审查或验证报告。",
621
+ packet_digest: "工作项说明摘要,用于证明报告对应的是当前这份工作项说明。",
622
+ boundFiles: "本工作项绑定的文件清单;代码审查报告必须说明这些文件是否都看过。",
623
+ review_scope: "报告中的审查覆盖范围,说明看了哪些文件、哪些没看及原因。",
624
+ code_review_scope: "代码审查范围:从已审基点到当前 HEAD 的提交改动、工作区改动和未跟踪代码文件。",
625
+ task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、声明测试、测试证据和改动文件。",
626
+ contract: "任务启动时的执行依据快照:tests/design/source/reason/guard 分别对应 测试/设计/来源/原因/边界;null 表示历史任务没有执行依据。",
627
+ changed_paths: "与某个任务(task)或代码状态检查相关的改动文件。",
628
+ changed_paths_partial_reason: "该任务(task)的提交段 diff 失败原因;存在时 changed_paths 只包含工作区对比结果,归属可能不完整。",
629
+ unattributed_paths: "代码审查范围中暂时无法归属到某个任务(task)的文件。",
630
+ unknown_attribution_tasks: "因为缺少边界快照或提交段 diff 失败而无法完整计算改动归属的任务(task)。",
631
+ coverage_exemption_refs: "测试覆盖豁免引用:说明某个 TEST 为什么没有绑定到任务(task)。",
632
+ code_state_check: "代码状态检查:最终验证时用于判断代码审查后代码是否又发生变化。",
633
+ event_id: "事件 ID,用于追溯证据来源。",
634
+ event_digest: "事件摘要,用于确认引用的证据事件没有被替换。",
635
+ attempt_id: "任务尝试 ID;执行依据模式下测试运行必须绑定当前活跃任务尝试。",
636
+ task_structure_digest: "历史模式的任务结构指纹;仅用于兼容旧证据。",
637
+ test_id: "测试契约里的 TEST ID。",
638
+ semantic_status: "测试语义状态:RED 预期失败、GREEN 预期成功或特征化(characterization)通过。",
639
+ covers_task_ids: "回归测试覆盖了哪些已完成任务(task)。",
640
+ scope_note: "范围扩大说明;任务(task)实现超出执行依据边界时填写。",
641
+ };
642
+ }
623
643
  /** jobs packet:返回工作项执行说明 */
624
644
  export function jobsPacket(projectRoot, change, jobId) {
625
645
  const events = readEvents(projectRoot, change);
@@ -628,15 +648,24 @@ export function jobsPacket(projectRoot, change, jobId) {
628
648
  return { found: false, message: `工作项 ${jobId} 不存在` };
629
649
  }
630
650
  const isCodeReviewer = job.role === "code-reviewer";
651
+ const packetContext = job.packet_context;
631
652
  return {
632
653
  found: true,
633
654
  packet: {
634
655
  job_id: job.job_id,
635
656
  role: job.role,
657
+ ...(job.gate_id ? { gate_id: job.gate_id } : {}),
636
658
  recommended_agent: recommendedAgentForRole(job.role),
637
659
  boundFiles: job.boundFiles,
638
660
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
639
661
  ...(job.previous_rejection ? { previous_rejection: job.previous_rejection } : {}),
662
+ ...(packetContext ? { packet_context: packetContext } : {}),
663
+ ...(packetContext?.code_review_scope ? { code_review_scope: packetContext.code_review_scope } : {}),
664
+ ...(packetContext?.coverage_exemption_refs ? { coverage_exemption_refs: packetContext.coverage_exemption_refs } : {}),
665
+ ...(packetContext?.task_execution_index ? { task_execution_index: packetContext.task_execution_index } : {}),
666
+ ...(packetContext?.unattributed_paths ? { unattributed_paths: packetContext.unattributed_paths } : {}),
667
+ ...(packetContext?.unknown_attribution_tasks ? { unknown_attribution_tasks: packetContext.unknown_attribution_tasks } : {}),
668
+ ...(packetContext?.code_state_check ? { code_state_check: packetContext.code_state_check } : {}),
640
669
  packet_digest: job.packet_digest,
641
670
  required_output_kind: "job_report_json",
642
671
  preferred_input_mode: "stdin",
@@ -647,16 +676,23 @@ export function jobsPacket(projectRoot, change, jobId) {
647
676
  ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer", "review_scope"]
648
677
  : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
649
678
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
679
+ 字段说明: packetFieldDescriptions(),
650
680
  output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
651
681
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
652
682
  (job.previous_rejection ? `上一次代码审查没有形成可推进结论,原因:${job.previous_rejection.reason}。本次请根据该原因重新审查,` : "") +
653
- (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在 reviewer.kind/id 中记录来源,` : "") +
654
- `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仍可作为 fallback。以下 JSON 合约给审查代理使用,普通对话不要原样复述。` +
683
+ (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
684
+ `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
655
685
  (isCodeReviewer
656
- ? `最小格式:{"role":"code-reviewer","verdict":"pass|fail","review_scope":{"job_id":"${job.job_id}","packet_digest":"${job.packet_digest}","checked_paths":${JSON.stringify(job.boundFiles.map(f => f.path))},"checked_docs":["proposal.md","design.md","tasks.md",".superspec/artifacts/test-contract.md"],"unchecked":[]},"findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}review_scope 用来说明本次审查覆盖了哪些文件和文档,checked_pathsunchecked 必须合起来覆盖全部 boundFilesunchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
657
- + `报告结论为 fail 时,findings 至少包含一个可处理、可追溯的阻塞问题,字段为 {"id":"<stable-id>","blocking":true,"type":"implementation|spec|mixed","description":"<what>","evidence":"<why>","source_refs":["<path:line>"],"impact":"<impact>","suggested_action":"apply|propose"}type implementation 表示纯代码实现问题,spec 表示方案/需求文档问题,mixed 表示需要使用者判断的混合问题。`
686
+ ? `最小格式:{"role":"code-reviewer","verdict":"pass|fail","review_scope":{"job_id":"${job.job_id}","packet_digest":"${job.packet_digest}","checked_paths":${JSON.stringify(job.boundFiles.map(f => f.path))},"checked_docs":${JSON.stringify(REVIEW_DOC_PATHS)},"unchecked":[]},"findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}};审查覆盖范围(review_scope)用来说明本次审查覆盖了哪些文件和文档,已检查路径(checked_paths)与未检查项(unchecked)必须合起来覆盖全部绑定文件(boundFiles),unchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
687
+ + `报告结论为 fail 时,问题列表(findings)至少包含一个可处理、可追溯的阻塞问题,字段为 {"id":"<stable-id>","blocking":true,"type":"implementation|spec|mixed","description":"<what>","evidence":"<why>","source_refs":["<path:line>"],"impact":"<impact>","suggested_action":"apply|propose"}。问题类型(type)中 implementation 表示纯代码实现问题,spec 表示方案/需求文档问题,mixed 表示需要使用者判断的混合问题。`
688
+ + (packetContext?.task_execution_index
689
+ ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
690
+ : "")
658
691
  : job.role === "verifier"
659
- ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]}。核对代码审查记录 code_review_gatepassed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带 review_fix_of:<job_id>#<problem_id>,方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:同一 task_completed.attempt_id 下必须有 RED/characterization GREEN;test-run 证据应包含 test_idcommandcwdexit_codesemantic_status;审查修复的回归 test-run 可用 covers_task_ids 说明覆盖了哪些已完成任务;缺少 attempt_id 的旧证据只能弱引用。`
692
+ ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]}。核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带审查修复引用(review_fix_of:<job_id>#<problem_id>),方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:证据须在同一已完成任务的任务尝试 ID(task_completed.attempt_id)下闭环——普通 TDD 任务至少一个同 TEST 先 RED(expected_failure)后 GREEN(expected_success)配对且每个声明 TEST 都有 GREEN;特征化任务(no_tdd_reason:characterization)可用 characterization_pass 作为通过证据,不要求 RED;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status);审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
693
+ (packetContext?.code_state_check
694
+ ? `本工作项带代码状态检查(code_state_check):head_matches 为 false 或 changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
695
+ : "")
660
696
  : requiresReviewer(job.role)
661
697
  ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
662
698
  : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
package/dist/review.d.ts CHANGED
@@ -13,5 +13,6 @@ export declare function reviewBoundFiles(changeRoot: string): Ref[];
13
13
  export declare function reviewEvidenceDigest(events: Event[]): string;
14
14
  export declare function boundFilesStaleReason(job: Job, changeRoot: string): string | null;
15
15
  export declare function reviewEvidenceStaleReason(job: Job, currentDigest: string): string | null;
16
- export declare function reviewVerifierStaleReason(job: Job, changeRoot: string, currentEvidenceDigest: string): string | null;
17
- export declare function isFreshReviewVerifier(job: Job, changeRoot: string, currentEvidenceDigest: string): boolean;
16
+ export declare function codeStateCheckStaleReason(job: Job, projectRoot: string | undefined, events: Event[] | undefined, ignoredCodePaths?: string[]): string | null;
17
+ export declare function reviewVerifierStaleReason(job: Job, changeRoot: string, currentEvidenceDigest: string, projectRoot?: string, events?: Event[], ignoredCodePaths?: string[]): string | null;
18
+ export declare function isFreshReviewVerifier(job: Job, changeRoot: string, currentEvidenceDigest: string, projectRoot?: string, events?: Event[]): boolean;
package/dist/review.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // SuperSpec review helpers: policy, verifier freshness, evidence digest
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { sha256File, sha256Text } from "./store.js";
4
+ import { docRef, sha256File, sha256Text } from "./store.js";
5
+ import { REVIEW_FINAL_VERIFIER_GATE } from "./review_job_gates.js";
6
+ import { computeCodeStateCheck, effectiveCoverageExemptionRefsFromEvents } from "./code_review.js";
5
7
  export const REVIEW_DOC_PATHS = [
6
8
  "proposal.md",
7
9
  "tasks.md",
8
10
  "design.md",
11
+ "specs/",
9
12
  ".superspec/artifacts/discovery.md",
10
13
  ".superspec/artifacts/business-invariants.md",
11
14
  ".superspec/artifacts/test-contract.md",
@@ -52,35 +55,24 @@ export function readReviewPolicyFromEvents(events) {
52
55
  return null;
53
56
  }
54
57
  export function isReviewReadyVerifier(job) {
55
- return job.role === "verifier" && job.created_from_transition === "review-ready";
58
+ return job.role === "verifier" && REVIEW_FINAL_VERIFIER_GATE.isJobForGate(job);
56
59
  }
57
60
  export function reviewBoundFiles(changeRoot) {
61
+ // 目录路径(以 / 结尾)始终绑定聚合指纹,与 boundFilesStaleReason 的 docRef 比对保持一致
58
62
  return REVIEW_DOC_PATHS
59
- .filter(path => existsSync(join(changeRoot, path)))
60
- .map(path => ({ path, sha: sha256File(join(changeRoot, path)) ?? "sha256:missing" }));
63
+ .filter(path => path.endsWith("/") || existsSync(join(changeRoot, path)))
64
+ .map(path => docRef(changeRoot, path));
61
65
  }
66
+ // 方案排序键:kind → task_id → test_id → attempt_id → event_id;缺失字段按空字符串。
67
+ // event_id 全局唯一,作为末位排序键足以保证稳定。
62
68
  function evidenceSortKey(record) {
63
- const parts = [
69
+ return [
64
70
  record.kind,
65
71
  record.task_id ?? "",
66
- record.attempt_id ?? "",
67
- record.task_structure_digest ?? "",
68
72
  record.test_id ?? "",
69
- record.semantic_status ?? "",
70
- ];
71
- if (record.covers_task_ids && record.covers_task_ids.length > 0) {
72
- parts.push(`covers:${JSON.stringify(record.covers_task_ids)}`);
73
- }
74
- parts.push(record.command ?? "", record.cwd ?? "", record.exit_code == null ? "" : String(record.exit_code), record.target_fingerprint ?? "", record.event_digest);
75
- return parts.join("\u0000");
76
- }
77
- function normalizedCoveredTaskIds(value) {
78
- if (!Array.isArray(value))
79
- return [];
80
- return [...new Set(value
81
- .filter((item) => typeof item === "string")
82
- .map(item => item.trim())
83
- .filter(Boolean))].sort();
73
+ record.attempt_id ?? "",
74
+ record.event_id ?? "",
75
+ ].join("\u0000");
84
76
  }
85
77
  export function reviewEvidenceDigest(events) {
86
78
  const attemptsById = new Map();
@@ -99,6 +91,7 @@ export function reviewEvidenceDigest(events) {
99
91
  task_id: payload.task_id,
100
92
  attempt_id: payload.attempt_id,
101
93
  task_structure_digest: attempt?.task_structure_digest ?? null,
94
+ event_id: ev.event_id,
102
95
  event_digest: ev.event_digest,
103
96
  });
104
97
  }
@@ -107,11 +100,12 @@ export function reviewEvidenceDigest(events) {
107
100
  const completedStructureDigests = new Set(completed
108
101
  .map(item => item.task_structure_digest)
109
102
  .filter((digest) => typeof digest === "string" && digest.length > 0));
103
+ // scope_note / boundary_snapshot / checkbox_update 属于 task_completed 事件 payload,已由 event_digest 覆盖
110
104
  const records = completed.map(item => ({
111
105
  kind: "task_completed",
112
106
  task_id: item.task_id,
113
107
  attempt_id: item.attempt_id,
114
- task_structure_digest: item.task_structure_digest,
108
+ event_id: item.event_id,
115
109
  event_digest: item.event_digest,
116
110
  }));
117
111
  for (const ev of events) {
@@ -124,18 +118,41 @@ export function reviewEvidenceDigest(events) {
124
118
  const matchesLegacyDigest = attemptId == null && structureDigest != null && completedStructureDigests.has(structureDigest);
125
119
  if (!matchesCompletedAttempt && !matchesLegacyDigest)
126
120
  continue;
127
- const coversTaskIds = normalizedCoveredTaskIds(payload.covers_task_ids);
128
121
  records.push({
129
122
  kind: "test_run_recorded",
130
123
  test_id: typeof payload.test_id === "string" ? payload.test_id : null,
131
124
  attempt_id: attemptId,
132
- task_structure_digest: structureDigest,
133
125
  semantic_status: typeof payload.semantic_status === "string" ? payload.semantic_status : null,
134
- ...(coversTaskIds.length > 0 ? { covers_task_ids: coversTaskIds } : {}),
126
+ exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
135
127
  command: typeof payload.command === "string" ? payload.command : "",
136
128
  cwd: typeof payload.cwd === "string" ? payload.cwd : "",
137
- exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
138
- target_fingerprint: typeof payload.target_fingerprint === "string" ? payload.target_fingerprint : null,
129
+ event_id: ev.event_id,
130
+ event_digest: ev.event_digest,
131
+ });
132
+ }
133
+ for (const ref of effectiveCoverageExemptionRefsFromEvents(events)) {
134
+ records.push({
135
+ kind: "coverage_exemption",
136
+ test_id: ref.test_id,
137
+ event_id: ref.event_id,
138
+ event_digest: ref.event_digest,
139
+ });
140
+ }
141
+ for (const ev of events) {
142
+ if (ev.event_type !== "transition_commit")
143
+ continue;
144
+ const payload = ev.payload;
145
+ if (payload.transition !== "review-ready" || payload.from_state !== "apply_done" || payload.to_state !== "review")
146
+ continue;
147
+ const gate = payload.code_review_gate;
148
+ if (!gate || (gate.decision !== "passed" && gate.decision !== "skipped"))
149
+ continue;
150
+ records.push({
151
+ kind: "code_review_gate",
152
+ decision: gate.decision,
153
+ job_id: typeof gate.job_id === "string" ? gate.job_id : null,
154
+ packet_digest: typeof gate.packet_digest === "string" ? gate.packet_digest : null,
155
+ event_id: ev.event_id,
139
156
  event_digest: ev.event_digest,
140
157
  });
141
158
  }
@@ -144,7 +161,8 @@ export function reviewEvidenceDigest(events) {
144
161
  }
145
162
  export function boundFilesStaleReason(job, changeRoot) {
146
163
  for (const bf of job.boundFiles) {
147
- const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
164
+ // 目录绑定(path / 结尾)比对聚合指纹,覆盖目录内文件的增/删/改
165
+ const current = docRef(changeRoot, bf.path).sha;
148
166
  if (current !== bf.sha) {
149
167
  return `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`;
150
168
  }
@@ -161,9 +179,24 @@ export function reviewEvidenceStaleReason(job, currentDigest) {
161
179
  }
162
180
  return null;
163
181
  }
164
- export function reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) {
165
- return boundFilesStaleReason(job, changeRoot) ?? reviewEvidenceStaleReason(job, currentEvidenceDigest);
182
+ export function codeStateCheckStaleReason(job, projectRoot, events, ignoredCodePaths = []) {
183
+ if (!isReviewReadyVerifier(job))
184
+ return null;
185
+ if (!job.packet_context?.code_state_check)
186
+ return null;
187
+ if (!projectRoot || !events)
188
+ return null;
189
+ const current = computeCodeStateCheck(projectRoot, events, ignoredCodePaths);
190
+ if (JSON.stringify(current) !== JSON.stringify(job.packet_context.code_state_check)) {
191
+ return "最终验证工作项的代码状态事实已变化";
192
+ }
193
+ return null;
194
+ }
195
+ export function reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest, projectRoot, events, ignoredCodePaths = []) {
196
+ return boundFilesStaleReason(job, changeRoot)
197
+ ?? reviewEvidenceStaleReason(job, currentEvidenceDigest)
198
+ ?? codeStateCheckStaleReason(job, projectRoot, events, ignoredCodePaths);
166
199
  }
167
- export function isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest) {
168
- return isReviewReadyVerifier(job) && reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) == null;
200
+ export function isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest, projectRoot, events) {
201
+ return isReviewReadyVerifier(job) && reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest, projectRoot, events) == null;
169
202
  }
@@ -0,0 +1,20 @@
1
+ import type { ReviewRisk } from "./review.ts";
2
+ import type { Job, JobRole, ReviewJobGateId, Snapshot } from "./types.ts";
3
+ export interface ReviewGateRule {
4
+ gate_id: ReviewJobGateId;
5
+ created_from_transition: "explore" | "propose-ready" | "review-ready";
6
+ allowedRoles: JobRole[];
7
+ reviewedDocPaths: string[];
8
+ requiredRolesForRisk(risk: ReviewRisk): JobRole[];
9
+ matchesOldJob(job: Job): boolean;
10
+ isJobForGate(job: Job): boolean;
11
+ openJobsForGate(snapshot: Snapshot): Job[];
12
+ }
13
+ export declare const EXPLORE_DISCOVERY_REVIEW_GATE_ID: "explore.discovery_review";
14
+ export declare const PROPOSE_FINAL_REVIEW_GATE_ID: "propose.final_review";
15
+ export declare const REVIEW_CODE_REVIEW_GATE_ID: "review.code_review";
16
+ export declare const REVIEW_FINAL_VERIFIER_GATE_ID: "review.final_verifier";
17
+ export declare const EXPLORE_DISCOVERY_REVIEW_GATE: ReviewGateRule;
18
+ export declare const PROPOSE_FINAL_REVIEW_GATE: ReviewGateRule;
19
+ export declare const REVIEW_CODE_REVIEW_GATE: ReviewGateRule;
20
+ export declare const REVIEW_FINAL_VERIFIER_GATE: ReviewGateRule;
@@ -0,0 +1,85 @@
1
+ import { reviewRolesForGate } from "./workflow_profile.js";
2
+ function makeReviewGateRule(input) {
3
+ const gate = {
4
+ ...input,
5
+ isJobForGate(job) {
6
+ if (!input.allowedRoles.includes(job.role))
7
+ return false;
8
+ if (job.gate_id)
9
+ return job.gate_id === input.gate_id;
10
+ return input.matchesOldJob(job);
11
+ },
12
+ openJobsForGate(snapshot) {
13
+ return snapshot.open_jobs.filter(job => gate.isJobForGate(job));
14
+ },
15
+ };
16
+ return gate;
17
+ }
18
+ export const EXPLORE_DISCOVERY_REVIEW_GATE_ID = "explore.discovery_review";
19
+ export const PROPOSE_FINAL_REVIEW_GATE_ID = "propose.final_review";
20
+ export const REVIEW_CODE_REVIEW_GATE_ID = "review.code_review";
21
+ export const REVIEW_FINAL_VERIFIER_GATE_ID = "review.final_verifier";
22
+ const EXPLORE_DISCOVERY_REVIEW_ROLES = ["critic"];
23
+ const PROPOSAL_REVIEW_ROLES = ["critic", "architect", "test-engineer"];
24
+ const PROPOSAL_REVIEW_ROLE_SET = new Set(PROPOSAL_REVIEW_ROLES);
25
+ const REVIEW_CODE_REVIEW_ROLES = ["code-reviewer"];
26
+ const REVIEW_FINAL_VERIFIER_ROLES = ["verifier"];
27
+ export const EXPLORE_DISCOVERY_REVIEW_GATE = makeReviewGateRule({
28
+ gate_id: EXPLORE_DISCOVERY_REVIEW_GATE_ID,
29
+ created_from_transition: "explore",
30
+ allowedRoles: EXPLORE_DISCOVERY_REVIEW_ROLES,
31
+ reviewedDocPaths: [".superspec/artifacts/discovery.md"],
32
+ requiredRolesForRisk(risk) {
33
+ return reviewRolesForGate(EXPLORE_DISCOVERY_REVIEW_GATE_ID, risk);
34
+ },
35
+ matchesOldJob(job) {
36
+ return job.created_from_transition === "explore" && job.role === "critic";
37
+ },
38
+ });
39
+ export const PROPOSE_FINAL_REVIEW_GATE = makeReviewGateRule({
40
+ gate_id: PROPOSE_FINAL_REVIEW_GATE_ID,
41
+ created_from_transition: "propose-ready",
42
+ allowedRoles: PROPOSAL_REVIEW_ROLES,
43
+ reviewedDocPaths: [
44
+ "proposal.md",
45
+ "tasks.md",
46
+ "design.md",
47
+ "specs/",
48
+ ".superspec/artifacts/discovery.md",
49
+ ".superspec/artifacts/business-invariants.md",
50
+ ".superspec/artifacts/test-contract.md",
51
+ ],
52
+ requiredRolesForRisk(risk) {
53
+ return reviewRolesForGate(PROPOSE_FINAL_REVIEW_GATE_ID, risk);
54
+ },
55
+ matchesOldJob(job) {
56
+ return job.created_from_transition === "propose-ready" && PROPOSAL_REVIEW_ROLE_SET.has(job.role);
57
+ },
58
+ });
59
+ export const REVIEW_CODE_REVIEW_GATE = makeReviewGateRule({
60
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
61
+ created_from_transition: "review-ready",
62
+ allowedRoles: REVIEW_CODE_REVIEW_ROLES,
63
+ reviewedDocPaths: [],
64
+ requiredRolesForRisk(risk) {
65
+ return reviewRolesForGate(REVIEW_CODE_REVIEW_GATE_ID, risk);
66
+ },
67
+ matchesOldJob(job) {
68
+ return job.created_from_transition === "review-ready" && job.role === "code-reviewer";
69
+ },
70
+ });
71
+ export const REVIEW_FINAL_VERIFIER_GATE = makeReviewGateRule({
72
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
73
+ created_from_transition: "review-ready",
74
+ allowedRoles: REVIEW_FINAL_VERIFIER_ROLES,
75
+ reviewedDocPaths: [],
76
+ requiredRolesForRisk(risk) {
77
+ return reviewRolesForGate(REVIEW_FINAL_VERIFIER_GATE_ID, risk);
78
+ },
79
+ matchesOldJob(job) {
80
+ return job.created_from_transition === "review-ready" &&
81
+ job.role === "verifier" &&
82
+ typeof job.review_evidence_digest === "string" &&
83
+ job.review_evidence_digest.length > 0;
84
+ },
85
+ });
package/dist/store.d.ts CHANGED
@@ -17,6 +17,16 @@ export declare function jobsDir(projectRoot: string, change: string): string;
17
17
  export declare function ensureChangeLayout(projectRoot: string, change: string): void;
18
18
  export declare function sha256Text(text: string): string;
19
19
  export declare function sha256File(filePath: string): string | null;
20
+ /**
21
+ * 递归列出目录下全部 .md 文件(相对路径,确定性顺序:逐层排序的深度优先)。
22
+ * 目录缺失返回空数组;symlink、并发删除的条目跳过(不参与聚合),
23
+ * 避免 specs/ 出现异常项时打断 rebuildSnapshot 的所有调用方。
24
+ */
25
+ export declare function listMarkdownFiles(dirPath: string): string[];
26
+ /** 目录聚合指纹:排序后的相对路径 + 逐文件内容 sha,只聚合 .md(临时/系统文件不参与);增/删/改任一 .md 都会变化。目录缺失或为空返回稳定空指纹 */
27
+ export declare function sha256Dir(dirPath: string): string;
28
+ /** 文档引用:路径以 / 结尾按目录聚合指纹绑定,其余按单文件 sha */
29
+ export declare function docRef(root: string, path: string): Ref;
20
30
  export declare function computeDocumentDigests(changeRoot: string, docPaths: string[]): Record<string, string>;
21
31
  export declare function digestOf(obj: unknown): string;
22
32
  export declare function appendEvent(projectRoot: string, change: string, event: Event): void;
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SuperSpec 流程引擎 — 存储层:路径、指纹、事件日志、快照、锁
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, statSync, renameSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, openSync, closeSync, unlinkSync, statSync, lstatSync, renameSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { hostname } from "node:os";
6
6
  // ===== 路径 =====
@@ -48,11 +48,61 @@ export function sha256File(filePath) {
48
48
  return null;
49
49
  return "sha256:" + createHash("sha256").update(readFileSync(filePath)).digest("hex");
50
50
  }
51
+ /**
52
+ * 递归列出目录下全部 .md 文件(相对路径,确定性顺序:逐层排序的深度优先)。
53
+ * 目录缺失返回空数组;symlink、并发删除的条目跳过(不参与聚合),
54
+ * 避免 specs/ 出现异常项时打断 rebuildSnapshot 的所有调用方。
55
+ */
56
+ export function listMarkdownFiles(dirPath) {
57
+ const out = [];
58
+ const lstatOrNull = (p) => { try {
59
+ return lstatSync(p);
60
+ }
61
+ catch {
62
+ return null;
63
+ } };
64
+ const walk = (dir, prefix) => {
65
+ const dirStat = lstatOrNull(dir);
66
+ if (!dirStat?.isDirectory() || dirStat.isSymbolicLink())
67
+ return;
68
+ let names;
69
+ try {
70
+ names = readdirSync(dir).sort();
71
+ }
72
+ catch {
73
+ return;
74
+ }
75
+ for (const name of names) {
76
+ const full = join(dir, name);
77
+ const rel = prefix ? `${prefix}/${name}` : name;
78
+ const st = lstatOrNull(full);
79
+ if (!st || st.isSymbolicLink())
80
+ continue;
81
+ if (st.isDirectory())
82
+ walk(full, rel);
83
+ else if (name.endsWith(".md"))
84
+ out.push(rel);
85
+ }
86
+ };
87
+ walk(dirPath, "");
88
+ return out;
89
+ }
90
+ /** 目录聚合指纹:排序后的相对路径 + 逐文件内容 sha,只聚合 .md(临时/系统文件不参与);增/删/改任一 .md 都会变化。目录缺失或为空返回稳定空指纹 */
91
+ export function sha256Dir(dirPath) {
92
+ const entries = listMarkdownFiles(dirPath)
93
+ .map(rel => `${rel}\u0000${sha256File(join(dirPath, rel)) ?? "sha256:missing"}`);
94
+ return sha256Text(entries.join("\n"));
95
+ }
96
+ /** 文档引用:路径以 / 结尾按目录聚合指纹绑定,其余按单文件 sha */
97
+ export function docRef(root, path) {
98
+ if (path.endsWith("/"))
99
+ return { path, sha: sha256Dir(join(root, path)) };
100
+ return { path, sha: sha256File(join(root, path)) ?? "sha256:missing" };
101
+ }
51
102
  export function computeDocumentDigests(changeRoot, docPaths) {
52
103
  const digests = {};
53
104
  for (const p of docPaths) {
54
- const full = join(changeRoot, p);
55
- digests[p] = sha256File(full) ?? "sha256:missing";
105
+ digests[p] = docRef(changeRoot, p).sha;
56
106
  }
57
107
  return digests;
58
108
  }
package/dist/sync.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { readEvents, eventsDigest, computeDocumentDigests, sha256Text, ensureChangeLayout, } from "./store.js";
5
- import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
- import { codeReviewJobStaleReason } from "./code_review.js";
5
+ import { reviewEvidenceDigest } from "./review.js";
6
+ import { invalidReasonForSnapshot } from "./job_validity.js";
7
7
  const TRACKED_DOCS = [
8
- "proposal.md", "design.md", "tasks.md",
8
+ "proposal.md", "design.md", "tasks.md", "specs/",
9
9
  ".superspec/artifacts/discovery.md",
10
10
  ".superspec/artifacts/business-invariants.md",
11
11
  ".superspec/artifacts/test-contract.md",
@@ -81,12 +81,10 @@ function replayEvents(events) {
81
81
  return { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition };
82
82
  }
83
83
  /** 粗粒度失效:检查 job 的 boundFiles 是否仍匹配当前文档 */
84
- function checkStaleJobs(jobs, projectRoot, changeRoot, currentReviewEvidenceDigest) {
84
+ function checkStaleJobs(jobs, projectRoot, changeRoot, events, currentReviewEvidenceDigest) {
85
85
  const stale = [];
86
86
  for (const job of jobs) {
87
- const reason = job.role === "code-reviewer"
88
- ? codeReviewJobStaleReason(projectRoot, job)
89
- : reviewVerifierStaleReason(job, changeRoot, currentReviewEvidenceDigest);
87
+ const reason = invalidReasonForSnapshot({ job, projectRoot, changeRoot, events, currentReviewEvidenceDigest });
90
88
  if (reason) {
91
89
  stale.push({ job_id: job.job_id, reason });
92
90
  }
@@ -114,8 +112,8 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
114
112
  const currentReviewEvidenceDigest = reviewEvidenceDigest(events);
115
113
  // 粗粒度失效检查(只读,不写事件):open code-reviewer job 防止提交过期报告;
116
114
  // accepted code-reviewer pass 不做持续 freshness gate,避免 apply_done 循环重审。
117
- const staleOpenInfo = checkStaleJobs(openJobs, projectRoot, changeRoot, currentReviewEvidenceDigest);
118
- const staleAcceptedInfo = checkStaleJobs(acceptedJobs.filter(j => j.role !== "code-reviewer"), projectRoot, changeRoot, currentReviewEvidenceDigest);
115
+ const staleOpenInfo = checkStaleJobs(openJobs, projectRoot, changeRoot, events, currentReviewEvidenceDigest);
116
+ const staleAcceptedInfo = checkStaleJobs(acceptedJobs.filter(j => j.role !== "code-reviewer"), projectRoot, changeRoot, events, currentReviewEvidenceDigest);
119
117
  const freshOpen = openJobs
120
118
  .filter(j => !staleOpenInfo.some(s => s.job_id === j.job_id))
121
119
  .filter(j => j.role !== "code-reviewer" || state === "apply_done");