@peterxiaoyang/superspec 0.1.25-beta.0 → 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/README.md CHANGED
@@ -81,7 +81,7 @@ superspec install
81
81
  ```
82
82
 
83
83
  这条命令的意思是:把 SuperSpec 当前可用的工作流入口安装到项目里。
84
- 当前 beta 会安装 `.superspec/` 引擎目录、`.codex/skills/superspec-*` 阶段入口、`.codex/prompts/*.md` 角色 prompt、`.codex/agents/*.toml` 子智能体配置,并补齐 `.codex/config.toml` 的多 agent 开关。`superspec init --scope project` 仍作为兼容别名可用。
84
+ 当前 beta 会安装 `.superspec/` 引擎目录、`.codex/skills/superspec-*` 阶段入口、`.codex/prompts/*.md` 角色 prompt、`.codex/agents/*.toml` 子智能体配置,补齐 `.codex/config.toml` 的多 agent 开关,并在项目根 `AGENTS.md` 中维护 SuperSpec 轻量门禁片段。`superspec init --scope project` 仍作为兼容别名可用。
85
85
 
86
86
  Windows PowerShell 如果拦截 npm 的 `.ps1` 脚本,请改用:
87
87
 
@@ -160,6 +160,14 @@ openspec/changes/<变更ID>/.superspec/
160
160
  `.superspec/` 要不要提交到 git,由你的团队决定。
161
161
  如果不提交,删掉后就没有 git 历史可以恢复。
162
162
 
163
+ ## 流程门禁
164
+
165
+ SuperSpec 的阶段入口由 `superspec transition next --change <变更ID>` 驱动。
166
+
167
+ 当当前阶段还有未完成的用户确认、审查、验证或工作项时,`next` 会先返回这些事项,不会把下一阶段命令作为推荐路径。重复运行会创建审查工作项的 transition 时,如果同阶段工作项已经存在,CLI 会返回正常的门禁结果,不会写入新事件,也不会把它当作程序错误。
168
+
169
+ 这仍然是轻量流程控制,不是写入拦截。它约束按 SuperSpec 正常入口执行时的下一步建议和状态提交结果,不承诺阻止绕过流程的手动编辑。
170
+
163
171
  ## Hook 会做什么
164
172
 
165
173
  SuperSpec 默认安装的 hook 只在子智能体启动和停止时运行:
@@ -238,7 +246,7 @@ superspec status
238
246
  superspec update
239
247
  ```
240
248
 
241
- 这条命令会先检查 npm 上的 latest 版本;如果有新版,会自动执行全局升级并用新版 CLI 重新同步项目入口。同步内容包括补齐 `.superspec/changes` 运行时目录,并把当前 CLI 内置的 `.codex/skills/superspec-*`、`.codex/prompts/*.md`、`.codex/agents/*.toml` 同步到项目里。
249
+ 这条命令会先检查 npm 上的 latest 版本;如果有新版,会自动执行全局升级并用新版 CLI 重新同步项目入口。同步内容包括补齐 `.superspec/changes` 运行时目录,把当前 CLI 内置的 `.codex/skills/superspec-*`、`.codex/prompts/*.md`、`.codex/agents/*.toml` 同步到项目里,并更新 `AGENTS.md` 中 marker 包裹的 SuperSpec 轻量门禁片段。
242
250
 
243
251
  ## 进阶信息
244
252
 
package/dist/cli.js CHANGED
@@ -54,6 +54,16 @@ function readStdinRecordContent(flag) {
54
54
  }
55
55
  return readFileSync(0, "utf8");
56
56
  }
57
+ function transitionExitCode(result) {
58
+ if (result.outcome === "blocked")
59
+ return 0;
60
+ return result.events_written === 0 ? 1 : 0;
61
+ }
62
+ function proposeReadyExitCode(result) {
63
+ if (result.outcome === "blocked")
64
+ return 0;
65
+ return result.events_written === 0 && result.message.includes("不能") ? 1 : 0;
66
+ }
57
67
  function parseVersion(version) {
58
68
  const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
59
69
  if (!match)
@@ -494,12 +504,12 @@ jobs 子命令:
494
504
  const risk = opts.risk ?? "strict";
495
505
  const result = proposeReady(projectRoot, change, cr, risk);
496
506
  console.log(JSON.stringify(result, null, 2));
497
- return result.events_written === 0 && result.message.includes("不能") ? 1 : 0;
507
+ return proposeReadyExitCode(result);
498
508
  }
499
509
  case "start-apply": {
500
510
  const result = startApply(projectRoot, change, cr);
501
511
  console.log(JSON.stringify(result, null, 2));
502
- return result.events_written === 0 ? 1 : 0;
512
+ return transitionExitCode(result);
503
513
  }
504
514
  case "task-start": {
505
515
  const taskId = opts.task;
@@ -509,7 +519,7 @@ jobs 子命令:
509
519
  }
510
520
  const result = taskStart(projectRoot, change, cr, taskId);
511
521
  console.log(JSON.stringify(result, null, 2));
512
- return result.events_written === 0 ? 1 : 0;
522
+ return transitionExitCode(result);
513
523
  }
514
524
  case "task-complete": {
515
525
  const taskId = opts.task;
@@ -519,7 +529,7 @@ jobs 子命令:
519
529
  }
520
530
  const result = taskComplete(projectRoot, change, cr, taskId);
521
531
  console.log(JSON.stringify(result, null, 2));
522
- return result.events_written === 0 ? 1 : 0;
532
+ return transitionExitCode(result);
523
533
  }
524
534
  case "reopen": {
525
535
  const to = opts.to;
@@ -534,23 +544,23 @@ jobs 子命令:
534
544
  }
535
545
  const result = reopen(projectRoot, change, cr, to, reason);
536
546
  console.log(JSON.stringify(result, null, 2));
537
- return result.events_written === 0 ? 1 : 0;
547
+ return transitionExitCode(result);
538
548
  }
539
549
  case "review-ready": {
540
550
  const risk = opts.risk ?? "strict";
541
551
  const result = reviewReady(projectRoot, change, cr, risk);
542
552
  console.log(JSON.stringify(result, null, 2));
543
- return result.events_written === 0 ? 1 : 0;
553
+ return transitionExitCode(result);
544
554
  }
545
555
  case "accept": {
546
556
  const result = accept(projectRoot, change, cr);
547
557
  console.log(JSON.stringify(result, null, 2));
548
- return result.events_written === 0 ? 1 : 0;
558
+ return transitionExitCode(result);
549
559
  }
550
560
  case "archive": {
551
561
  const result = archive(projectRoot, change, cr);
552
562
  console.log(JSON.stringify(result, null, 2));
553
- return result.events_written === 0 ? 1 : 0;
563
+ return transitionExitCode(result);
554
564
  }
555
565
  default:
556
566
  console.error(`未知的 transition 子命令:${subcommand}`);
package/dist/install.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface InstallResult {
10
10
  prompts: string[];
11
11
  agents: string[];
12
12
  config: string;
13
+ agents_md: string;
13
14
  openspec_config: string;
14
15
  };
15
16
  }
package/dist/install.js CHANGED
@@ -55,6 +55,9 @@ function assertWorkflowTemplates(templateRoot) {
55
55
  if (!existsSync(file))
56
56
  missing.push(file);
57
57
  }
58
+ const agentsMdTemplate = join(templateRoot, "AGENTS.md");
59
+ if (!existsSync(agentsMdTemplate))
60
+ missing.push(agentsMdTemplate);
58
61
  if (missing.length > 0) {
59
62
  throw new Error(`workflow templates missing: ${missing.join(", ")}`);
60
63
  }
@@ -66,7 +69,6 @@ function writeBundledFile(src, dest) {
66
69
  const current = readFileSync(dest, "utf8");
67
70
  if (current === next)
68
71
  return;
69
- writeFileSync(`${dest}.bak`, current);
70
72
  }
71
73
  writeFileSync(dest, next);
72
74
  }
@@ -242,6 +244,38 @@ function ensureOpenSpecChineseContext(projectRoot) {
242
244
  writeFileSync(configPath, next);
243
245
  return OPENSPEC_CONFIG_PATH;
244
246
  }
247
+ const AGENTS_MD_PATH = "AGENTS.md";
248
+ const SUPERSPEC_AGENTS_START = "<!-- SUPERSPEC:AGENTS:START -->";
249
+ const SUPERSPEC_AGENTS_END = "<!-- SUPERSPEC:AGENTS:END -->";
250
+ function readAgentsMdTemplate(templateRoot) {
251
+ const template = readFileSync(join(templateRoot, AGENTS_MD_PATH), "utf8").trimEnd();
252
+ if (!template.includes(SUPERSPEC_AGENTS_START) || !template.includes(SUPERSPEC_AGENTS_END)) {
253
+ throw new Error(`workflow AGENTS.md template missing SuperSpec markers: ${join(templateRoot, AGENTS_MD_PATH)}`);
254
+ }
255
+ return template;
256
+ }
257
+ function replaceMarkerBlock(content, block) {
258
+ const start = content.indexOf(SUPERSPEC_AGENTS_START);
259
+ const end = content.indexOf(SUPERSPEC_AGENTS_END);
260
+ if (start < 0 || end < 0 || end < start)
261
+ return null;
262
+ const afterEnd = end + SUPERSPEC_AGENTS_END.length;
263
+ return `${content.slice(0, start)}${block}${content.slice(afterEnd)}`;
264
+ }
265
+ function ensureAgentsMd(projectRoot, block) {
266
+ const agentsPath = join(projectRoot, AGENTS_MD_PATH);
267
+ if (!existsSync(agentsPath)) {
268
+ writeFileSync(agentsPath, `${block}\n`);
269
+ return AGENTS_MD_PATH;
270
+ }
271
+ const current = readFileSync(agentsPath, "utf8");
272
+ const replaced = replaceMarkerBlock(current, block);
273
+ const next = replaced ?? `${current.trimEnd()}\n\n${block}\n`;
274
+ if (next === current)
275
+ return AGENTS_MD_PATH;
276
+ writeFileSync(agentsPath, next);
277
+ return AGENTS_MD_PATH;
278
+ }
245
279
  export function installProject(projectRoot, options = {}) {
246
280
  if (!options.allowLegacyState && legacyStateFound(projectRoot)) {
247
281
  throw new Error("检测到老版 SuperSpec (0.x) 的状态文件。\n" +
@@ -251,6 +285,7 @@ export function installProject(projectRoot, options = {}) {
251
285
  }
252
286
  const templateRoot = options.templateRoot ?? defaultTemplateRoot();
253
287
  assertWorkflowTemplates(templateRoot);
288
+ const agentsMdTemplate = readAgentsMdTemplate(templateRoot);
254
289
  const engineDir = join(projectRoot, ".superspec");
255
290
  mkdirSync(join(engineDir, "changes"), { recursive: true });
256
291
  const gitignorePath = join(engineDir, ".gitignore");
@@ -266,6 +301,7 @@ export function installProject(projectRoot, options = {}) {
266
301
  prompts: copyPrompts(templateRoot, projectRoot),
267
302
  agents: copyAgents(templateRoot, projectRoot),
268
303
  config: ensureCodexConfig(projectRoot),
304
+ agents_md: ensureAgentsMd(projectRoot, agentsMdTemplate),
269
305
  openspec_config: ensureOpenSpecChineseContext(projectRoot),
270
306
  },
271
307
  };
@@ -0,0 +1,6 @@
1
+ import type { Job, RequiredJobAction } from "./types.ts";
2
+ export declare function jobPacketCommand(change: string, jobId: string): string;
3
+ export declare function jobPacketArgv(change: string, jobId: string): string[];
4
+ export declare function requiredJobAction(change: string, job: Job): RequiredJobAction;
5
+ export declare function requiredJobActions(change: string, jobs: Job[]): RequiredJobAction[];
6
+ export declare function jobSubmitArgv(change: string, jobId: string): string[];
@@ -0,0 +1,21 @@
1
+ // SuperSpec workflow engine - shared job action protocol helpers.
2
+ export function jobPacketCommand(change, jobId) {
3
+ return `superspec jobs packet --change "${change}" --job "${jobId}"`;
4
+ }
5
+ export function jobPacketArgv(change, jobId) {
6
+ return ["superspec", "jobs", "packet", "--change", change, "--job", jobId];
7
+ }
8
+ export function requiredJobAction(change, job) {
9
+ return {
10
+ job_id: job.job_id,
11
+ role: job.role,
12
+ packet_command: jobPacketCommand(change, job.job_id),
13
+ packet_argv: jobPacketArgv(change, job.job_id),
14
+ };
15
+ }
16
+ export function requiredJobActions(change, jobs) {
17
+ return jobs.map(job => requiredJobAction(change, job));
18
+ }
19
+ export function jobSubmitArgv(change, jobId) {
20
+ return ["superspec", "record", "job-submit", "--change", change, "--job", jobId, "--report", "-"];
21
+ }
package/dist/next.js CHANGED
@@ -4,13 +4,22 @@ import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { readEvents, sha256Text } from "./store.js";
6
6
  import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
7
+ import { requiredJobActions } from "./job_action.js";
7
8
  import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
8
9
  const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
9
10
  function isActiveProposalReviewJob(job) {
10
11
  return job.created_from_transition === "propose-ready" && ACTIVE_PROPOSAL_REVIEW_ROLES.has(job.role);
11
12
  }
12
- function packetCommand(change, jobId) {
13
- return `superspec jobs packet --change "${change}" --job "${jobId}"`;
13
+ function isExploreReviewJob(job) {
14
+ return job.created_from_transition === "explore" && job.role === "critic";
15
+ }
16
+ function requiredJobsOutput(state, change, jobs, reason) {
17
+ return {
18
+ state,
19
+ path: "required_job",
20
+ required_jobs: requiredJobActions(change, jobs),
21
+ reason,
22
+ };
14
23
  }
15
24
  function transitionCommand(change, name, extra = "") {
16
25
  return `superspec transition ${name} --change "${change}"${extra ? " " + extra : ""}`;
@@ -67,6 +76,9 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
67
76
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
68
77
  switch (snapshot.state) {
69
78
  case "init":
79
+ if (snapshot.open_jobs.length > 0) {
80
+ return requiredJobsOutput("init", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
81
+ }
70
82
  return {
71
83
  state: "init",
72
84
  path: "next_command",
@@ -75,6 +87,10 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
75
87
  missing_inputs: [],
76
88
  };
77
89
  case "explore": {
90
+ const exploreReviewJobs = snapshot.open_jobs.filter(isExploreReviewJob);
91
+ if (exploreReviewJobs.length > 0) {
92
+ return requiredJobsOutput("explore", change, exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
93
+ }
78
94
  // Phase 2:检查 discovery.md
79
95
  const discoveryCheck = validateDiscovery(changeRoot);
80
96
  if (!discoveryCheck.ok) {
@@ -117,17 +133,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
117
133
  }
118
134
  const proposalReviewJobs = snapshot.open_jobs.filter(isActiveProposalReviewJob);
119
135
  if (proposalReviewJobs.length > 0) {
120
- const jobs = proposalReviewJobs.map(j => ({
121
- job_id: j.job_id,
122
- role: j.role,
123
- packet_command: packetCommand(change, j.job_id),
124
- }));
125
- return {
126
- state: "propose",
127
- path: "required_job",
128
- required_jobs: jobs,
129
- reason: `有 ${jobs.length} 个待完成工作项`,
130
- };
136
+ return requiredJobsOutput("propose", change, proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
131
137
  }
132
138
  return {
133
139
  state: "propose",
@@ -140,16 +146,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
140
146
  case "propose_ready": {
141
147
  const proposalReviewJobs = snapshot.open_jobs.filter(isActiveProposalReviewJob);
142
148
  if (proposalReviewJobs.length > 0) {
143
- return {
144
- state: "propose_ready",
145
- path: "required_job",
146
- required_jobs: proposalReviewJobs.map(j => ({
147
- job_id: j.job_id,
148
- role: j.role,
149
- packet_command: packetCommand(change, j.job_id),
150
- })),
151
- reason: `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`,
152
- };
149
+ return requiredJobsOutput("propose_ready", change, proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
153
150
  }
154
151
  return {
155
152
  state: "propose_ready",
@@ -160,6 +157,9 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
160
157
  };
161
158
  }
162
159
  case "apply": {
160
+ if (snapshot.open_jobs.length > 0) {
161
+ return requiredJobsOutput("apply", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
162
+ }
163
163
  // 检查是否所有任务已完成
164
164
  const pending = pendingTaskIds(changeRoot);
165
165
  if (pending.length > 0) {
@@ -190,15 +190,6 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
190
190
  missing_inputs: [],
191
191
  };
192
192
  }
193
- // 有 open job → 做
194
- if (snapshot.open_jobs.length > 0) {
195
- return {
196
- state: "apply",
197
- path: "required_job",
198
- required_jobs: snapshot.open_jobs.map(j => ({ job_id: j.job_id, role: j.role, packet_command: packetCommand(change, j.job_id) })),
199
- reason: `有 ${snapshot.open_jobs.length} 个待完成工作项`,
200
- };
201
- }
202
193
  return {
203
194
  state: "apply",
204
195
  path: "next_command",
@@ -219,12 +210,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
219
210
  };
220
211
  }
221
212
  if (snapshot.open_jobs.length > 0) {
222
- return {
223
- state: "apply_done",
224
- path: "required_job",
225
- required_jobs: snapshot.open_jobs.map(j => ({ job_id: j.job_id, role: j.role, packet_command: packetCommand(change, j.job_id) })),
226
- reason: `有 ${snapshot.open_jobs.length} 个待完成工作项`,
227
- };
213
+ return requiredJobsOutput("apply_done", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
228
214
  }
229
215
  return {
230
216
  state: "apply_done",
@@ -247,16 +233,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
247
233
  }
248
234
  const reviewVerifierJobs = snapshot.open_jobs.filter(isReviewReadyVerifier);
249
235
  if (reviewVerifierJobs.length > 0) {
250
- return {
251
- state: "review",
252
- path: "required_job",
253
- required_jobs: reviewVerifierJobs.map(j => ({
254
- job_id: j.job_id,
255
- role: j.role,
256
- packet_command: packetCommand(change, j.job_id),
257
- })),
258
- reason: `有 ${reviewVerifierJobs.length} 个待完成最终验证工作项`,
259
- };
236
+ return requiredJobsOutput("review", change, reviewVerifierJobs, `有 ${reviewVerifierJobs.length} 个待完成最终验证工作项`);
260
237
  }
261
238
  const events = readEvents(projectRoot, change);
262
239
  const policy = readReviewPolicyFromEvents(events);
@@ -291,6 +268,9 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
291
268
  };
292
269
  }
293
270
  case "accepted":
271
+ if (snapshot.open_jobs.length > 0) {
272
+ return requiredJobsOutput("accepted", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项,暂不归档`);
273
+ }
294
274
  return {
295
275
  state: "accepted",
296
276
  path: "next_command",
@@ -299,11 +279,20 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
299
279
  missing_inputs: [],
300
280
  };
301
281
  case "archive":
282
+ if (snapshot.open_jobs.length > 0) {
283
+ return requiredJobsOutput("archive", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项,暂不结束`);
284
+ }
302
285
  return {
303
286
  state: "archive",
304
287
  path: "done",
305
288
  reason: "已归档,流程完成。",
306
289
  };
290
+ case "abandoned":
291
+ return {
292
+ state: "abandoned",
293
+ path: "done",
294
+ reason: "变更已放弃,流程终止。",
295
+ };
307
296
  default:
308
297
  return {
309
298
  state: snapshot.state,
package/dist/record.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
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"]);
@@ -321,6 +322,7 @@ export function jobsPacket(projectRoot, change, jobId) {
321
322
  required_output_kind: "job_report_json",
322
323
  preferred_input_mode: "stdin",
323
324
  submission_command: `superspec record job-submit --change "${change}" --job "${job.job_id}" --report -`,
325
+ submission_argv: jobSubmitArgv(change, job.job_id),
324
326
  file_fallback: true,
325
327
  output_contract_fields: requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
326
328
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
@@ -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
@@ -26,12 +26,19 @@ export interface JobPacket {
26
26
  required_output_kind: string;
27
27
  preferred_input_mode?: "stdin" | "file";
28
28
  submission_command?: string;
29
+ submission_argv?: string[];
29
30
  file_fallback?: boolean;
30
31
  output_contract_fields?: string[];
31
32
  output_contract_optional_fields?: string[];
32
33
  stop_conditions: string[];
33
34
  created_from_transition: string;
34
35
  }
36
+ export interface RequiredJobAction {
37
+ job_id: string;
38
+ role: JobRole;
39
+ packet_command: string;
40
+ packet_argv: string[];
41
+ }
35
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";
36
43
  export interface Event {
37
44
  event_id: string;
@@ -120,11 +127,7 @@ export type NextOutput = {
120
127
  missing_inputs: MissingInput[];
121
128
  } | {
122
129
  path: "required_job";
123
- required_jobs: {
124
- job_id: string;
125
- role: JobRole;
126
- packet_command: string;
127
- }[];
130
+ required_jobs: RequiredJobAction[];
128
131
  reason: string;
129
132
  } | {
130
133
  path: "ask_user";
@@ -136,10 +139,11 @@ export type NextOutput = {
136
139
  });
137
140
  export interface TransitionResult {
138
141
  transition: string;
139
- outcome: "advanced" | "job_created";
142
+ outcome: "advanced" | "job_created" | "blocked";
140
143
  from_state: State;
141
144
  to_state: State;
142
145
  created_jobs: string[];
146
+ required_jobs?: RequiredJobAction[];
143
147
  message: string;
144
148
  events_written: number;
145
149
  details?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.25-beta.0",
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,6 +19,8 @@ metadata:
19
19
  3. 登记结果
20
20
  4. 回到 1
21
21
 
22
+ 如果下一步提示当前阶段还有用户确认、审查或验证事项,先完成这些事项。完成前不要继续下一个任务、不要进入下一阶段;对用户说明时使用自然语言,不默认复述内部 JSON 字段或完整 packet。
23
+
22
24
  ## 本阶段做什么
23
25
 
24
26
  每个任务的循环:
@@ -18,6 +18,8 @@ metadata:
18
18
  2. 执行返回的命令
19
19
  3. 回到 1
20
20
 
21
+ 如果下一步提示还有用户确认、审查或验证事项,先完成这些事项。完成前不要归档或宣布流程完成;对用户说明时使用自然语言,不默认复述内部 JSON 字段或完整 packet。
22
+
21
23
  ## 本阶段做什么
22
24
 
23
25
  1. **确认状态为 accepted**:next 会检查
@@ -19,7 +19,9 @@ metadata:
19
19
  3. 登记结果
20
20
  4. 回到 1
21
21
 
22
- next 返回 `ask_user` 说明 discovery 不完整或有未确认问题。若 scope 是 `explore_discovery`,先检查并填写 discovery 草稿,不要把草稿占位内容直接转问用户;只有真实阻塞问题才向用户提问,收到回答后优先用 `superspec record user-decision --change "<change>" --input -` 从 stdin 登记 JSON 内容;文件路径模式仍可作为 fallback
22
+ 如果下一步提示当前阶段还有用户确认、审查或验证事项,先完成这些事项。完成前不要进入下一阶段,也不要修改业务代码;对用户说明时使用自然语言,不默认复述内部 JSON 字段或完整 packet
23
+
24
+ 如果下一步说明 discovery 不完整或有未确认问题,先检查并填写 discovery 草稿,不要把草稿占位内容直接转问用户;只有真实阻塞问题才向用户提问,收到回答后优先用 `superspec record user-decision --change "<change>" --input -` 从 stdin 登记 JSON 内容;文件路径模式仍可作为 fallback。
23
25
 
24
26
  本技能默认走完整审查路径。探索完成后,`explore → propose` 会先创建 `critic` 工作项,由 Critic 角色审查需求澄清记录。审查完成后优先通过 `superspec record job-submit --change "<change>" --job <JOB> --report -` 从 stdin 登记 JSON 报告内容;文件路径模式仍可作为 fallback。
25
27
 
@@ -52,22 +54,20 @@ next 返回 `ask_user` 说明 discovery 不完整或有未确认问题。若 sco
52
54
 
53
55
  写入 `openspec/changes/<change>/.superspec/artifacts/discovery.md`:
54
56
 
55
- 如果文件已经存在并包含 `<!-- superspec:discovery-draft -->` 或“待探索后...”占位文本,说明它是引擎生成的草稿。完成探索后必须删除草稿标记并替换所有占位内容,否则引擎会继续阻止推进。
56
-
57
57
  ```markdown
58
58
  # Discovery
59
59
 
60
- ## 当前代码事实
61
- - src/path.ts:10 当前系统怎么工作
62
-
63
60
  ## 需求理解
64
- (用户目标和当前实现之间的差异)
61
+ - 用户目标
62
+
63
+ ## 现状
64
+ - 当前系统怎么工作 src/path.ts:10
65
65
 
66
- ## 影响范围候选
67
- - src/path.ts:10 可能受影响的代码表面和相邻风险
66
+ ## 影响范围
67
+ - 可能受影响的代码表面和相邻风险
68
68
 
69
69
  ## 风险和边界
70
- (技术风险、依赖、兼容性;尽量绑定代码或文档锚点)
70
+ - 技术风险、依赖、兼容性;尽量绑定代码或文档锚点
71
71
 
72
72
  ## 待确认问题
73
73
  - [ ] 问题1的描述
@@ -19,7 +19,9 @@ metadata:
19
19
  3. 登记结果
20
20
  4. 回到 1
21
21
 
22
- next 返回需要审查时,先按返回的审查说明完成对应审查,再优先用 `superspec record job-submit --change "<change>" --job <JOB> --report -` 从 stdin 提交 JSON 审查报告内容;文件路径模式仍可作为 fallback
22
+ 如果下一步提示当前阶段还有用户确认、审查或验证事项,先完成这些事项。完成前不要进入下一阶段,也不要修改业务代码;对用户说明时使用自然语言,不默认复述内部 JSON 字段或完整 packet
23
+
24
+ 如果下一步需要审查,先按返回的审查说明完成对应审查,再优先用 `superspec record job-submit --change "<change>" --job <JOB> --report -` 从 stdin 提交 JSON 审查报告内容;文件路径模式仍可作为 fallback。
23
25
 
24
26
  人类可读正文默认使用简体中文;OpenSpec 结构标题、规范关键字、命令、路径、JSON 字段、代码标识符保留原文。
25
27
  如果 OpenSpec 生成文档语言不符合预期,先检查 `openspec/config.yaml` 的官方 `context` 设置;不要在变更文档里添加自定义 `language` 字段。
@@ -19,7 +19,9 @@ metadata:
19
19
  3. 登记结果
20
20
  4. 回到 1
21
21
 
22
- next 返回需要 verifier 工作项时,先按返回的验证说明执行核对,再优先用 `superspec record job-submit --change "<change>" --job <JOB> --report -` 从 stdin 提交 JSON 验证报告内容;文件路径模式仍可作为 fallback
22
+ 如果下一步提示当前阶段还有用户确认、审查或验证事项,先完成这些事项。完成前不要 accept archive;对用户说明时使用自然语言,不默认复述内部 JSON 字段或完整 packet
23
+
24
+ 如果下一步需要 verifier 工作项,先按返回的验证说明执行核对,再优先用 `superspec record job-submit --change "<change>" --job <JOB> --report -` 从 stdin 提交 JSON 验证报告内容;文件路径模式仍可作为 fallback。
23
25
 
24
26
  `record job-submit` 沿用现有 raw 归档:报告追加到 `raw/review-reports.jsonl`,不会为 review gate 新增 raw 文件类型。
25
27