@peterxiaoyang/superspec 0.1.40 → 0.1.41

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.
@@ -5,11 +5,23 @@ import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseTasksMd,
5
5
  import { currentGitHead } from "./git_state.js";
6
6
  import { docRef, sha256File } from "./store.js";
7
7
  import { isReviewReadyVerifier, isFreshReviewVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest, } from "./review.js";
8
- import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, collectCodeReviewGateFacts, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, } from "./code_review.js";
8
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewJobStaleReason, collectCodeReviewGateFacts, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
9
+ import { isPhaseAdvanceAuthorized, latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
9
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
10
11
  function requiredJobs(state, jobs, reason) {
11
12
  return { kind: "required_jobs", state, jobs, reason };
12
13
  }
14
+ function phaseConfirmationStep(context, boundary, reason) {
15
+ const confirmation = phaseConfirmationForBoundary(context.projectRoot, context.events, context.snapshot, boundary, context.mode.risk);
16
+ if (!confirmation || isPhaseAdvanceAuthorized(context.events, confirmation))
17
+ return null;
18
+ return {
19
+ kind: "ask_user",
20
+ state: context.snapshot.state,
21
+ ask: confirmation.ask,
22
+ reason,
23
+ };
24
+ }
13
25
  function reviewGatePlan(snapshot, gate, requiredRoles) {
14
26
  const missingRoles = [];
15
27
  for (const role of requiredRoles) {
@@ -63,14 +75,36 @@ function missingBaseArtifact(changeRoot, risk) {
63
75
  return null;
64
76
  }
65
77
  export function proposalDocsBaseline(changeRoot) {
66
- // specs/ 用目录聚合指纹:审查可能只对 specs 提出修复,reopen 后仅改 specs 也算文档变化
67
- const docs = ["proposal.md", "design.md", "tasks.md", "specs/", ".superspec/artifacts/test-contract.md"];
78
+ // 与 Propose gate 的可修改审查目标保持同一来源:specs/ 用目录聚合指纹,避免 reopen 基线漏掉任一个可修改的计划材料。
79
+ const docs = PROPOSE_FINAL_REVIEW_GATE.reviewTargets;
68
80
  const baseline = {};
69
81
  for (const doc of docs) {
70
82
  baseline[doc] = docRef(changeRoot, doc).sha;
71
83
  }
72
84
  return baseline;
73
85
  }
86
+ function isDigestMap(value) {
87
+ if (!value || typeof value !== "object" || Array.isArray(value))
88
+ return false;
89
+ const entries = Object.entries(value);
90
+ return entries.length > 0 && entries.every(([, digest]) => typeof digest === "string");
91
+ }
92
+ export function latestAcceptedProposalBaseline(events) {
93
+ for (let i = events.length - 1; i >= 0; i--) {
94
+ const ev = events[i];
95
+ if (ev.event_type !== "transition_commit")
96
+ continue;
97
+ const payload = ev.payload;
98
+ if (payload.transition !== "accept" ||
99
+ payload.from_state !== "review" ||
100
+ payload.to_state !== "accepted")
101
+ continue;
102
+ return isDigestMap(payload.accepted_baseline_docs)
103
+ ? payload.accepted_baseline_docs
104
+ : null;
105
+ }
106
+ return null;
107
+ }
74
108
  export function latestReopenProposeBaseline(events) {
75
109
  for (let i = events.length - 1; i >= 0; i--) {
76
110
  const ev = events[i];
@@ -185,8 +219,42 @@ export function pendingTaskStatusForApply(changeRoot, events) {
185
219
  export function formatPendingTaskMessage(ids, action) {
186
220
  return `尚有未完成任务:${ids.join(", ")};${action}`;
187
221
  }
222
+ function nextArgv(change, risk) {
223
+ return [
224
+ "superspec",
225
+ "transition",
226
+ "next",
227
+ "--change",
228
+ change,
229
+ ...(risk === "strict" ? [] : ["--risk", risk]),
230
+ ];
231
+ }
232
+ function acceptedMaterialFollowup(change, risk, planDocsChangedSinceAccept) {
233
+ return {
234
+ kind: "accepted_material_followup",
235
+ trigger: "material_user_followup",
236
+ reason_source: "summarize_user_input",
237
+ reopen_argv_template: [
238
+ "superspec",
239
+ "transition",
240
+ "reopen",
241
+ "--change",
242
+ change,
243
+ "--to",
244
+ "propose",
245
+ "--reason",
246
+ "{{reason}}",
247
+ ],
248
+ resume: {
249
+ kind: "continue_current_phase",
250
+ instruction: "根据用户补充更新 proposal/specs/design/tasks/test-contract;完成后重新执行 next。",
251
+ next_argv_after_completion: nextArgv(change, risk),
252
+ },
253
+ plan_docs_changed_since_accept: planDocsChangedSinceAccept,
254
+ };
255
+ }
188
256
  export function planNextStep(context) {
189
- const { changeRoot, mode, snapshot } = context;
257
+ const { change, changeRoot, events, mode, snapshot } = context;
190
258
  switch (snapshot.state) {
191
259
  case "init":
192
260
  if (snapshot.open_jobs.length > 0) {
@@ -217,6 +285,12 @@ export function planNextStep(context) {
217
285
  };
218
286
  return { kind: "ask_user", state: "explore", ask, reason: `有 ${openQs} 个未确认问题` };
219
287
  }
288
+ const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
289
+ if (!reviewGatePlan(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles)) {
290
+ const confirmation = phaseConfirmationStep(context, "explore_to_propose", "探索完成,等待用户确认进入计划阶段");
291
+ if (confirmation)
292
+ return confirmation;
293
+ }
220
294
  return {
221
295
  kind: "run_transition",
222
296
  state: "explore",
@@ -253,6 +327,12 @@ export function planNextStep(context) {
253
327
  if (proposalReviewJobs.length > 0) {
254
328
  return requiredJobs("propose_ready", proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
255
329
  }
330
+ const startApplyPlan = planStartApplyTransition(context, false);
331
+ if (startApplyPlan.kind === "advance") {
332
+ const confirmation = phaseConfirmationStep(context, "propose_to_apply", "计划阶段完成,等待用户确认开始实现");
333
+ if (confirmation)
334
+ return confirmation;
335
+ }
256
336
  return { kind: "run_transition", state: "propose_ready", transition: "start-apply", reason: "计划就绪,开始执行" };
257
337
  }
258
338
  case "apply":
@@ -263,14 +343,28 @@ export function planNextStep(context) {
263
343
  return planReviewNext(context);
264
344
  case "accepted":
265
345
  if (snapshot.open_jobs.length > 0) {
266
- return requiredJobs("accepted", snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项,暂不归档`);
346
+ return requiredJobs("accepted", snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项,暂不结束流程`);
347
+ }
348
+ {
349
+ const acceptedBaseline = latestAcceptedProposalBaseline(events);
350
+ const planDocsChanged = acceptedBaseline
351
+ ? proposalDocsChangedSinceBaseline(changeRoot, acceptedBaseline)
352
+ : null;
353
+ const driftNote = planDocsChanged === true
354
+ ? "检测到 accepted 后计划材料已变化;当前完成结论仍对应 accepted 时冻结的版本。"
355
+ : "";
356
+ return {
357
+ kind: "done",
358
+ state: "accepted",
359
+ reason: `${driftNote}审查已接受,流程完成;后续若使用者补充或修改需求、方案、验收或实现约束,按 continuation 自动回到 propose 后继续,不得要求使用者执行工作流命令`,
360
+ continuation: acceptedMaterialFollowup(change, mode.risk, planDocsChanged),
361
+ };
267
362
  }
268
- return { kind: "ask_archive_confirmation", state: "accepted", reason: "审查通过,等待用户确认归档" };
269
363
  case "archive":
270
364
  if (snapshot.open_jobs.length > 0) {
271
- return requiredJobs("archive", snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项,暂不结束`);
365
+ return requiredJobs("archive", snapshot.open_jobs, `历史 archive 状态仍有 ${snapshot.open_jobs.length} 个待完成工作项`);
272
366
  }
273
- return { kind: "done", state: "archive", reason: "已归档,流程完成。" };
367
+ return { kind: "done", state: "archive", reason: "历史 archive 状态,流程已结束。" };
274
368
  case "abandoned":
275
369
  return { kind: "done", state: "abandoned", reason: "变更已放弃,流程终止。" };
276
370
  default:
@@ -320,6 +414,14 @@ function planApplyNext(context) {
320
414
  reason: "所有任务完成,进入审查",
321
415
  };
322
416
  }
417
+ export function blockingJobsForApplyDone(projectRoot, events, snapshot) {
418
+ const facts = collectCodeReviewGateFacts(events);
419
+ const currentWorkingPaths = currentCodeReviewWorkingPaths(projectRoot, events);
420
+ const freshCodeReviewJobIds = new Set(facts.openJobs
421
+ .filter(job => codeReviewJobStaleReason(projectRoot, job, currentWorkingPaths) == null)
422
+ .map(job => job.job_id));
423
+ return snapshot.open_jobs.filter(job => job.role !== "code-reviewer" || freshCodeReviewJobIds.has(job.job_id));
424
+ }
323
425
  function planApplyDoneNext(context) {
324
426
  const { change, changeRoot, events, mode, snapshot } = context;
325
427
  const pendingTasks = pendingTaskStatusForApply(changeRoot, events).pending;
@@ -332,10 +434,12 @@ function planApplyDoneNext(context) {
332
434
  reason: `发现未完成任务 ${pendingTasks[0]},回到执行阶段`,
333
435
  };
334
436
  }
335
- if (snapshot.open_jobs.length > 0) {
336
- return requiredJobs("apply_done", snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
337
- }
338
437
  const facts = collectCodeReviewGateFacts(events);
438
+ const currentWorkingPaths = currentCodeReviewWorkingPaths(context.projectRoot, events);
439
+ const relevantOpenJobs = blockingJobsForApplyDone(context.projectRoot, events, snapshot);
440
+ if (relevantOpenJobs.length > 0) {
441
+ return requiredJobs("apply_done", relevantOpenJobs, `有 ${relevantOpenJobs.length} 个待完成工作项`);
442
+ }
339
443
  const latest = facts.latestTerminal;
340
444
  if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
341
445
  const status = latestCodeReviewFailedStatus(events);
@@ -423,6 +527,19 @@ function planApplyDoneNext(context) {
423
527
  };
424
528
  return { kind: "ask_user", state: "apply_done", ask, reason: "代码审查报告连续不符合要求或没有可处理问题" };
425
529
  }
530
+ const codeScan = scanCodeChangesForReview(context.projectRoot, events);
531
+ const acceptedScope = latest?.state === "accepted"
532
+ ? latest.job.packet_context?.code_review_scope
533
+ : undefined;
534
+ const acceptedCurrentHead = acceptedScope?.current_head;
535
+ const acceptedReviewReady = latest?.state === "accepted" &&
536
+ codeReviewJobStaleReason(context.projectRoot, latest.job, currentWorkingPaths) == null && (acceptedCurrentHead === null ||
537
+ (typeof acceptedCurrentHead === "string" && acceptedCurrentHead.trim() !== ""));
538
+ if (!codeScan.hasCodeChanges || acceptedReviewReady) {
539
+ const confirmation = phaseConfirmationStep(context, "apply_to_review", "Apply 与代码审查完成,等待用户确认进入最终审查");
540
+ if (confirmation)
541
+ return confirmation;
542
+ }
426
543
  return {
427
544
  kind: "run_transition",
428
545
  state: "apply_done",
@@ -485,7 +602,7 @@ export function planTransition(name, context) {
485
602
  }
486
603
  }
487
604
  function planExploreTransition(context) {
488
- const { changeRoot, mode, snapshot } = context;
605
+ const { changeRoot, events, mode, projectRoot, snapshot } = context;
489
606
  if (snapshot.state === "init") {
490
607
  return { kind: "advance", fromState: "init", toState: "explore", reason: "进入探索阶段" };
491
608
  }
@@ -499,7 +616,18 @@ function planExploreTransition(context) {
499
616
  const gatePlan = reviewGatePlan(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
500
617
  if (gatePlan)
501
618
  return gatePlan;
502
- return { kind: "advance", fromState: "explore", toState: "propose", reason: "探索完成" };
619
+ const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "explore_to_propose");
620
+ const decision = confirmation ? latestAcceptedPhaseDecision(events, confirmation) : null;
621
+ if (!confirmation || decision?.decision !== "advance") {
622
+ return { kind: "skip", message: confirmation ? phaseConfirmationMissingMessage(confirmation) : "无法建立 Explore 阶段确认范围" };
623
+ }
624
+ return {
625
+ kind: "advance",
626
+ fromState: "explore",
627
+ toState: "propose",
628
+ reason: "探索完成",
629
+ payload: phaseConfirmationCommitPayload(confirmation, decision),
630
+ };
503
631
  }
504
632
  function planProposeReadyTransition(context) {
505
633
  const { changeRoot, mode, snapshot } = context;
@@ -526,7 +654,7 @@ function planProposeReadyTransition(context) {
526
654
  return gatePlan;
527
655
  return { kind: "advance", fromState: "propose", toState: "propose_ready", reason: `risk=${risk},所有需求已满足` };
528
656
  }
529
- function planStartApplyTransition(context) {
657
+ function planStartApplyTransition(context, enforceConfirmation = true) {
530
658
  const { changeRoot, events, projectRoot, snapshot } = context;
531
659
  if (snapshot.state !== "propose_ready")
532
660
  return { kind: "skip", message: `当前状态 ${snapshot.state},需要 propose_ready` };
@@ -548,6 +676,11 @@ function planStartApplyTransition(context) {
548
676
  const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot);
549
677
  if (!executionRequirementPlan.ok)
550
678
  return { kind: "skip", message: executionRequirementPlan.message };
679
+ const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "propose_to_apply");
680
+ const decision = confirmation ? latestAcceptedPhaseDecision(events, confirmation) : null;
681
+ if (enforceConfirmation && (!confirmation || decision?.decision !== "advance")) {
682
+ return { kind: "skip", message: confirmation ? phaseConfirmationMissingMessage(confirmation) : "无法建立 Propose 阶段确认范围" };
683
+ }
551
684
  const gitHead = currentGitHead(projectRoot);
552
685
  return {
553
686
  kind: "advance",
@@ -558,6 +691,7 @@ function planStartApplyTransition(context) {
558
691
  apply_start_head: gitHead.head,
559
692
  apply_start_head_reason: gitHead.reason,
560
693
  apply_contract_mode: executionRequirementPlan.mode,
694
+ ...(confirmation && decision?.decision === "advance" ? phaseConfirmationCommitPayload(confirmation, decision) : {}),
561
695
  },
562
696
  };
563
697
  }
@@ -578,5 +712,11 @@ function planAcceptTransition(context) {
578
712
  if (!verifierAccepted)
579
713
  return { kind: "skip", message: "缺少仍然匹配当前证据的最终验证,请先运行 review-ready" };
580
714
  }
581
- return { kind: "advance", fromState: "review", toState: "accepted", reason: "审查通过" };
715
+ return {
716
+ kind: "advance",
717
+ fromState: "review",
718
+ toState: "accepted",
719
+ reason: "审查通过",
720
+ payload: { accepted_baseline_docs: proposalDocsBaseline(changeRoot) },
721
+ };
582
722
  }
package/dist/record.js CHANGED
@@ -2,10 +2,14 @@
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 { rebuildSnapshot } from "./sync.js";
6
+ import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
+ import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, } from "./phase_confirmation.js";
5
8
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
6
9
  import { invalidReasonForSubmittedReport } from "./job_validity.js";
7
10
  import { jobSubmitArgv } from "./job_action.js";
8
11
  import { REVIEW_DOC_PATHS } from "./review.js";
12
+ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
9
13
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
10
14
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
11
15
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -13,6 +17,9 @@ const CODE_REVIEW_FINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
13
17
  function requiresReviewer(role) {
14
18
  return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer";
15
19
  }
20
+ function isOrdinaryReviewer(role) {
21
+ return role === "critic" || role === "architect" || role === "test-engineer";
22
+ }
16
23
  function recommendedAgentForRole(role) {
17
24
  switch (role) {
18
25
  case "critic": return "critic";
@@ -42,6 +49,43 @@ function roleDescription(role) {
42
49
  return "执行受限测试工作项";
43
50
  }
44
51
  }
52
+ function previousRejectionInstruction(job) {
53
+ const previous = job.previous_rejection;
54
+ if (!previous)
55
+ return "";
56
+ const reason = `上一次同角色审查没有形成可推进结论,原因:${previous.reason}。`;
57
+ if (!previous.findings || previous.findings.length === 0)
58
+ return `${reason}本次请先针对该原因重新审查,`;
59
+ return `${reason}本工作项附带上一次同角色审查尚未闭环的问题列表;请优先逐项复核:同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题;新增问题必须提供与历史问题不同的具体证据。`;
60
+ }
61
+ function ordinaryReviewerFindingInstruction(job) {
62
+ if (!isOrdinaryReviewer(job.role))
63
+ return "";
64
+ return "问题列表中的每个新 finding 必须分配稳定 ID,后续同一问题沿用该 ID,";
65
+ }
66
+ function reviewScopeForJob(job) {
67
+ if (job.review_targets !== undefined || job.read_only_refs !== undefined) {
68
+ return {
69
+ reviewTargets: job.review_targets ?? [],
70
+ readOnlyRefs: job.read_only_refs ?? [],
71
+ };
72
+ }
73
+ const gate = [EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE]
74
+ .find(candidate => candidate.isJobForGate(job));
75
+ return gate
76
+ ? { reviewTargets: [...gate.reviewTargets], readOnlyRefs: [...gate.readOnlyRefs] }
77
+ : { reviewTargets: [], readOnlyRefs: [] };
78
+ }
79
+ function reviewScopeInstruction(job, reviewTargets, readOnlyRefs) {
80
+ if (reviewTargets.length === 0 && readOnlyRefs.length === 0) {
81
+ return `请审查 ${job.boundFiles.map(file => file.path).join(", ")},`;
82
+ }
83
+ const targets = reviewTargets.length > 0 ? `本 gate 可提出修改建议的审查目标为 ${reviewTargets.join(", ")}。` : "";
84
+ const refs = readOnlyRefs.length > 0
85
+ ? `只读上游引用为 ${readOnlyRefs.join(", ")};只允许读取和核对一致性,不得要求在当前阶段修改、追加、删除、重排或格式化这些文件,不得把修改只读引用列为本阶段 required fix。只读引用自身的缺失、错误或矛盾可在 risks 或 open_questions 中上报给主流程,但不得单独作为本 gate 的 fail。只有能定位到审查目标的不一致,才能作为 fail 并指向该审查目标的修复。`
86
+ : "";
87
+ return targets + refs;
88
+ }
45
89
  function asObject(value) {
46
90
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
47
91
  }
@@ -205,6 +249,7 @@ function failedReviewReportRejectEventPayload(input) {
205
249
  job_id: input.jobId,
206
250
  role: input.job.role,
207
251
  report_digest: input.reportDigest,
252
+ result_kind: "review_failed",
208
253
  reason: "报告结论为 fail,工作项未通过",
209
254
  findings: Array.isArray(input.parsedReport.findings) ? input.parsedReport.findings : [],
210
255
  ...input.rawRef,
@@ -286,6 +331,9 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
286
331
  if (!Array.isArray(obj.findings)) {
287
332
  checks.push("报告问题列表 findings 必须是数组");
288
333
  }
334
+ else if (isOrdinaryReviewer(job.role) && obj.verdict === "fail" && obj.findings.length === 0) {
335
+ checks.push("普通审查报告结论为 fail 时 findings 至少包含一个问题");
336
+ }
289
337
  if (requiresReviewer(job.role)) {
290
338
  validateReviewer(obj, checks);
291
339
  }
@@ -311,29 +359,21 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
311
359
  checks.push("报告内容为空");
312
360
  }
313
361
  if (checks.length > 0) {
314
- const resultKind = job.role === "code-reviewer" ? "invalid_report" : undefined;
315
- const rawRef = resultKind && parsedReport
362
+ const resultKind = "invalid_report";
363
+ const rawRef = job.role === "code-reviewer" && parsedReport
316
364
  ? appendRawRecord(projectRoot, change, "review-reports", parsedReport)
317
365
  : null;
318
366
  const rejectEvent = makeEvent(change, "job_rejected", {
319
- ...(resultKind
320
- ? codeReviewRejectEventPayload({
321
- jobId,
322
- job,
323
- reportDigest,
324
- resultKind,
325
- reason: checks.join("; "),
326
- parsedReport,
327
- rawRef,
328
- reportPath,
329
- })
330
- : {
331
- job_id: jobId,
332
- role: job.role,
333
- report_digest: reportDigest,
334
- ...(reportPath ? { report_path: reportPath } : {}),
335
- reason: checks.join("; "),
336
- }),
367
+ ...codeReviewRejectEventPayload({
368
+ jobId,
369
+ job,
370
+ reportDigest,
371
+ resultKind,
372
+ reason: checks.join("; "),
373
+ parsedReport,
374
+ rawRef,
375
+ reportPath,
376
+ }),
337
377
  });
338
378
  appendEvent(projectRoot, change, rejectEvent);
339
379
  return {
@@ -475,21 +515,21 @@ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, r
475
515
  });
476
516
  }
477
517
  function recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest) {
478
- const existing = events.find(e => e.event_type === "user_decision_recorded"
518
+ const existing = [...events].reverse().find(e => e.event_type === "user_decision_recorded"
479
519
  && e.payload.input_digest === inputDigest);
480
- if (existing) {
481
- const accepted = existing.payload.accepted !== false;
482
- return {
483
- event_type: "user_decision_recorded",
484
- accepted,
485
- message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
486
- };
487
- }
488
520
  let decision;
489
521
  try {
490
522
  decision = JSON.parse(content);
491
523
  }
492
524
  catch {
525
+ if (existing) {
526
+ const accepted = existing.payload.accepted !== false;
527
+ return {
528
+ event_type: "user_decision_recorded",
529
+ accepted,
530
+ message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
531
+ };
532
+ }
493
533
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
494
534
  accepted: false,
495
535
  reason: "invalid_json",
@@ -505,6 +545,76 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
505
545
  }));
506
546
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少决策范围(scope)或答复内容(answer)" };
507
547
  }
548
+ let phaseConfirmation = null;
549
+ let phaseAction = null;
550
+ if (isPhaseConfirmationScope(decision.scope)) {
551
+ const existingAccepted = existing &&
552
+ existing.payload.accepted !== false;
553
+ const latestAcceptedForScope = [...events].reverse().find(event => {
554
+ if (event.event_type !== "user_decision_recorded")
555
+ return false;
556
+ const payload = event.payload;
557
+ return payload.accepted !== false &&
558
+ payload.scope === decision.scope &&
559
+ payload.phase_confirmation != null;
560
+ });
561
+ const changeRoot = openspecChangeRoot(projectRoot, change);
562
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
563
+ const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot);
564
+ if (existingAccepted &&
565
+ current?.scope === decision.scope &&
566
+ latestAcceptedForScope?.event_id === existing.event_id) {
567
+ return {
568
+ event_type: "user_decision_recorded",
569
+ accepted: true,
570
+ message: "幂等返回:同一用户决策已登记",
571
+ };
572
+ }
573
+ const action = current ? phaseActionForAnswer(current, decision.answer) : null;
574
+ const rejectionReason = !current
575
+ ? "phase_confirmation_not_pending"
576
+ : decision.scope !== current.scope
577
+ ? "stale_phase_confirmation_scope"
578
+ : !action
579
+ ? "invalid_phase_confirmation_answer"
580
+ : action.reason === "required" && !nonEmptyString(decision.reason)
581
+ ? "missing_phase_confirmation_reason"
582
+ : null;
583
+ if (rejectionReason) {
584
+ if (existing &&
585
+ existing.payload.accepted === false &&
586
+ existing.payload.reason === rejectionReason) {
587
+ return {
588
+ event_type: "user_decision_recorded",
589
+ accepted: false,
590
+ message: "幂等返回:同一无效阶段确认已登记",
591
+ };
592
+ }
593
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
594
+ accepted: false,
595
+ scope: decision.scope,
596
+ answer: decision.answer,
597
+ reason: rejectionReason,
598
+ input_digest: inputDigest,
599
+ }));
600
+ const message = rejectionReason === "invalid_phase_confirmation_answer" && current
601
+ ? `阶段确认答复必须精确为:${current.ask.allowed_answers.join("、")}`
602
+ : rejectionReason === "missing_phase_confirmation_reason" && action
603
+ ? action.reason_prompt ?? "当前选择必须写明原因"
604
+ : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
605
+ return { event_type: "user_decision_recorded", accepted: false, message };
606
+ }
607
+ phaseConfirmation = current;
608
+ phaseAction = action;
609
+ }
610
+ if (existing && !phaseAction) {
611
+ const accepted = existing.payload.accepted !== false;
612
+ return {
613
+ event_type: "user_decision_recorded",
614
+ accepted,
615
+ message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
616
+ };
617
+ }
508
618
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
509
619
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
510
620
  if (!normalizedAnswer) {
@@ -541,9 +651,19 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
541
651
  : null;
542
652
  const normalizedDecision = {
543
653
  scope: decision.scope,
544
- question: typeof decision.question === "string" ? decision.question : "",
545
- answer: normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
654
+ question: phaseConfirmation
655
+ ? phaseConfirmation.ask.question
656
+ : typeof decision.question === "string" ? decision.question : "",
657
+ answer: phaseAction
658
+ ? phaseAction.label
659
+ : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
546
660
  ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
661
+ ...(phaseAction ? {
662
+ phase_confirmation: {
663
+ boundary: phaseAction.boundary,
664
+ decision: phaseAction.decision,
665
+ },
666
+ } : {}),
547
667
  };
548
668
  const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
549
669
  const event = makeEvent(change, "user_decision_recorded", {
@@ -649,6 +769,7 @@ export function jobsPacket(projectRoot, change, jobId) {
649
769
  }
650
770
  const isCodeReviewer = job.role === "code-reviewer";
651
771
  const packetContext = job.packet_context;
772
+ const { reviewTargets, readOnlyRefs } = reviewScopeForJob(job);
652
773
  return {
653
774
  found: true,
654
775
  packet: {
@@ -657,6 +778,8 @@ export function jobsPacket(projectRoot, change, jobId) {
657
778
  ...(job.gate_id ? { gate_id: job.gate_id } : {}),
658
779
  recommended_agent: recommendedAgentForRole(job.role),
659
780
  boundFiles: job.boundFiles,
781
+ ...(reviewTargets.length > 0 ? { review_targets: reviewTargets } : {}),
782
+ ...(readOnlyRefs.length > 0 ? { read_only_refs: readOnlyRefs } : {}),
660
783
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
661
784
  ...(job.previous_rejection ? { previous_rejection: job.previous_rejection } : {}),
662
785
  ...(packetContext ? { packet_context: packetContext } : {}),
@@ -677,10 +800,12 @@ export function jobsPacket(projectRoot, change, jobId) {
677
800
  : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
678
801
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
679
802
  字段说明: packetFieldDescriptions(),
680
- output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
803
+ output_instructions: `${roleDescription(job.role)}。` +
804
+ reviewScopeInstruction(job, reviewTargets, readOnlyRefs) +
681
805
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
682
- (job.previous_rejection ? `上一次代码审查没有形成可推进结论,原因:${job.previous_rejection.reason}。本次请根据该原因重新审查,` : "") +
806
+ previousRejectionInstruction(job) +
683
807
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
808
+ ordinaryReviewerFindingInstruction(job) +
684
809
  `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
685
810
  (isCodeReviewer
686
811
  ? `最小格式:{"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>"}。`
package/dist/review.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Event, Job, Ref } from "./types.ts";
1
+ import { type ReviewGateRule } from "./review_job_gates.ts";
2
+ import type { Event, Job, JobRole, Ref, ReviewPreviousRejection } from "./types.ts";
2
3
  export type ReviewRisk = "minimal" | "normal" | "strict";
3
4
  export interface ReviewPolicy {
4
5
  review_risk: ReviewRisk;
@@ -8,6 +9,7 @@ export declare const REVIEW_DOC_PATHS: string[];
8
9
  export declare function assertCommitPayloadExtension(payload: Record<string, unknown>): void;
9
10
  export declare function reviewPolicyForRisk(risk: ReviewRisk): ReviewPolicy;
10
11
  export declare function readReviewPolicyFromEvents(events: Event[]): ReviewPolicy | null;
12
+ export declare function latestReviewHistoryForGateRole(events: Event[], gate: ReviewGateRule, role: JobRole): ReviewPreviousRejection | null;
11
13
  export declare function isReviewReadyVerifier(job: Job): boolean;
12
14
  export declare function reviewBoundFiles(changeRoot: string): Ref[];
13
15
  export declare function reviewEvidenceDigest(events: Event[]): string;