@peterxiaoyang/superspec 0.1.41 → 0.1.42

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.
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { next as nextCmd } from "./next.js";
10
10
  import { proposeReady, commitTransition, transitionInit, transitionExplore, startApply, taskStart, taskComplete, reopen, reviewReady, accept } from "./transition.js";
11
11
  import { recordJobSubmit, recordJobSubmitContent, recordUserDecision, recordUserDecisionContent, jobsList, jobsPacket } from "./record.js";
12
12
  import { recordTestRun, recordTestRunContent } from "./task.js";
13
+ import { RecordInputDecodingError, decodeRecordInput } from "./record_input.js";
13
14
  import { probeOpenSpec, openspecStatus, changeRoot } from "./openspec.js";
14
15
  import { SUPERSPEC_VERSION } from "./version.js";
15
16
  const PACKAGE_NAME = "@peterxiaoyang/superspec";
@@ -54,7 +55,7 @@ function readStdinRecordContent(flag) {
54
55
  if (process.stdin.isTTY === true) {
55
56
  throw new StdinRecordInputError(flag);
56
57
  }
57
- return readFileSync(0, "utf8");
58
+ return decodeRecordInput(readFileSync(0));
58
59
  }
59
60
  function transitionExitCode(result) {
60
61
  if (result.outcome === "blocked")
@@ -709,7 +710,7 @@ jobs 子命令:
709
710
  inputContent = readStdinRecordContent("--input");
710
711
  }
711
712
  catch (err) {
712
- if (err instanceof StdinRecordInputError) {
713
+ if (err instanceof StdinRecordInputError || err instanceof RecordInputDecodingError) {
713
714
  console.error(err.message);
714
715
  return 1;
715
716
  }
@@ -773,7 +774,7 @@ jobs 子命令:
773
774
  : recordJobSubmit(projectRoot, change, cr, jobId, report);
774
775
  }
775
776
  catch (err) {
776
- if (err instanceof StdinRecordInputError) {
777
+ if (err instanceof StdinRecordInputError || err instanceof RecordInputDecodingError) {
777
778
  console.error(err.message);
778
779
  return 1;
779
780
  }
@@ -795,7 +796,7 @@ jobs 子命令:
795
796
  : recordUserDecision(projectRoot, change, inputFile);
796
797
  }
797
798
  catch (err) {
798
- if (err instanceof StdinRecordInputError) {
799
+ if (err instanceof StdinRecordInputError || err instanceof RecordInputDecodingError) {
799
800
  console.error(err.message);
800
801
  return 1;
801
802
  }
@@ -817,7 +818,7 @@ jobs 子命令:
817
818
  : recordTestRun(projectRoot, change, inputFile);
818
819
  }
819
820
  catch (err) {
820
- if (err instanceof StdinRecordInputError) {
821
+ if (err instanceof StdinRecordInputError || err instanceof RecordInputDecodingError) {
821
822
  console.error(err.message);
822
823
  return 1;
823
824
  }
package/dist/record.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
- import { readFileSync, existsSync } from "node:fs";
2
+ import { 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
5
  import { rebuildSnapshot } from "./sync.js";
@@ -10,6 +10,7 @@ import { invalidReasonForSubmittedReport } from "./job_validity.js";
10
10
  import { jobSubmitArgv } from "./job_action.js";
11
11
  import { REVIEW_DOC_PATHS } from "./review.js";
12
12
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
13
+ import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
13
14
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
14
15
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
15
16
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -17,6 +18,13 @@ const CODE_REVIEW_FINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
17
18
  function requiresReviewer(role) {
18
19
  return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer";
19
20
  }
21
+ /** All review reports with bound material acknowledge full file coverage. */
22
+ function requiresReviewScope(job) {
23
+ return job.boundFiles.length > 0 && job.role !== "executor" && job.role !== "test-run";
24
+ }
25
+ function projectLocalInvalidReportMustTerminate(job) {
26
+ return job.role === "code-reviewer" || job.packet_context?.code_state_check !== undefined;
27
+ }
20
28
  function isOrdinaryReviewer(role) {
21
29
  return role === "critic" || role === "architect" || role === "test-engineer";
22
30
  }
@@ -55,8 +63,8 @@ function previousRejectionInstruction(job) {
55
63
  return "";
56
64
  const reason = `上一次同角色审查没有形成可推进结论,原因:${previous.reason}。`;
57
65
  if (!previous.findings || previous.findings.length === 0)
58
- return `${reason}本次请先针对该原因重新审查,`;
59
- return `${reason}本工作项附带上一次同角色审查尚未闭环的问题列表;请优先逐项复核:同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题;新增问题必须提供与历史问题不同的具体证据。`;
66
+ return `${reason}完成全部材料覆盖和角色分析后,再针对该原因复核,`;
67
+ return `${reason}完成全部材料覆盖和角色分析后,再逐项复核本工作项附带的上一次同角色审查尚未闭环问题:同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题;新增问题必须提供与历史问题不同的具体证据。`;
60
68
  }
61
69
  function ordinaryReviewerFindingInstruction(job) {
62
70
  if (!isOrdinaryReviewer(job.role))
@@ -86,6 +94,23 @@ function reviewScopeInstruction(job, reviewTargets, readOnlyRefs) {
86
94
  : "";
87
95
  return targets + refs;
88
96
  }
97
+ function reviewCoverageInstruction(job) {
98
+ if (!requiresReviewScope(job))
99
+ return "";
100
+ return `审查顺序固定为:先建立覆盖索引,按文件和标题/行段完整浏览全部 boundFiles 至文件末尾;长文档必须分段读取,每段只保留短锚点和候选风险,不能在发现第一个 blocker 时提交。随后按本角色职责进行跨文件分析;最后才复核历史 findings 并去重,一次性提交当前快照下发现的全部 blocker。报告中的 review_scope.checked_paths 必须列出全部已浏览的 boundFiles;它只是覆盖回执,不能代替语义审查。`;
101
+ }
102
+ function migrationEvidenceInstruction(job) {
103
+ const isProposeReview = PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job);
104
+ if (!isProposeReview)
105
+ return "";
106
+ return "迁移与环境证据按阶段分层:Explore 记录迁移风险、兼容约束和外部未知;Propose 只需审查迁移策略、回滚/兼容设计、任务与可执行测试计划;Apply/Review 才记录实际 datasource、制品 SHA、Flyway history、审计日志和运行证据。可因策略、计划或文档矛盾而 fail;不得只因实际环境证据尚未产生而 fail。";
107
+ }
108
+ function recordInputInstruction(job) {
109
+ const encoding = "stdin 请传 UTF-8 JSON;Windows PowerShell 请设置 $OutputEncoding 和 [Console]::OutputEncoding 为 UTF-8,文件备用方式请使用 Set-Content -Encoding utf8。为兼容旧版 PowerShell,带 BOM 的 UTF-16LE 文件也可接受。";
110
+ if (!projectLocalInvalidReportMustTerminate(job))
111
+ return encoding;
112
+ return `${encoding}项目内 fallback 报告若存在解码或协议错误会终结当前工作项,以免被后续代码状态检查当作改动;如需保留同一工作项重交,请使用 stdin 或工作区外临时文件。`;
113
+ }
89
114
  function asObject(value) {
90
115
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
91
116
  }
@@ -169,6 +194,23 @@ function validateCodeReviewScope(obj, job, checks) {
169
194
  }
170
195
  }
171
196
  }
197
+ function validateReviewScope(obj, job, checks) {
198
+ const scope = asObject(obj.review_scope);
199
+ if (!scope) {
200
+ checks.push("审查报告缺少覆盖范围字段 review_scope");
201
+ return;
202
+ }
203
+ if (!stringArray(scope.checked_paths)) {
204
+ checks.push("审查报告覆盖范围里的 checked_paths 必须是字符串数组");
205
+ return;
206
+ }
207
+ const checkedPaths = new Set(scope.checked_paths);
208
+ for (const bound of job.boundFiles) {
209
+ if (!checkedPaths.has(bound.path)) {
210
+ checks.push(`审查报告未说明已检查 ${bound.path}`);
211
+ }
212
+ }
213
+ }
172
214
  function actionableCodeReviewFindings(findings) {
173
215
  if (!Array.isArray(findings))
174
216
  return { actionable: [], reasons: ["报告字段 findings 必须是数组"] };
@@ -307,6 +349,35 @@ function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
307
349
  message: `工作项 ${jobId} 已结束(${terminal}),不接受新报告。需要新工作项请重新执行对应的 transition。`,
308
350
  };
309
351
  }
352
+ function retryableJobSubmitResult(jobId, checks) {
353
+ return {
354
+ accepted: false,
355
+ message: `工作项 ${jobId} 的报告未接受,可修正后以同一工作项重新提交:${checks.join("; ")}`,
356
+ job_state: "requested",
357
+ events_written: 0,
358
+ };
359
+ }
360
+ function terminalInvalidReportResult(input) {
361
+ const rawRef = input.job.role === "code-reviewer" && input.parsedReport
362
+ ? appendRawRecord(input.projectRoot, input.change, "review-reports", input.parsedReport)
363
+ : null;
364
+ appendEvent(input.projectRoot, input.change, makeEvent(input.change, "job_rejected", codeReviewRejectEventPayload({
365
+ jobId: input.jobId,
366
+ job: input.job,
367
+ reportDigest: input.reportDigest,
368
+ resultKind: "invalid_report",
369
+ reason: input.reason,
370
+ parsedReport: input.parsedReport,
371
+ rawRef,
372
+ reportPath: input.reportPath,
373
+ })));
374
+ return {
375
+ event_type: "job_rejected",
376
+ accepted: false,
377
+ message: `工作项 ${input.jobId} 被拒绝:${input.reason}`,
378
+ job_state: "rejected",
379
+ };
380
+ }
310
381
  function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile) {
311
382
  const checks = [];
312
383
  let parsedReport = null;
@@ -340,6 +411,9 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
340
411
  if (job.role === "code-reviewer") {
341
412
  validateCodeReviewScope(parsedReport, job, checks);
342
413
  }
414
+ else if (requiresReviewScope(job)) {
415
+ validateReviewScope(parsedReport, job, checks);
416
+ }
343
417
  }
344
418
  }
345
419
  catch {
@@ -352,37 +426,22 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
352
426
  events,
353
427
  reportPath,
354
428
  });
355
- if (invalidReason) {
356
- checks.push(invalidReason);
357
- }
358
- if (!reportContent.trim()) {
429
+ if (!reportContent.trim() && !checks.includes("报告内容为空")) {
359
430
  checks.push("报告内容为空");
360
431
  }
361
- if (checks.length > 0) {
362
- const resultKind = "invalid_report";
363
- const rawRef = job.role === "code-reviewer" && parsedReport
364
- ? appendRawRecord(projectRoot, change, "review-reports", parsedReport)
365
- : null;
366
- const rejectEvent = makeEvent(change, "job_rejected", {
367
- ...codeReviewRejectEventPayload({
368
- jobId,
369
- job,
370
- reportDigest,
371
- resultKind,
372
- reason: checks.join("; "),
373
- parsedReport,
374
- rawRef,
375
- reportPath,
376
- }),
432
+ if (invalidReason) {
433
+ return terminalInvalidReportResult({
434
+ projectRoot, change, jobId, job, reportDigest, reason: invalidReason, parsedReport, reportPath,
435
+ });
436
+ }
437
+ // Code-state-sensitive review jobs must record project-local fallback paths for later scope scans.
438
+ if (checks.length > 0 && projectLocalInvalidReportMustTerminate(job) && reportPath) {
439
+ return terminalInvalidReportResult({
440
+ projectRoot, change, jobId, job, reportDigest, reason: checks.join("; "), parsedReport, reportPath,
377
441
  });
378
- appendEvent(projectRoot, change, rejectEvent);
379
- return {
380
- event_type: "job_rejected",
381
- accepted: false,
382
- message: `工作项 ${jobId} 被拒绝:${checks.join("; ")}`,
383
- job_state: "rejected",
384
- };
385
442
  }
443
+ if (checks.length > 0)
444
+ return retryableJobSubmitResult(jobId, checks);
386
445
  if (job.role !== "code-reviewer" && parsedReport?.verdict === "fail") {
387
446
  const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
388
447
  const rejectEvent = makeEvent(change, "job_rejected", failedReviewReportRejectEventPayload({
@@ -412,23 +471,12 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
412
471
  if (verdict === "pass") {
413
472
  if (blockingCount > 0) {
414
473
  const reason = "报告结论为 pass 时不能包含 blocking:true 的阻塞问题";
415
- const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
416
- appendEvent(projectRoot, change, makeEvent(change, "job_rejected", codeReviewRejectEventPayload({
417
- jobId,
418
- job,
419
- reportDigest,
420
- resultKind: "invalid_report",
421
- reason,
422
- parsedReport,
423
- rawRef,
424
- reportPath,
425
- })));
426
- return {
427
- event_type: "job_rejected",
428
- accepted: false,
429
- message: `工作项 ${jobId} 被拒绝:${reason}`,
430
- job_state: "rejected",
431
- };
474
+ if (projectLocalInvalidReportMustTerminate(job) && reportPath) {
475
+ return terminalInvalidReportResult({
476
+ projectRoot, change, jobId, job, reportDigest, reason, parsedReport, reportPath,
477
+ });
478
+ }
479
+ return retryableJobSubmitResult(jobId, [reason]);
432
480
  }
433
481
  }
434
482
  else if (verdict === "fail") {
@@ -490,10 +538,25 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
490
538
  return terminalJobSubmitResult(events, jobId, terminal, reportDigest);
491
539
  }
492
540
  if (!existsSync(reportFile)) {
493
- return { event_type: "job_rejected", accepted: false, message: `报告文件不存在:${reportFile}` };
541
+ return retryableJobSubmitResult(jobId, [`报告文件不存在:${reportFile}`]);
494
542
  }
495
- const reportContent = readFileSync(reportFile, "utf8");
496
543
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
544
+ let reportContent;
545
+ try {
546
+ reportContent = readRecordInputFile(reportFile);
547
+ }
548
+ catch (err) {
549
+ if (err instanceof RecordInputDecodingError) {
550
+ const reportPath = projectRelativePath(projectRoot, reportFile);
551
+ if (projectLocalInvalidReportMustTerminate(job) && reportPath) {
552
+ return terminalInvalidReportResult({
553
+ projectRoot, change, jobId, job, reportDigest, reason: err.message, parsedReport: null, reportPath,
554
+ });
555
+ }
556
+ return retryableJobSubmitResult(jobId, [err.message]);
557
+ }
558
+ throw err;
559
+ }
497
560
  return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile);
498
561
  });
499
562
  }
@@ -688,7 +751,16 @@ export function recordUserDecision(projectRoot, change, inputFile) {
688
751
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "file_not_found", path: inputFile }));
689
752
  return { event_type: "user_decision_recorded", accepted: false, message: `决策文件不存在:${inputFile}` };
690
753
  }
691
- const content = readFileSync(inputFile, "utf8");
754
+ let content;
755
+ try {
756
+ content = readRecordInputFile(inputFile);
757
+ }
758
+ catch (err) {
759
+ if (err instanceof RecordInputDecodingError) {
760
+ return { accepted: false, message: err.message, events_written: 0 };
761
+ }
762
+ throw err;
763
+ }
692
764
  const inputDigest = sha256File(inputFile) ?? "sha256:unknown";
693
765
  return recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest);
694
766
  });
@@ -739,8 +811,8 @@ function packetFieldDescriptions() {
739
811
  return {
740
812
  job_id: "工作项 ID,用于提交本次审查或验证报告。",
741
813
  packet_digest: "工作项说明摘要,用于证明报告对应的是当前这份工作项说明。",
742
- boundFiles: "本工作项绑定的文件清单;代码审查报告必须说明这些文件是否都看过。",
743
- review_scope: "报告中的审查覆盖范围,说明看了哪些文件、哪些没看及原因。",
814
+ boundFiles: "本工作项绑定的文件清单;审查报告必须说明这些文件是否都看过。",
815
+ review_scope: "报告中的审查覆盖范围;普通 reviewer/verifier 用 checked_paths 回执全部绑定文件,code-reviewer 还需按专用协议说明未检查项。",
744
816
  code_review_scope: "代码审查范围:从已审基点到当前 HEAD 的提交改动、工作区改动和未跟踪代码文件。",
745
817
  task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、声明测试、测试证据和改动文件。",
746
818
  contract: "任务启动时的执行依据快照:tests/design/source/reason/guard 分别对应 测试/设计/来源/原因/边界;null 表示历史任务没有执行依据。",
@@ -768,6 +840,7 @@ export function jobsPacket(projectRoot, change, jobId) {
768
840
  return { found: false, message: `工作项 ${jobId} 不存在` };
769
841
  }
770
842
  const isCodeReviewer = job.role === "code-reviewer";
843
+ const hasReviewScope = requiresReviewScope(job);
771
844
  const packetContext = job.packet_context;
772
845
  const { reviewTargets, readOnlyRefs } = reviewScopeForJob(job);
773
846
  return {
@@ -797,16 +870,22 @@ export function jobsPacket(projectRoot, change, jobId) {
797
870
  file_fallback: true,
798
871
  output_contract_fields: isCodeReviewer
799
872
  ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer", "review_scope"]
800
- : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
873
+ : [
874
+ ...REVIEW_REPORT_REQUIRED_FIELDS,
875
+ ...(requiresReviewer(job.role) ? ["reviewer"] : []),
876
+ ...(hasReviewScope ? ["review_scope"] : []),
877
+ ],
801
878
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
802
879
  字段说明: packetFieldDescriptions(),
803
880
  output_instructions: `${roleDescription(job.role)}。` +
804
881
  reviewScopeInstruction(job, reviewTargets, readOnlyRefs) +
882
+ migrationEvidenceInstruction(job) +
805
883
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
884
+ reviewCoverageInstruction(job) +
806
885
  previousRejectionInstruction(job) +
807
886
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
808
887
  ordinaryReviewerFindingInstruction(job) +
809
- `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
888
+ `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。${recordInputInstruction(job)}协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
810
889
  (isCodeReviewer
811
890
  ? `最小格式:{"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>"}。`
812
891
  + `报告结论为 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 表示需要使用者判断的混合问题。`
@@ -814,13 +893,13 @@ export function jobsPacket(projectRoot, change, jobId) {
814
893
  ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
815
894
  : "")
816
895
  : job.role === "verifier"
817
- ? `最小格式:{"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)的旧证据只能弱引用。` +
896
+ ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}。核对代码审查记录(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)的旧证据只能弱引用。` +
818
897
  (packetContext?.code_state_check
819
898
  ? `本工作项带代码状态检查(code_state_check):head_matches 为 false 或 changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
820
899
  : "")
821
900
  : requiresReviewer(job.role)
822
- ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
823
- : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
901
+ ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""},"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
902
+ : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}`),
824
903
  stop_conditions: ["审查完成后提交报告,不要修改文档"],
825
904
  created_from_transition: job.created_from_transition,
826
905
  },
@@ -0,0 +1,9 @@
1
+ /**
2
+ * JSON record 输入只接受 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE。
3
+ * 不猜测系统 ANSI/GBK 等编码,避免把损坏文本静默归档为 UTF-8 JSONL。
4
+ */
5
+ export declare class RecordInputDecodingError extends Error {
6
+ constructor(message?: string);
7
+ }
8
+ export declare function decodeRecordInput(bytes: Buffer): string;
9
+ export declare function readRecordInputFile(path: string): string;
@@ -0,0 +1,34 @@
1
+ // SuperSpec 流程引擎 — record JSON 输入的无损解码
2
+ import { readFileSync } from "node:fs";
3
+ /**
4
+ * JSON record 输入只接受 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE。
5
+ * 不猜测系统 ANSI/GBK 等编码,避免把损坏文本静默归档为 UTF-8 JSONL。
6
+ */
7
+ export class RecordInputDecodingError extends Error {
8
+ constructor(message = "JSON 输入编码无效:仅支持 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE") {
9
+ super(message);
10
+ this.name = "RecordInputDecodingError";
11
+ }
12
+ }
13
+ function decode(bytes, encoding) {
14
+ try {
15
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
16
+ }
17
+ catch {
18
+ throw new RecordInputDecodingError();
19
+ }
20
+ }
21
+ export function decodeRecordInput(bytes) {
22
+ // UTF-8 BOM.
23
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
24
+ return decode(bytes.subarray(3), "utf-8");
25
+ }
26
+ // Windows PowerShell 5.1 files and redirected output commonly use this form.
27
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
28
+ return decode(bytes.subarray(2), "utf-16le");
29
+ }
30
+ return decode(bytes, "utf-8");
31
+ }
32
+ export function readRecordInputFile(path) {
33
+ return decodeRecordInput(readFileSync(path));
34
+ }
package/dist/task.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord, readEvents } from "./store.js";
5
5
  import { tasksStructureDigest as formatDigest } from "./format.js";
6
+ import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
6
7
  /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
7
8
  export function tasksStructureDigestOf(changeRoot) {
8
9
  const p = join(changeRoot, "tasks.md");
@@ -144,7 +145,14 @@ export function recordTestRun(projectRoot, change, inputFile) {
144
145
  ensureChangeLayout(projectRoot, change);
145
146
  if (!existsSync(inputFile))
146
147
  return { accepted: false, message: `文件不存在:${inputFile}` };
147
- return recordTestRunLoaded(projectRoot, change, readFileSync(inputFile, "utf8"));
148
+ try {
149
+ return recordTestRunLoaded(projectRoot, change, readRecordInputFile(inputFile));
150
+ }
151
+ catch (err) {
152
+ if (err instanceof RecordInputDecodingError)
153
+ return { accepted: false, message: err.message };
154
+ throw err;
155
+ }
148
156
  });
149
157
  }
150
158
  /** record test-run:从 JSON 内容登记测试运行记录 */
package/dist/types.d.ts CHANGED
@@ -297,8 +297,10 @@ export interface TransitionResult {
297
297
  details?: Record<string, unknown>;
298
298
  }
299
299
  export interface RecordResult {
300
- event_type: EventType;
300
+ /** Undefined means the input was rejected before an event was written and may be corrected and resubmitted. */
301
+ event_type?: EventType;
301
302
  accepted: boolean;
302
303
  message: string;
303
304
  job_state?: JobState;
305
+ events_written?: number;
304
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {