@peterxiaoyang/superspec 0.1.38 → 0.1.40

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 (37) hide show
  1. package/dist/cli.js +19 -2
  2. package/dist/code_review.d.ts +17 -2
  3. package/dist/code_review.js +471 -80
  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 +1 -0
  9. package/dist/job_validity.js +11 -8
  10. package/dist/next.js +46 -314
  11. package/dist/phase_plan.d.ts +97 -0
  12. package/dist/phase_plan.js +582 -0
  13. package/dist/record.js +56 -7
  14. package/dist/review.d.ts +3 -2
  15. package/dist/review.js +64 -32
  16. package/dist/review_job_gates.js +1 -0
  17. package/dist/store.d.ts +10 -0
  18. package/dist/store.js +53 -3
  19. package/dist/sync.js +5 -5
  20. package/dist/task.js +87 -9
  21. package/dist/task_evidence.js +80 -0
  22. package/dist/transition.d.ts +1 -1
  23. package/dist/transition.js +301 -210
  24. package/dist/types.d.ts +77 -1
  25. package/package.json +1 -1
  26. package/templates/workflow/agents/explore.toml +1 -1
  27. package/templates/workflow/prompts/architect.md +17 -27
  28. package/templates/workflow/prompts/code-reviewer.md +13 -2
  29. package/templates/workflow/prompts/critic.md +62 -61
  30. package/templates/workflow/prompts/executor.md +1 -1
  31. package/templates/workflow/prompts/explore.md +38 -26
  32. package/templates/workflow/prompts/test-engineer.md +18 -32
  33. package/templates/workflow/prompts/verifier.md +5 -3
  34. package/templates/workflow/skills/superspec-apply/SKILL.md +34 -11
  35. package/templates/workflow/skills/superspec-explore/SKILL.md +66 -64
  36. package/templates/workflow/skills/superspec-propose/SKILL.md +67 -44
  37. package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
package/dist/record.js CHANGED
@@ -5,6 +5,7 @@ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha
5
5
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
6
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) {
@@ -323,11 +325,13 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
323
325
  reason: checks.join("; "),
324
326
  parsedReport,
325
327
  rawRef,
328
+ reportPath,
326
329
  })
327
330
  : {
328
331
  job_id: jobId,
329
332
  role: job.role,
330
333
  report_digest: reportDigest,
334
+ ...(reportPath ? { report_path: reportPath } : {}),
331
335
  reason: checks.join("; "),
332
336
  }),
333
337
  });
@@ -348,6 +352,8 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
348
352
  parsedReport,
349
353
  rawRef,
350
354
  }));
355
+ if (reportPath)
356
+ rejectEvent.payload.report_path = reportPath;
351
357
  appendEvent(projectRoot, change, rejectEvent);
352
358
  return {
353
359
  event_type: "job_rejected",
@@ -375,6 +381,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
375
381
  reason,
376
382
  parsedReport,
377
383
  rawRef,
384
+ reportPath,
378
385
  })));
379
386
  return {
380
387
  event_type: "job_rejected",
@@ -401,6 +408,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
401
408
  reason,
402
409
  parsedReport,
403
410
  rawRef,
411
+ reportPath,
404
412
  })));
405
413
  return {
406
414
  event_type: "job_rejected",
@@ -416,6 +424,7 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
416
424
  role: job.role,
417
425
  report_digest: reportDigest,
418
426
  accepted_at: new Date().toISOString(),
427
+ ...(reportPath ? { report_path: reportPath } : {}),
419
428
  ...rawRef,
420
429
  });
421
430
  appendEvent(projectRoot, change, acceptEvent);
@@ -494,7 +503,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
494
503
  reason: "missing_scope_or_answer",
495
504
  input_digest: inputDigest,
496
505
  }));
497
- return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scopeanswer" };
506
+ return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少决策范围(scope)或答复内容(answer" };
498
507
  }
499
508
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
500
509
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
@@ -547,7 +556,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
547
556
  return {
548
557
  event_type: "user_decision_recorded",
549
558
  accepted: true,
550
- message: `用户决策已登记:${decision.scope}`,
559
+ message: `用户决策已登记:决策范围(scope)=${decision.scope}`,
551
560
  };
552
561
  }
553
562
  /** record user-decision:登记用户决策 */
@@ -606,6 +615,31 @@ export function jobsList(projectRoot, change) {
606
615
  }
607
616
  return { open, accepted, rejected };
608
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
+ }
609
643
  /** jobs packet:返回工作项执行说明 */
610
644
  export function jobsPacket(projectRoot, change, jobId) {
611
645
  const events = readEvents(projectRoot, change);
@@ -614,6 +648,7 @@ export function jobsPacket(projectRoot, change, jobId) {
614
648
  return { found: false, message: `工作项 ${jobId} 不存在` };
615
649
  }
616
650
  const isCodeReviewer = job.role === "code-reviewer";
651
+ const packetContext = job.packet_context;
617
652
  return {
618
653
  found: true,
619
654
  packet: {
@@ -624,6 +659,13 @@ export function jobsPacket(projectRoot, change, jobId) {
624
659
  boundFiles: job.boundFiles,
625
660
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
626
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 } : {}),
627
669
  packet_digest: job.packet_digest,
628
670
  required_output_kind: "job_report_json",
629
671
  preferred_input_mode: "stdin",
@@ -634,16 +676,23 @@ export function jobsPacket(projectRoot, change, jobId) {
634
676
  ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer", "review_scope"]
635
677
  : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
636
678
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
679
+ 字段说明: packetFieldDescriptions(),
637
680
  output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
638
681
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
639
682
  (job.previous_rejection ? `上一次代码审查没有形成可推进结论,原因:${job.previous_rejection.reason}。本次请根据该原因重新审查,` : "") +
640
- (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在 reviewer.kind/id 中记录来源,` : "") +
641
- `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仍可作为 fallback。以下 JSON 合约给审查代理使用,普通对话不要原样复述。` +
683
+ (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
684
+ `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
642
685
  (isCodeReviewer
643
- ? `最小格式:{"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>"}。`
644
- + `报告结论为 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
+ : "")
645
691
  : job.role === "verifier"
646
- ? `最小格式:{"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
+ : "")
647
696
  : requiresReviewer(job.role)
648
697
  ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
649
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,12 +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
5
  import { REVIEW_FINAL_VERIFIER_GATE } from "./review_job_gates.js";
6
+ import { computeCodeStateCheck, effectiveCoverageExemptionRefsFromEvents } from "./code_review.js";
6
7
  export const REVIEW_DOC_PATHS = [
7
8
  "proposal.md",
8
9
  "tasks.md",
9
10
  "design.md",
11
+ "specs/",
10
12
  ".superspec/artifacts/discovery.md",
11
13
  ".superspec/artifacts/business-invariants.md",
12
14
  ".superspec/artifacts/test-contract.md",
@@ -56,32 +58,21 @@ export function isReviewReadyVerifier(job) {
56
58
  return job.role === "verifier" && REVIEW_FINAL_VERIFIER_GATE.isJobForGate(job);
57
59
  }
58
60
  export function reviewBoundFiles(changeRoot) {
61
+ // 目录路径(以 / 结尾)始终绑定聚合指纹,与 boundFilesStaleReason 的 docRef 比对保持一致
59
62
  return REVIEW_DOC_PATHS
60
- .filter(path => existsSync(join(changeRoot, path)))
61
- .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));
62
65
  }
66
+ // 方案排序键:kind → task_id → test_id → attempt_id → event_id;缺失字段按空字符串。
67
+ // event_id 全局唯一,作为末位排序键足以保证稳定。
63
68
  function evidenceSortKey(record) {
64
- const parts = [
69
+ return [
65
70
  record.kind,
66
71
  record.task_id ?? "",
67
- record.attempt_id ?? "",
68
- record.task_structure_digest ?? "",
69
72
  record.test_id ?? "",
70
- record.semantic_status ?? "",
71
- ];
72
- if (record.covers_task_ids && record.covers_task_ids.length > 0) {
73
- parts.push(`covers:${JSON.stringify(record.covers_task_ids)}`);
74
- }
75
- parts.push(record.command ?? "", record.cwd ?? "", record.exit_code == null ? "" : String(record.exit_code), record.target_fingerprint ?? "", record.event_digest);
76
- return parts.join("\u0000");
77
- }
78
- function normalizedCoveredTaskIds(value) {
79
- if (!Array.isArray(value))
80
- return [];
81
- return [...new Set(value
82
- .filter((item) => typeof item === "string")
83
- .map(item => item.trim())
84
- .filter(Boolean))].sort();
73
+ record.attempt_id ?? "",
74
+ record.event_id ?? "",
75
+ ].join("\u0000");
85
76
  }
86
77
  export function reviewEvidenceDigest(events) {
87
78
  const attemptsById = new Map();
@@ -100,6 +91,7 @@ export function reviewEvidenceDigest(events) {
100
91
  task_id: payload.task_id,
101
92
  attempt_id: payload.attempt_id,
102
93
  task_structure_digest: attempt?.task_structure_digest ?? null,
94
+ event_id: ev.event_id,
103
95
  event_digest: ev.event_digest,
104
96
  });
105
97
  }
@@ -108,11 +100,12 @@ export function reviewEvidenceDigest(events) {
108
100
  const completedStructureDigests = new Set(completed
109
101
  .map(item => item.task_structure_digest)
110
102
  .filter((digest) => typeof digest === "string" && digest.length > 0));
103
+ // scope_note / boundary_snapshot / checkbox_update 属于 task_completed 事件 payload,已由 event_digest 覆盖
111
104
  const records = completed.map(item => ({
112
105
  kind: "task_completed",
113
106
  task_id: item.task_id,
114
107
  attempt_id: item.attempt_id,
115
- task_structure_digest: item.task_structure_digest,
108
+ event_id: item.event_id,
116
109
  event_digest: item.event_digest,
117
110
  }));
118
111
  for (const ev of events) {
@@ -125,18 +118,41 @@ export function reviewEvidenceDigest(events) {
125
118
  const matchesLegacyDigest = attemptId == null && structureDigest != null && completedStructureDigests.has(structureDigest);
126
119
  if (!matchesCompletedAttempt && !matchesLegacyDigest)
127
120
  continue;
128
- const coversTaskIds = normalizedCoveredTaskIds(payload.covers_task_ids);
129
121
  records.push({
130
122
  kind: "test_run_recorded",
131
123
  test_id: typeof payload.test_id === "string" ? payload.test_id : null,
132
124
  attempt_id: attemptId,
133
- task_structure_digest: structureDigest,
134
125
  semantic_status: typeof payload.semantic_status === "string" ? payload.semantic_status : null,
135
- ...(coversTaskIds.length > 0 ? { covers_task_ids: coversTaskIds } : {}),
126
+ exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
136
127
  command: typeof payload.command === "string" ? payload.command : "",
137
128
  cwd: typeof payload.cwd === "string" ? payload.cwd : "",
138
- exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
139
- 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,
140
156
  event_digest: ev.event_digest,
141
157
  });
142
158
  }
@@ -145,7 +161,8 @@ export function reviewEvidenceDigest(events) {
145
161
  }
146
162
  export function boundFilesStaleReason(job, changeRoot) {
147
163
  for (const bf of job.boundFiles) {
148
- const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
164
+ // 目录绑定(path / 结尾)比对聚合指纹,覆盖目录内文件的增/删/改
165
+ const current = docRef(changeRoot, bf.path).sha;
149
166
  if (current !== bf.sha) {
150
167
  return `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`;
151
168
  }
@@ -162,9 +179,24 @@ export function reviewEvidenceStaleReason(job, currentDigest) {
162
179
  }
163
180
  return null;
164
181
  }
165
- export function reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) {
166
- 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);
167
199
  }
168
- export function isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest) {
169
- 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;
170
202
  }
@@ -44,6 +44,7 @@ export const PROPOSE_FINAL_REVIEW_GATE = makeReviewGateRule({
44
44
  "proposal.md",
45
45
  "tasks.md",
46
46
  "design.md",
47
+ "specs/",
47
48
  ".superspec/artifacts/discovery.md",
48
49
  ".superspec/artifacts/business-invariants.md",
49
50
  ".superspec/artifacts/test-contract.md",
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
@@ -5,7 +5,7 @@ import { readEvents, eventsDigest, computeDocumentDigests, sha256Text, ensureCha
5
5
  import { reviewEvidenceDigest } from "./review.js";
6
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,10 +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 = invalidReasonForSnapshot({ job, projectRoot, changeRoot, currentReviewEvidenceDigest });
87
+ const reason = invalidReasonForSnapshot({ job, projectRoot, changeRoot, events, currentReviewEvidenceDigest });
88
88
  if (reason) {
89
89
  stale.push({ job_id: job.job_id, reason });
90
90
  }
@@ -112,8 +112,8 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
112
112
  const currentReviewEvidenceDigest = reviewEvidenceDigest(events);
113
113
  // 粗粒度失效检查(只读,不写事件):open code-reviewer job 防止提交过期报告;
114
114
  // accepted code-reviewer pass 不做持续 freshness gate,避免 apply_done 循环重审。
115
- const staleOpenInfo = checkStaleJobs(openJobs, projectRoot, changeRoot, currentReviewEvidenceDigest);
116
- 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);
117
117
  const freshOpen = openJobs
118
118
  .filter(j => !staleOpenInfo.some(s => s.job_id === j.job_id))
119
119
  .filter(j => j.role !== "code-reviewer" || state === "apply_done");
package/dist/task.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // SuperSpec 流程引擎 — task:测试运行记录 + 任务结构指纹工具
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord } from "./store.js";
4
+ import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord, readEvents } from "./store.js";
5
5
  import { tasksStructureDigest as formatDigest } from "./format.js";
6
6
  /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
7
7
  export function tasksStructureDigestOf(changeRoot) {
@@ -19,30 +19,44 @@ function recordTestRunLoaded(projectRoot, change, content) {
19
19
  catch {
20
20
  return { accepted: false, message: "无效 JSON" };
21
21
  }
22
- if (!tr.test_id || !tr.task_structure_digest) {
23
- return { accepted: false, message: "缺少 test_id task_structure_digest" };
22
+ const events = readEvents(projectRoot, change);
23
+ const attemptRecord = typeof tr.attempt_id === "string" ? attemptById(events, tr.attempt_id) : null;
24
+ const activeAttempt = attemptRecord?.state === "active" ? attemptRecord.attempt : null;
25
+ if (attemptRecord?.attempt.contract_mode === true && attemptRecord.state !== "active") {
26
+ return { accepted: false, message: "契约测试证据只能登记到当前活跃任务尝试(attempt_id)" };
27
+ }
28
+ if (!activeAttempt && activeContractAttemptExists(events)) {
29
+ return { accepted: false, message: "契约测试证据必须带当前活跃任务尝试 ID(attempt_id)" };
30
+ }
31
+ if (activeAttempt?.contract_mode === true) {
32
+ const contractCheck = validateContractTestRunInput(tr, activeAttempt);
33
+ if (!contractCheck.ok)
34
+ return { accepted: false, message: contractCheck.message };
35
+ }
36
+ else if (!tr.test_id || !tr.task_structure_digest) {
37
+ return { accepted: false, message: "缺少测试 ID(test_id)或历史任务结构指纹(task_structure_digest)" };
24
38
  }
25
39
  let coversTaskIds;
26
40
  if (tr.covers_task_ids !== undefined) {
27
41
  if (!Array.isArray(tr.covers_task_ids)) {
28
- return { accepted: false, message: "covers_task_ids 必须是字符串数组" };
42
+ return { accepted: false, message: "回归覆盖任务列表(covers_task_ids)必须是字符串数组" };
29
43
  }
30
44
  if (tr.covers_task_ids.length === 0) {
31
- return { accepted: false, message: "covers_task_ids 不能是空数组" };
45
+ return { accepted: false, message: "回归覆盖任务列表(covers_task_ids)不能是空数组" };
32
46
  }
33
47
  for (const raw of tr.covers_task_ids) {
34
48
  if (typeof raw !== "string")
35
- return { accepted: false, message: "covers_task_ids 必须是字符串数组" };
49
+ return { accepted: false, message: "回归覆盖任务列表(covers_task_ids)必须是字符串数组" };
36
50
  const value = raw.trim();
37
51
  if (!value)
38
- return { accepted: false, message: "covers_task_ids 不能包含空字符串" };
52
+ return { accepted: false, message: "回归覆盖任务列表(covers_task_ids)不能包含空字符串" };
39
53
  (coversTaskIds ??= []).push(value);
40
54
  }
41
55
  coversTaskIds = [...new Set(coversTaskIds)].sort();
42
56
  }
43
57
  const normalizedTestRun = {
44
58
  test_id: tr.test_id,
45
- task_structure_digest: tr.task_structure_digest,
59
+ task_structure_digest: tr.task_structure_digest ?? activeAttempt?.task_structure_digest ?? "",
46
60
  attempt_id: tr.attempt_id ?? null,
47
61
  ...(coversTaskIds ? { covers_task_ids: coversTaskIds } : {}),
48
62
  command: tr.command ?? "",
@@ -58,7 +72,71 @@ function recordTestRunLoaded(projectRoot, change, content) {
58
72
  ...rawRef,
59
73
  });
60
74
  appendEvent(projectRoot, change, event);
61
- return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
75
+ return { accepted: true, message: `测试运行已登记:测试 ID(test_id)=${tr.test_id}` };
76
+ }
77
+ function attemptById(events, attemptId) {
78
+ const attempts = new Map();
79
+ for (const ev of events) {
80
+ if (ev.event_type === "task_started") {
81
+ const attempt = ev.payload;
82
+ if (typeof attempt.attempt_id === "string")
83
+ attempts.set(attempt.attempt_id, { attempt, state: "active" });
84
+ }
85
+ else if (ev.event_type === "task_completed") {
86
+ const payload = ev.payload;
87
+ const existing = typeof payload.attempt_id === "string" ? attempts.get(payload.attempt_id) : null;
88
+ if (existing)
89
+ existing.state = "completed";
90
+ }
91
+ else if (ev.event_type === "task_abandoned") {
92
+ const payload = ev.payload;
93
+ const existing = typeof payload.attempt_id === "string" ? attempts.get(payload.attempt_id) : null;
94
+ if (existing)
95
+ existing.state = "abandoned";
96
+ }
97
+ }
98
+ return attempts.get(attemptId) ?? null;
99
+ }
100
+ function activeContractAttemptExists(events) {
101
+ const attempts = new Map();
102
+ for (const ev of events) {
103
+ if (ev.event_type === "task_started") {
104
+ const attempt = ev.payload;
105
+ if (typeof attempt.attempt_id === "string")
106
+ attempts.set(attempt.attempt_id, attempt);
107
+ }
108
+ else if (ev.event_type === "task_completed" || ev.event_type === "task_abandoned") {
109
+ const payload = ev.payload;
110
+ if (typeof payload.attempt_id === "string")
111
+ attempts.delete(payload.attempt_id);
112
+ }
113
+ }
114
+ return [...attempts.values()].some(attempt => attempt.contract_mode === true);
115
+ }
116
+ function validateContractTestRunInput(tr, attempt) {
117
+ if (!tr.test_id || !tr.command || !tr.cwd || typeof tr.exit_code !== "number" || !tr.semantic_status) {
118
+ return { ok: false, message: "契约测试证据缺少测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)或语义状态(semantic_status)" };
119
+ }
120
+ if (typeof tr.attempt_id !== "string" || tr.attempt_id !== attempt.attempt_id) {
121
+ return { ok: false, message: "契约测试证据必须带当前活跃任务尝试 ID(attempt_id)" };
122
+ }
123
+ if (!["expected_failure", "expected_success", "characterization_pass"].includes(tr.semantic_status)) {
124
+ return { ok: false, message: "语义状态(semantic_status)必须是 expected_failure、expected_success 或 characterization_pass" };
125
+ }
126
+ if (tr.semantic_status === "expected_failure" && tr.exit_code === 0) {
127
+ return { ok: false, message: "RED 预期失败(expected_failure)要求退出码(exit_code)非 0" };
128
+ }
129
+ if ((tr.semantic_status === "expected_success" || tr.semantic_status === "characterization_pass") && tr.exit_code !== 0) {
130
+ return { ok: false, message: `语义状态(semantic_status=${tr.semantic_status})要求退出码(exit_code)为 0` };
131
+ }
132
+ if (tr.semantic_status === "characterization_pass" && !(attempt.tdd_required === false && attempt.no_tdd_reason === "characterization")) {
133
+ return { ok: false, message: "特征化通过(characterization_pass)只适用于无需 TDD 的特征化任务(tdd_required:false,no_tdd_reason:characterization)" };
134
+ }
135
+ const declaredTests = attempt.contract?.tests ?? [];
136
+ if (declaredTests.length > 0 && !declaredTests.includes(tr.test_id)) {
137
+ return { ok: false, message: `测试 ID(test_id=${tr.test_id})不属于当前任务契约声明的测试列表` };
138
+ }
139
+ return { ok: true };
62
140
  }
63
141
  /** record test-run:登记测试运行记录 */
64
142
  export function recordTestRun(projectRoot, change, inputFile) {