@peterxiaoyang/superspec 0.1.24 → 0.1.26

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/record.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, withLock, appendRawRecord, } from "./store.js";
4
+ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
5
  import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
+ import { jobSubmitArgv } from "./job_action.js";
6
7
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
7
8
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
8
9
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -57,142 +58,194 @@ function jobTerminalState(events, jobId) {
57
58
  }
58
59
  return null;
59
60
  }
61
+ function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
62
+ const existing = events.find(e => (e.event_type === "job_accepted" || e.event_type === "job_rejected")
63
+ && e.payload.job_id === jobId
64
+ && e.payload.report_digest === reportDigest);
65
+ if (existing) {
66
+ return {
67
+ event_type: existing.event_type,
68
+ accepted: existing.event_type === "job_accepted",
69
+ message: "幂等返回:同 report 已提交",
70
+ job_state: existing.event_type === "job_accepted" ? "accepted" : "rejected",
71
+ };
72
+ }
73
+ return {
74
+ event_type: "job_rejected",
75
+ accepted: false,
76
+ message: `工作项 ${jobId} 已终态(${terminal}),不接受新报告。需要新工作项请重跑 transition。`,
77
+ };
78
+ }
79
+ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest) {
80
+ const checks = [];
81
+ let parsedReport = null;
82
+ try {
83
+ const report = JSON.parse(reportContent);
84
+ if (!report || typeof report !== "object" || Array.isArray(report)) {
85
+ checks.push("报告必须是 JSON object");
86
+ }
87
+ else {
88
+ parsedReport = report;
89
+ const obj = parsedReport;
90
+ for (const field of REVIEW_REPORT_REQUIRED_FIELDS) {
91
+ if (!(field in obj))
92
+ checks.push(`报告缺少必填字段 ${field}`);
93
+ }
94
+ if (obj.role !== job.role) {
95
+ checks.push(`报告角色 ${String(obj.role)} 与工作项角色 ${job.role} 不匹配`);
96
+ }
97
+ if (obj.verdict !== "pass" && obj.verdict !== "fail") {
98
+ checks.push("报告 verdict 必须是 pass 或 fail");
99
+ }
100
+ if (!Array.isArray(obj.findings)) {
101
+ checks.push("报告 findings 必须是数组");
102
+ }
103
+ if (obj.verdict === "fail") {
104
+ checks.push("报告 verdict=fail,工作项未通过");
105
+ }
106
+ if (requiresReviewer(job.role)) {
107
+ if (!("reviewer" in obj))
108
+ checks.push("报告缺少必填字段 reviewer");
109
+ const reviewer = obj.reviewer;
110
+ if (!reviewer || typeof reviewer !== "object" || Array.isArray(reviewer)) {
111
+ checks.push("报告 reviewer 必须是包含 kind/id 的对象");
112
+ }
113
+ else {
114
+ if (typeof reviewer.kind !== "string" || !REVIEWER_KINDS.has(reviewer.kind)) {
115
+ checks.push(`报告 reviewer.kind 必须是 ${[...REVIEWER_KINDS].join("|")} 之一`);
116
+ }
117
+ if (typeof reviewer.id !== "string" || reviewer.id.trim() === "") {
118
+ checks.push("报告 reviewer.id 必须是非空字符串");
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ catch {
125
+ checks.push("报告必须是有效 JSON");
126
+ }
127
+ for (const bf of job.boundFiles) {
128
+ const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
129
+ if (currentSha !== bf.sha) {
130
+ checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
131
+ }
132
+ }
133
+ const reviewStaleReason = reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
134
+ if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
135
+ checks.push(reviewStaleReason);
136
+ }
137
+ if (!reportContent.trim()) {
138
+ checks.push("报告内容为空");
139
+ }
140
+ if (checks.length > 0) {
141
+ const rejectEvent = makeEvent(change, "job_rejected", {
142
+ job_id: jobId,
143
+ role: job.role,
144
+ report_digest: reportDigest,
145
+ reason: checks.join("; "),
146
+ });
147
+ appendEvent(projectRoot, change, rejectEvent);
148
+ return {
149
+ event_type: "job_rejected",
150
+ accepted: false,
151
+ message: `工作项 ${jobId} 被拒绝:${checks.join("; ")}`,
152
+ job_state: "rejected",
153
+ };
154
+ }
155
+ const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
156
+ const acceptEvent = makeEvent(change, "job_accepted", {
157
+ job_id: jobId,
158
+ role: job.role,
159
+ report_digest: reportDigest,
160
+ accepted_at: new Date().toISOString(),
161
+ ...rawRef,
162
+ });
163
+ appendEvent(projectRoot, change, acceptEvent);
164
+ return {
165
+ event_type: "job_accepted",
166
+ accepted: true,
167
+ message: `工作项 ${jobId}(${job.role})已接受`,
168
+ job_state: "accepted",
169
+ };
170
+ }
60
171
  /** record job-submit:登记工作项结果 */
61
172
  export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFile) {
62
173
  return withLock(projectRoot, change, () => {
63
174
  ensureChangeLayout(projectRoot, change);
64
175
  const events = readEvents(projectRoot, change);
65
- // 查找 job
66
176
  const job = findJob(events, jobId);
67
177
  if (!job) {
68
178
  return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
69
179
  }
70
- // 检查终态
71
180
  const terminal = jobTerminalState(events, jobId);
72
181
  if (terminal) {
73
- // 幂等检查:同 report_digest → 返回旧结果
74
182
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
75
- const existing = events.find(e => (e.event_type === "job_accepted" || e.event_type === "job_rejected")
76
- && e.payload.job_id === jobId
77
- && e.payload.report_digest === reportDigest);
78
- if (existing) {
79
- return {
80
- event_type: existing.event_type,
81
- accepted: existing.event_type === "job_accepted",
82
- message: "幂等返回:同 report 已提交",
83
- job_state: existing.event_type === "job_accepted" ? "accepted" : "rejected",
84
- };
85
- }
86
- // 终态 job + 不同 report → 拒绝
87
- return {
88
- event_type: "job_rejected",
89
- accepted: false,
90
- message: `工作项 ${jobId} 已终态(${terminal}),不接受新报告。需要新工作项请重跑 transition。`,
91
- };
183
+ return terminalJobSubmitResult(events, jobId, terminal, reportDigest);
92
184
  }
93
- // 读报告
94
185
  if (!existsSync(reportFile)) {
95
186
  return { event_type: "job_rejected", accepted: false, message: `报告文件不存在:${reportFile}` };
96
187
  }
97
188
  const reportContent = readFileSync(reportFile, "utf8");
98
189
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
99
- // acceptance checks
100
- const checks = [];
101
- let parsedReport = null;
102
- // 0. 报告格式和角色匹配(最小 JSON contract)
103
- try {
104
- const report = JSON.parse(reportContent);
105
- if (!report || typeof report !== "object" || Array.isArray(report)) {
106
- checks.push("报告必须是 JSON object");
107
- }
108
- else {
109
- parsedReport = report;
110
- const obj = parsedReport;
111
- for (const field of REVIEW_REPORT_REQUIRED_FIELDS) {
112
- if (!(field in obj))
113
- checks.push(`报告缺少必填字段 ${field}`);
114
- }
115
- if (obj.role !== job.role) {
116
- checks.push(`报告角色 ${String(obj.role)} 与工作项角色 ${job.role} 不匹配`);
117
- }
118
- if (obj.verdict !== "pass" && obj.verdict !== "fail") {
119
- checks.push("报告 verdict 必须是 pass 或 fail");
120
- }
121
- if (!Array.isArray(obj.findings)) {
122
- checks.push("报告 findings 必须是数组");
123
- }
124
- if (obj.verdict === "fail") {
125
- checks.push("报告 verdict=fail,工作项未通过");
126
- }
127
- if (requiresReviewer(job.role)) {
128
- if (!("reviewer" in obj))
129
- checks.push("报告缺少必填字段 reviewer");
130
- const reviewer = obj.reviewer;
131
- if (!reviewer || typeof reviewer !== "object" || Array.isArray(reviewer)) {
132
- checks.push("报告 reviewer 必须是包含 kind/id 的对象");
133
- }
134
- else {
135
- if (typeof reviewer.kind !== "string" || !REVIEWER_KINDS.has(reviewer.kind)) {
136
- checks.push(`报告 reviewer.kind 必须是 ${[...REVIEWER_KINDS].join("|")} 之一`);
137
- }
138
- if (typeof reviewer.id !== "string" || reviewer.id.trim() === "") {
139
- checks.push("报告 reviewer.id 必须是非空字符串");
140
- }
141
- }
142
- }
143
- }
144
- }
145
- catch {
146
- checks.push("报告必须是有效 JSON");
147
- }
148
- // 1. boundFiles 仍匹配当前文档(missing 也算不匹配)
149
- for (const bf of job.boundFiles) {
150
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
151
- if (currentSha !== bf.sha) {
152
- checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
153
- }
154
- }
155
- const reviewStaleReason = reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
156
- if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
157
- checks.push(reviewStaleReason);
158
- }
159
- // 2. 报告格式基本校验(非空 JSON 或文本)
160
- if (!reportContent.trim()) {
161
- checks.push("报告内容为空");
190
+ return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest);
191
+ });
192
+ }
193
+ /** record job-submit:从 JSON 内容登记工作项结果 */
194
+ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, reportContent) {
195
+ return withLock(projectRoot, change, () => {
196
+ ensureChangeLayout(projectRoot, change);
197
+ const events = readEvents(projectRoot, change);
198
+ const job = findJob(events, jobId);
199
+ if (!job) {
200
+ return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
162
201
  }
163
- if (checks.length > 0) {
164
- // 拒绝
165
- const rejectEvent = makeEvent(change, "job_rejected", {
166
- job_id: jobId,
167
- role: job.role,
168
- report_digest: reportDigest,
169
- reason: checks.join("; "),
170
- });
171
- appendEvent(projectRoot, change, rejectEvent);
172
- return {
173
- event_type: "job_rejected",
174
- accepted: false,
175
- message: `工作项 ${jobId} 被拒绝:${checks.join("; ")}`,
176
- job_state: "rejected",
177
- };
202
+ const reportDigest = sha256Text(reportContent);
203
+ const terminal = jobTerminalState(events, jobId);
204
+ if (terminal) {
205
+ return terminalJobSubmitResult(events, jobId, terminal, reportDigest);
178
206
  }
179
- // 接受
180
- const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
181
- const acceptEvent = makeEvent(change, "job_accepted", {
182
- job_id: jobId,
183
- role: job.role,
184
- report_digest: reportDigest,
185
- accepted_at: new Date().toISOString(),
186
- ...rawRef,
187
- });
188
- appendEvent(projectRoot, change, acceptEvent);
207
+ return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest);
208
+ });
209
+ }
210
+ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest) {
211
+ let decision;
212
+ try {
213
+ decision = JSON.parse(content);
214
+ }
215
+ catch {
216
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "invalid_json" }));
217
+ return { event_type: "user_decision_recorded", accepted: false, message: "决策文件不是有效 JSON" };
218
+ }
219
+ if (!decision.scope || !decision.answer) {
220
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "missing_scope_or_answer" }));
221
+ return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scope 或 answer" };
222
+ }
223
+ const existing = events.find(e => e.event_type === "user_decision_recorded"
224
+ && e.payload.input_digest === inputDigest);
225
+ if (existing) {
189
226
  return {
190
- event_type: "job_accepted",
227
+ event_type: "user_decision_recorded",
191
228
  accepted: true,
192
- message: `工作项 ${jobId}(${job.role})已接受`,
193
- job_state: "accepted",
229
+ message: "幂等返回:同 user decision 已登记",
194
230
  };
231
+ }
232
+ const normalizedDecision = {
233
+ scope: decision.scope,
234
+ question: decision.question ?? "",
235
+ answer: decision.answer,
236
+ };
237
+ const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
238
+ const event = makeEvent(change, "user_decision_recorded", {
239
+ ...normalizedDecision,
240
+ input_digest: inputDigest,
241
+ ...rawRef,
195
242
  });
243
+ appendEvent(projectRoot, change, event);
244
+ return {
245
+ event_type: "user_decision_recorded",
246
+ accepted: true,
247
+ message: `用户决策已登记:scope=${decision.scope}`,
248
+ };
196
249
  }
197
250
  /** record user-decision:登记用户决策 */
198
251
  export function recordUserDecision(projectRoot, change, inputFile) {
@@ -204,45 +257,16 @@ export function recordUserDecision(projectRoot, change, inputFile) {
204
257
  return { event_type: "user_decision_recorded", accepted: false, message: `决策文件不存在:${inputFile}` };
205
258
  }
206
259
  const content = readFileSync(inputFile, "utf8");
207
- let decision;
208
- try {
209
- decision = JSON.parse(content);
210
- }
211
- catch {
212
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "invalid_json" }));
213
- return { event_type: "user_decision_recorded", accepted: false, message: "决策文件不是有效 JSON" };
214
- }
215
- if (!decision.scope || !decision.answer) {
216
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "missing_scope_or_answer" }));
217
- return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scope 或 answer" };
218
- }
219
260
  const inputDigest = sha256File(inputFile) ?? "sha256:unknown";
220
- const existing = events.find(e => e.event_type === "user_decision_recorded"
221
- && e.payload.input_digest === inputDigest);
222
- if (existing) {
223
- return {
224
- event_type: "user_decision_recorded",
225
- accepted: true,
226
- message: "幂等返回:同 user decision 已登记",
227
- };
228
- }
229
- const normalizedDecision = {
230
- scope: decision.scope,
231
- question: decision.question ?? "",
232
- answer: decision.answer,
233
- };
234
- const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
235
- const event = makeEvent(change, "user_decision_recorded", {
236
- ...normalizedDecision,
237
- input_digest: inputDigest,
238
- ...rawRef,
239
- });
240
- appendEvent(projectRoot, change, event);
241
- return {
242
- event_type: "user_decision_recorded",
243
- accepted: true,
244
- message: `用户决策已登记:scope=${decision.scope}`,
245
- };
261
+ return recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest);
262
+ });
263
+ }
264
+ /** record user-decision:从 JSON 内容登记用户决策 */
265
+ export function recordUserDecisionContent(projectRoot, change, content) {
266
+ return withLock(projectRoot, change, () => {
267
+ ensureChangeLayout(projectRoot, change);
268
+ const events = readEvents(projectRoot, change);
269
+ return recordUserDecisionLoaded(projectRoot, change, events, content, sha256Text(content));
246
270
  });
247
271
  }
248
272
  /** jobs list(HIGH-1 修复:从 transition_commit.new_jobs 提取,不再依赖已删除的 job_requested 事件) */
@@ -296,12 +320,16 @@ export function jobsPacket(projectRoot, change, jobId) {
296
320
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
297
321
  packet_digest: job.packet_digest,
298
322
  required_output_kind: "job_report_json",
323
+ preferred_input_mode: "stdin",
324
+ submission_command: `superspec record job-submit --change "${change}" --job "${job.job_id}" --report -`,
325
+ submission_argv: jobSubmitArgv(change, job.job_id),
326
+ file_fallback: true,
299
327
  output_contract_fields: requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
300
328
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
301
329
  output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
302
330
  (job.review_evidence_digest ? `本工作项绑定的执行证据版本为 ${job.review_evidence_digest},` : "") +
303
331
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} reviewer 执行并在 reviewer.kind/id 中记录来源,` : "") +
304
- `产出 JSON 报告文件并通过 superspec record job-submit 登记。` +
332
+ `产出 JSON 报告内容并优先通过 --report - stdin 登记;文件路径模式仍可作为 fallback。` +
305
333
  (requiresReviewer(job.role)
306
334
  ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
307
335
  : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
package/dist/task.d.ts CHANGED
@@ -5,3 +5,8 @@ export declare function recordTestRun(projectRoot: string, change: string, input
5
5
  accepted: boolean;
6
6
  message: string;
7
7
  };
8
+ /** record test-run:从 JSON 内容登记测试运行记录 */
9
+ export declare function recordTestRunContent(projectRoot: string, change: string, content: string): {
10
+ accepted: boolean;
11
+ message: string;
12
+ };
package/dist/task.js CHANGED
@@ -11,39 +11,49 @@ export function tasksStructureDigestOf(changeRoot) {
11
11
  const content = readFileSync(p, "utf8");
12
12
  return formatDigest(content, sha256Text);
13
13
  }
14
+ function recordTestRunLoaded(projectRoot, change, content) {
15
+ let tr;
16
+ try {
17
+ tr = JSON.parse(content);
18
+ }
19
+ catch {
20
+ return { accepted: false, message: "无效 JSON" };
21
+ }
22
+ if (!tr.test_id || !tr.task_structure_digest) {
23
+ return { accepted: false, message: "缺少 test_id 或 task_structure_digest" };
24
+ }
25
+ const normalizedTestRun = {
26
+ test_id: tr.test_id,
27
+ task_structure_digest: tr.task_structure_digest,
28
+ attempt_id: tr.attempt_id ?? null,
29
+ command: tr.command ?? "",
30
+ cwd: tr.cwd ?? "",
31
+ exit_code: tr.exit_code ?? -1,
32
+ semantic_status: tr.semantic_status ?? "unknown",
33
+ target_fingerprint: tr.target_fingerprint ?? null,
34
+ raw_log_ref: tr.raw_log_ref ?? null,
35
+ };
36
+ const rawRef = appendRawRecord(projectRoot, change, "test-runs", normalizedTestRun);
37
+ const event = makeEvent(change, "test_run_recorded", {
38
+ ...normalizedTestRun,
39
+ ...rawRef,
40
+ });
41
+ appendEvent(projectRoot, change, event);
42
+ return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
43
+ }
14
44
  /** record test-run:登记测试运行记录 */
15
45
  export function recordTestRun(projectRoot, change, inputFile) {
16
46
  return withLock(projectRoot, change, () => {
17
47
  ensureChangeLayout(projectRoot, change);
18
48
  if (!existsSync(inputFile))
19
49
  return { accepted: false, message: `文件不存在:${inputFile}` };
20
- let tr;
21
- try {
22
- tr = JSON.parse(readFileSync(inputFile, "utf8"));
23
- }
24
- catch {
25
- return { accepted: false, message: "无效 JSON" };
26
- }
27
- if (!tr.test_id || !tr.task_structure_digest) {
28
- return { accepted: false, message: "缺少 test_id 或 task_structure_digest" };
29
- }
30
- const normalizedTestRun = {
31
- test_id: tr.test_id,
32
- task_structure_digest: tr.task_structure_digest,
33
- attempt_id: tr.attempt_id ?? null,
34
- command: tr.command ?? "",
35
- cwd: tr.cwd ?? "",
36
- exit_code: tr.exit_code ?? -1,
37
- semantic_status: tr.semantic_status ?? "unknown",
38
- target_fingerprint: tr.target_fingerprint ?? null,
39
- raw_log_ref: tr.raw_log_ref ?? null,
40
- };
41
- const rawRef = appendRawRecord(projectRoot, change, "test-runs", normalizedTestRun);
42
- const event = makeEvent(change, "test_run_recorded", {
43
- ...normalizedTestRun,
44
- ...rawRef,
45
- });
46
- appendEvent(projectRoot, change, event);
47
- return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
50
+ return recordTestRunLoaded(projectRoot, change, readFileSync(inputFile, "utf8"));
51
+ });
52
+ }
53
+ /** record test-run:从 JSON 内容登记测试运行记录 */
54
+ export function recordTestRunContent(projectRoot, change, content) {
55
+ return withLock(projectRoot, change, () => {
56
+ ensureChangeLayout(projectRoot, change);
57
+ return recordTestRunLoaded(projectRoot, change, content);
48
58
  });
49
59
  }
@@ -13,15 +13,22 @@ interface Decision {
13
13
  details?: Record<string, unknown>;
14
14
  postCommit?: (projectRoot: string, change: string, changeRoot: string) => void;
15
15
  }
16
+ interface SkipDecision {
17
+ skip: true;
18
+ message: string;
19
+ }
20
+ interface BlockedDecision {
21
+ blocked: true;
22
+ reason: string;
23
+ jobs: Job[];
24
+ details?: Record<string, unknown>;
25
+ }
16
26
  /**
17
27
  * 统一 transition 提交协议——所有校验在锁内。
18
28
  */
19
29
  export declare function commitTransition(projectRoot: string, change: string, changeRoot: string, opts: {
20
30
  name: string;
21
- decide: (snapshot: Snapshot) => Decision | {
22
- skip: true;
23
- message: string;
24
- };
31
+ decide: (snapshot: Snapshot) => Decision | SkipDecision | BlockedDecision;
25
32
  idempotencyInputs?: Record<string, unknown>;
26
33
  }): TransitionResult;
27
34
  export declare function proposeReady(projectRoot: string, change: string, changeRoot: string, risk?: "minimal" | "normal" | "strict"): TransitionResult;
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, sha256File, sha256Text, } from "./store.js";
5
5
  import { rebuildSnapshot } from "./sync.js";
6
+ import { requiredJobActions } from "./job_action.js";
6
7
  import { assertCommitPayloadExtension, isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewBoundFiles, reviewEvidenceDigest, reviewPolicyForRisk, } from "./review.js";
7
8
  import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, pendingTasksInContent, tasksStructureDigest } from "./format.js";
8
9
  let transitionSeq = 0;
@@ -31,7 +32,7 @@ function checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, tr
31
32
  for (const role of requiredRoles) {
32
33
  const openForRole = snapshot.open_jobs.find(j => j.role === role && j.created_from_transition === transitionName);
33
34
  if (openForRole)
34
- return { fromState: snapshot.state, toState: snapshot.state, outcome: "advanced", reason: `工作项 ${role} 已存在(${openForRole.job_id}),请先完成它` };
35
+ return { blocked: true, reason: `状态未推进;已有待完成工作项 ${role}(${openForRole.job_id})`, jobs: [openForRole] };
35
36
  const fresh = snapshot.accepted_jobs.find(j => j.role === role && j.created_from_transition === transitionName);
36
37
  if (!fresh) {
37
38
  staleRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
@@ -114,13 +115,28 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
114
115
  const existing = events.find(e => e.idempotency_key === idemKey && e.event_type === "transition_commit");
115
116
  if (existing) {
116
117
  const p = existing.payload;
118
+ const newJobs = p.new_jobs ?? [];
117
119
  return {
118
120
  transition: name, outcome: p.outcome,
119
121
  from_state: p.from_state, to_state: p.to_state,
120
122
  created_jobs: p.created_job_ids ?? [], message: "幂等返回", events_written: 0,
123
+ ...(newJobs.length > 0 ? { required_jobs: requiredJobActions(change, newJobs) } : {}),
121
124
  };
122
125
  }
123
126
  const decision = opts.decide(snapshot);
127
+ if ("blocked" in decision) {
128
+ return {
129
+ transition: name,
130
+ outcome: "blocked",
131
+ from_state: snapshot.state,
132
+ to_state: snapshot.state,
133
+ created_jobs: [],
134
+ required_jobs: requiredJobActions(change, decision.jobs),
135
+ message: decision.reason,
136
+ events_written: 0,
137
+ ...(decision.details ? { details: decision.details } : {}),
138
+ };
139
+ }
124
140
  if ("skip" in decision) {
125
141
  return {
126
142
  transition: name, outcome: "advanced",
@@ -163,6 +179,7 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
163
179
  return {
164
180
  transition: name, outcome, from_state: fromState, to_state: toState,
165
181
  created_jobs: newJobs.map(j => j.job_id),
182
+ ...(newJobs.length > 0 ? { required_jobs: requiredJobActions(change, newJobs) } : {}),
166
183
  message: outcome === "advanced" ? `状态推进:${fromState} → ${toState}` : `状态不变(${fromState}),创建了 ${newJobs.length} 个工作项`,
167
184
  events_written: 1 + extraEvents.length,
168
185
  ...(details ? { details } : {}),
@@ -348,7 +365,7 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
348
365
  if (snapshot.state === "apply_done" || snapshot.state === "review") {
349
366
  const verifierOpen = snapshot.open_jobs.find(isReviewReadyVerifier);
350
367
  if (verifierOpen)
351
- return { skip: true, message: `有待完成的最终验证工作项 ${verifierOpen.job_id}` };
368
+ return { blocked: true, reason: `状态未推进;已有待完成最终验证工作项 ${verifierOpen.job_id}`, jobs: [verifierOpen] };
352
369
  const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
353
370
  if (!policy.requires_verifier) {
354
371
  if (snapshot.state === "apply_done") {
package/dist/types.d.ts CHANGED
@@ -24,11 +24,21 @@ export interface JobPacket {
24
24
  review_evidence_digest?: string;
25
25
  packet_digest: string;
26
26
  required_output_kind: string;
27
+ preferred_input_mode?: "stdin" | "file";
28
+ submission_command?: string;
29
+ submission_argv?: string[];
30
+ file_fallback?: boolean;
27
31
  output_contract_fields?: string[];
28
32
  output_contract_optional_fields?: string[];
29
33
  stop_conditions: string[];
30
34
  created_from_transition: string;
31
35
  }
36
+ export interface RequiredJobAction {
37
+ job_id: string;
38
+ role: JobRole;
39
+ packet_command: string;
40
+ packet_argv: string[];
41
+ }
32
42
  export type EventType = "transition_prepare" | "transition_commit" | "job_requested" | "job_invalidated" | "reopen" | "abandon" | "task_started" | "task_completed" | "task_abandoned" | "job_accepted" | "job_rejected" | "user_decision_recorded" | "test_run_recorded" | "task_activation_recorded" | "artifact_recorded";
33
43
  export interface Event {
34
44
  event_id: string;
@@ -117,11 +127,7 @@ export type NextOutput = {
117
127
  missing_inputs: MissingInput[];
118
128
  } | {
119
129
  path: "required_job";
120
- required_jobs: {
121
- job_id: string;
122
- role: JobRole;
123
- packet_command: string;
124
- }[];
130
+ required_jobs: RequiredJobAction[];
125
131
  reason: string;
126
132
  } | {
127
133
  path: "ask_user";
@@ -133,10 +139,11 @@ export type NextOutput = {
133
139
  });
134
140
  export interface TransitionResult {
135
141
  transition: string;
136
- outcome: "advanced" | "job_created";
142
+ outcome: "advanced" | "job_created" | "blocked";
137
143
  from_state: State;
138
144
  to_state: State;
139
145
  created_jobs: string[];
146
+ required_jobs?: RequiredJobAction[];
140
147
  message: string;
141
148
  events_written: number;
142
149
  details?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,11 @@
1
+ <!-- SUPERSPEC:AGENTS:START -->
2
+ # SuperSpec
3
+
4
+ 本项目启用 SuperSpec。使用 `superspec-*` 工作流时,以 `superspec transition next --change "<change>"` 返回的下一步为准;流程完成前不得跳阶段、不得自称完成。
5
+
6
+ SuperSpec 创建的独立审查/验证工作项,视为已授权启动对应 subagent;无需再次询问用户。主会话不得自批这些工作项。
7
+
8
+ 审查/验证工作项只授权处理该工作项绑定的内容,不得扩大范围、跳过阶段或代替后续流程。若当前环境无法启动 subagent,只能使用本地或用户已授权的独立来源;没有可用独立来源时停在当前工作项并说明阻塞,主会话不得因此自批。
9
+
10
+ 执行 CLI 返回命令时优先使用 `*_argv` 字段。用户可见回复使用自然语言;除非用户要求调试信息,不复述内部 JSON 字段。
11
+ <!-- SUPERSPEC:AGENTS:END -->
@@ -19,7 +19,7 @@ argument-hint: "本次架构审查说明"
19
19
 
20
20
  在 `superspec-review` 或 disclosure review 中,先读取主流程提供的本次任务说明。以本次任务说明中的审查范围、绑定文件、输出格式、字段要求和停止条件为准;不要依赖本 prompt 记忆输出 schema。
21
21
 
22
- 当本次任务说明要求提交 `job_report_json` 报告时,提交给 `superspec record job-submit` 的报告文件必须是 JSON
22
+ 当本次任务说明要求提交 `job_report_json` 报告时,提交给 `superspec record job-submit` 的报告内容必须是 JSON,并优先通过 `--report -` 从 stdin 登记:
23
23
 
24
24
  ```json
25
25
  {
@@ -20,7 +20,7 @@ argument-hint: "本次反方审查说明"
20
20
 
21
21
  在 `superspec-review` 或 disclosure review 中,先读取主流程提供的本次任务说明。以本次任务说明中的审查范围、绑定文件、输出格式、字段要求和停止条件为准;不要依赖本 prompt 记忆输出 schema。
22
22
 
23
- 当本次任务说明要求提交 `job_report_json` 报告时,提交给 `superspec record job-submit` 的报告文件必须是 JSON
23
+ 当本次任务说明要求提交 `job_report_json` 报告时,提交给 `superspec record job-submit` 的报告内容必须是 JSON,并优先通过 `--report -` 从 stdin 登记:
24
24
 
25
25
  ```json
26
26
  {