@peterxiaoyang/superspec 0.1.14 → 0.1.15-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +302 -0
  3. package/dist/format.d.ts +29 -0
  4. package/dist/format.js +141 -0
  5. package/dist/next.d.ts +3 -0
  6. package/dist/next.js +161 -0
  7. package/dist/openspec.d.ts +9 -0
  8. package/dist/openspec.js +41 -0
  9. package/dist/record.d.ts +20 -0
  10. package/dist/record.js +211 -0
  11. package/dist/skill_loop.d.ts +23 -0
  12. package/dist/skill_loop.js +58 -0
  13. package/dist/store.d.ts +31 -0
  14. package/dist/store.js +213 -0
  15. package/dist/sync.d.ts +3 -0
  16. package/dist/sync.js +135 -0
  17. package/dist/task.d.ts +7 -0
  18. package/dist/task.js +44 -0
  19. package/dist/transition.d.ts +34 -0
  20. package/dist/transition.js +400 -0
  21. package/dist/types.d.ts +138 -0
  22. package/dist/types.js +3 -0
  23. package/package.json +8 -53
  24. package/templates/workflow/skills/superspec-apply/SKILL.md +46 -118
  25. package/templates/workflow/skills/superspec-archive/SKILL.md +22 -47
  26. package/templates/workflow/skills/superspec-explore/SKILL.md +38 -69
  27. package/templates/workflow/skills/superspec-propose/SKILL.md +58 -60
  28. package/templates/workflow/skills/superspec-review/SKILL.md +36 -81
  29. package/adapters/codex/agents/architect.toml +0 -13
  30. package/adapters/codex/agents/code-reviewer.toml +0 -13
  31. package/adapters/codex/agents/critic.toml +0 -13
  32. package/adapters/codex/agents/executor.toml +0 -13
  33. package/adapters/codex/agents/explore.toml +0 -13
  34. package/adapters/codex/agents/test-engineer.toml +0 -13
  35. package/adapters/codex/agents/test-runner.toml +0 -13
  36. package/adapters/codex/agents/verifier.toml +0 -13
  37. package/adapters/codex/install-map.json +0 -116
  38. package/bin/launch.js +0 -37
  39. package/bin/superspec-check.js +0 -4
  40. package/bin/superspec-hook.js +0 -4
  41. package/bin/superspec-init.js +0 -4
  42. package/bin/superspec.js +0 -4
  43. package/dist/src/apply_worker_chain.d.ts +0 -60
  44. package/dist/src/apply_worker_chain.js +0 -1283
  45. package/dist/src/apply_worker_chain_lifecycle.d.ts +0 -21
  46. package/dist/src/apply_worker_chain_lifecycle.js +0 -409
  47. package/dist/src/archive.d.ts +0 -23
  48. package/dist/src/archive.js +0 -430
  49. package/dist/src/cli.d.ts +0 -1
  50. package/dist/src/cli.js +0 -33
  51. package/dist/src/cli_args.d.ts +0 -32
  52. package/dist/src/cli_args.js +0 -454
  53. package/dist/src/core.d.ts +0 -20
  54. package/dist/src/core.js +0 -394
  55. package/dist/src/disclosure.d.ts +0 -35
  56. package/dist/src/disclosure.js +0 -671
  57. package/dist/src/doctor.d.ts +0 -44
  58. package/dist/src/doctor.js +0 -230
  59. package/dist/src/evidence.d.ts +0 -29
  60. package/dist/src/evidence.js +0 -1107
  61. package/dist/src/gates.d.ts +0 -19
  62. package/dist/src/gates.js +0 -1631
  63. package/dist/src/git.d.ts +0 -8
  64. package/dist/src/git.js +0 -112
  65. package/dist/src/hooks/adapter.d.ts +0 -5
  66. package/dist/src/hooks/adapter.js +0 -347
  67. package/dist/src/hooks/guard_api.d.ts +0 -12
  68. package/dist/src/hooks/guard_api.js +0 -797
  69. package/dist/src/hooks/health.d.ts +0 -4
  70. package/dist/src/hooks/health.js +0 -86
  71. package/dist/src/hooks/policy_event.d.ts +0 -41
  72. package/dist/src/hooks/policy_event.js +0 -2634
  73. package/dist/src/hooks/types.d.ts +0 -72
  74. package/dist/src/hooks/types.js +0 -1
  75. package/dist/src/hooks/validation.d.ts +0 -3
  76. package/dist/src/hooks/validation.js +0 -70
  77. package/dist/src/i18n.d.ts +0 -21
  78. package/dist/src/i18n.js +0 -720
  79. package/dist/src/init_cli.d.ts +0 -28
  80. package/dist/src/init_cli.js +0 -404
  81. package/dist/src/install_engine.d.ts +0 -71
  82. package/dist/src/install_engine.js +0 -538
  83. package/dist/src/invariants.d.ts +0 -16
  84. package/dist/src/invariants.js +0 -363
  85. package/dist/src/openspec.d.ts +0 -37
  86. package/dist/src/openspec.js +0 -274
  87. package/dist/src/packet_render.d.ts +0 -5
  88. package/dist/src/packet_render.js +0 -1767
  89. package/dist/src/packet_schema.d.ts +0 -59
  90. package/dist/src/packet_schema.js +0 -1
  91. package/dist/src/paths.d.ts +0 -22
  92. package/dist/src/paths.js +0 -203
  93. package/dist/src/project_init.d.ts +0 -33
  94. package/dist/src/project_init.js +0 -191
  95. package/dist/src/self_update.d.ts +0 -14
  96. package/dist/src/self_update.js +0 -56
  97. package/dist/src/state.d.ts +0 -37
  98. package/dist/src/state.js +0 -464
  99. package/dist/src/tasks.d.ts +0 -38
  100. package/dist/src/tasks.js +0 -373
  101. package/dist/src/util.d.ts +0 -163
  102. package/dist/src/util.js +0 -792
  103. package/dist/superspec.d.ts +0 -6
  104. package/dist/superspec.js +0 -97
  105. package/dist/superspec_guard.d.ts +0 -4
  106. package/dist/superspec_guard.js +0 -19
  107. package/dist/superspec_hook.d.ts +0 -4
  108. package/dist/superspec_hook.js +0 -35
  109. package/dist/superspec_init.d.ts +0 -2
  110. package/dist/superspec_init.js +0 -17
  111. package/schemas/hook-event.schema.json +0 -27
  112. package/schemas/install-manifest.schema.json +0 -97
  113. package/templates/hooks/codex-hooks.json +0 -35
  114. package/templates/sidecar/archive-preservation.json +0 -11
  115. package/templates/sidecar/business-invariants.md +0 -38
  116. package/templates/sidecar/config.yaml +0 -13
  117. package/templates/sidecar/discovery.md +0 -33
  118. package/templates/sidecar/test-contract.md +0 -27
  119. package/templates/workflow/prompts/architect.md +0 -27
  120. package/templates/workflow/prompts/code-reviewer.md +0 -34
  121. package/templates/workflow/prompts/critic.md +0 -30
  122. package/templates/workflow/prompts/executor.md +0 -32
  123. package/templates/workflow/prompts/explore.md +0 -27
  124. package/templates/workflow/prompts/test-engineer.md +0 -28
  125. package/templates/workflow/prompts/test-runner.md +0 -35
  126. package/templates/workflow/prompts/verifier.md +0 -35
package/dist/sync.js ADDED
@@ -0,0 +1,135 @@
1
+ // SuperSpec 流程引擎 — sync:从当前文档 + events.jsonl 重建 snapshot
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { readEvents, eventsDigest, computeDocumentDigests, sha256File, sha256Text, ensureChangeLayout, } from "./store.js";
5
+ const TRACKED_DOCS = [
6
+ "proposal.md", "design.md", "tasks.md",
7
+ ".superspec/artifacts/discovery.md",
8
+ ".superspec/artifacts/business-invariants.md",
9
+ ".superspec/artifacts/test-contract.md",
10
+ ];
11
+ /** 从 events.jsonl 推导:当前状态、jobs、attempts、pending decisions */
12
+ function replayEvents(events) {
13
+ let state = "init";
14
+ const openJobs = [];
15
+ const acceptedJobs = [];
16
+ const activeAttempts = [];
17
+ const taskStatuses = {};
18
+ let lastTransition = null;
19
+ for (const ev of events) {
20
+ switch (ev.event_type) {
21
+ case "transition_commit": {
22
+ const p = ev.payload;
23
+ state = p.to_state;
24
+ if (ev.transition_id)
25
+ lastTransition = ev.transition_id;
26
+ // HIGH 6 修复:从 commit payload replay job
27
+ const created = ev.payload.created_job_ids ?? [];
28
+ const payloadJobs = ev.payload.new_jobs ?? [];
29
+ for (const jid of created) {
30
+ const job = payloadJobs.find(j => j.job_id === jid);
31
+ if (job && !openJobs.some(j => j.job_id === jid))
32
+ openJobs.push(job);
33
+ }
34
+ break;
35
+ }
36
+ // H4 修复:删除 job_requested / job_invalidated 死分支——job 只通过 transition_commit 的 new_jobs payload 创建,staleness 只在 sync 内存计算(不写事件)
37
+ case "job_accepted": {
38
+ const { job_id } = ev.payload;
39
+ const idx = openJobs.findIndex(j => j.job_id === job_id);
40
+ if (idx >= 0) {
41
+ const job = openJobs.splice(idx, 1)[0];
42
+ job.state = "accepted";
43
+ acceptedJobs.push(job);
44
+ }
45
+ break;
46
+ }
47
+ case "job_rejected": {
48
+ const { job_id } = ev.payload;
49
+ const idx = openJobs.findIndex(j => j.job_id === job_id);
50
+ if (idx >= 0) {
51
+ openJobs.splice(idx, 1)[0]; // remove from open
52
+ }
53
+ break;
54
+ }
55
+ // H4:job_invalidated 事件从不发射(删除死分支)
56
+ case "task_started": {
57
+ const attempt = ev.payload;
58
+ activeAttempts.push(attempt);
59
+ taskStatuses[attempt.task_id] = "doing";
60
+ break;
61
+ }
62
+ case "task_completed": {
63
+ const { task_id, attempt_id } = ev.payload;
64
+ const idx = activeAttempts.findIndex(a => a.attempt_id === attempt_id);
65
+ if (idx >= 0)
66
+ activeAttempts[idx].state = "closed";
67
+ taskStatuses[task_id] = "done";
68
+ break;
69
+ }
70
+ case "task_abandoned": {
71
+ const { attempt_id } = ev.payload;
72
+ const idx = activeAttempts.findIndex(a => a.attempt_id === attempt_id);
73
+ if (idx >= 0)
74
+ activeAttempts.splice(idx, 1);
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition };
80
+ }
81
+ /** 粗粒度失效:检查 accepted job 的 boundFiles 是否仍匹配当前文档 */
82
+ function checkStaleJobs(acceptedJobs, documentDigests) {
83
+ const stale = [];
84
+ for (const job of acceptedJobs) {
85
+ for (const bf of job.boundFiles) {
86
+ const current = documentDigests[bf.path];
87
+ if (current && current !== bf.sha) {
88
+ stale.push({
89
+ job_id: job.job_id,
90
+ reason: `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`,
91
+ });
92
+ break; // 一个文件变就够了
93
+ }
94
+ }
95
+ }
96
+ return stale;
97
+ }
98
+ /** 解析 tasks.md 结构指纹(复选框归一化) */
99
+ function tasksStructureDigest(changeRoot) {
100
+ const tasksPath = join(changeRoot, "tasks.md");
101
+ if (!existsSync(tasksPath))
102
+ return null;
103
+ const content = readFileSync(tasksPath, "utf8");
104
+ // 复选框归一化:- [x] → - [ ]
105
+ const normalized = content.replace(/- \[[xX]\]/g, "- [ ]");
106
+ return sha256Text(normalized);
107
+ }
108
+ /** 重建 snapshot(sync 的核心) */
109
+ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusDigest = "sha256:unknown") {
110
+ ensureChangeLayout(projectRoot, change);
111
+ const events = readEvents(projectRoot, change);
112
+ const evDigest = eventsDigest(events);
113
+ const documentDigests = computeDocumentDigests(changeRoot, TRACKED_DOCS);
114
+ const tsDigest = tasksStructureDigest(changeRoot);
115
+ const { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition } = replayEvents(events);
116
+ // 粗粒度失效检查(只读,不写事件)
117
+ const staleInfo = checkStaleJobs(acceptedJobs, documentDigests);
118
+ // stale 的 job 从 accepted 移除(sync 只反映当前事实)
119
+ const freshAccepted = acceptedJobs.filter(j => !staleInfo.some(s => s.job_id === j.job_id));
120
+ return {
121
+ change_id: change,
122
+ state,
123
+ openspec_status_digest: openspecStatusDigest,
124
+ events_digest: evDigest,
125
+ document_digests: documentDigests,
126
+ tasks_structure_digest: tsDigest,
127
+ task_statuses: taskStatuses,
128
+ open_jobs: openJobs,
129
+ accepted_jobs: freshAccepted,
130
+ active_task_attempts: activeAttempts,
131
+ pending_user_decisions: [],
132
+ last_transition: lastTransition,
133
+ computed_at: new Date().toISOString(),
134
+ };
135
+ }
package/dist/task.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
2
+ export declare function tasksStructureDigestOf(changeRoot: string): string | null;
3
+ /** record test-run:登记测试运行记录 */
4
+ export declare function recordTestRun(projectRoot: string, change: string, inputFile: string): {
5
+ accepted: boolean;
6
+ message: string;
7
+ };
package/dist/task.js ADDED
@@ -0,0 +1,44 @@
1
+ // SuperSpec 流程引擎 — task:测试运行记录 + 任务结构指纹工具
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock } from "./store.js";
5
+ import { tasksStructureDigest as formatDigest } from "./format.js";
6
+ /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
7
+ export function tasksStructureDigestOf(changeRoot) {
8
+ const p = join(changeRoot, "tasks.md");
9
+ if (!existsSync(p))
10
+ return null;
11
+ const content = readFileSync(p, "utf8");
12
+ return formatDigest(content, sha256Text);
13
+ }
14
+ /** record test-run:登记测试运行记录 */
15
+ export function recordTestRun(projectRoot, change, inputFile) {
16
+ return withLock(projectRoot, change, () => {
17
+ ensureChangeLayout(projectRoot, change);
18
+ if (!existsSync(inputFile))
19
+ 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 event = makeEvent(change, "test_run_recorded", {
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
+ appendEvent(projectRoot, change, event);
42
+ return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
43
+ });
44
+ }
@@ -0,0 +1,34 @@
1
+ import type { Snapshot, State, Job, TransitionResult } from "./types.ts";
2
+ interface Decision {
3
+ fromState: State;
4
+ toState: State;
5
+ outcome: "advanced" | "job_created";
6
+ newJobs?: Job[];
7
+ reason: string;
8
+ extraEvents?: {
9
+ type: string;
10
+ payload: Record<string, unknown>;
11
+ }[];
12
+ postCommit?: (projectRoot: string, change: string, changeRoot: string) => void;
13
+ }
14
+ /**
15
+ * 统一 transition 提交协议——所有校验在锁内。
16
+ */
17
+ export declare function commitTransition(projectRoot: string, change: string, changeRoot: string, opts: {
18
+ name: string;
19
+ decide: (snapshot: Snapshot) => Decision | {
20
+ skip: true;
21
+ message: string;
22
+ };
23
+ idempotencyInputs?: Record<string, unknown>;
24
+ }): TransitionResult;
25
+ export declare function proposeReady(projectRoot: string, change: string, changeRoot: string, risk?: "minimal" | "normal" | "strict"): TransitionResult;
26
+ export declare function transitionInit(projectRoot: string, change: string, changeRoot: string): TransitionResult;
27
+ export declare function transitionExplore(projectRoot: string, change: string, changeRoot: string): TransitionResult;
28
+ export declare function startApply(projectRoot: string, change: string, changeRoot: string): TransitionResult;
29
+ export declare function taskStart(projectRoot: string, change: string, changeRoot: string, taskId: string): TransitionResult;
30
+ export declare function reviewReady(projectRoot: string, change: string, changeRoot: string, risk?: "minimal" | "normal" | "strict"): TransitionResult;
31
+ export declare function accept(projectRoot: string, change: string, changeRoot: string): TransitionResult;
32
+ export declare function archive(projectRoot: string, change: string, changeRoot: string): TransitionResult;
33
+ export declare function taskComplete(projectRoot: string, change: string, changeRoot: string, taskId: string): TransitionResult;
34
+ export {};
@@ -0,0 +1,400 @@
1
+ // SuperSpec 流程引擎 — transition:提交协议 + 所有 transition 处理器
2
+ import { join } from "node:path";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
4
+ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, stagingDir, sha256File, sha256Text, } from "./store.js";
5
+ import { rebuildSnapshot } from "./sync.js";
6
+ import { validateDiscovery, findTaskInLines, parseTasksMd, tasksStructureDigest } from "./format.js";
7
+ let transitionSeq = 0;
8
+ function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
9
+ let jobSeq = 0;
10
+ function newJobId(change, role) { return `JOB-${change.slice(0, 8)}-${role.slice(0, 4)}-${Date.now()}-${++jobSeq}`; }
11
+ const TRANSITION_REQUIREMENTS = {
12
+ "propose-ready": {
13
+ minimal: [],
14
+ normal: ["proposal-auditor"],
15
+ strict: ["critic-review", "architect-review", "test-engineer-review"],
16
+ },
17
+ "explore": {
18
+ minimal: [],
19
+ normal: [],
20
+ strict: ["clarification-review"],
21
+ },
22
+ };
23
+ /**
24
+ * 通用 job 检查/创建逻辑——任何 transition 都能调用。
25
+ * 检查 requiredRoles 是否有 fresh accepted job;缺则创建新 job。
26
+ * 返回 null = 全部满足;返回 Decision = 需要创建 job(状态不变)。
27
+ */
28
+ function checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, transitionName, bindDocPaths) {
29
+ const staleRoles = [];
30
+ for (const role of requiredRoles) {
31
+ const openForRole = snapshot.open_jobs.find(j => j.role === role);
32
+ if (openForRole)
33
+ return { fromState: snapshot.state, toState: snapshot.state, outcome: "advanced", reason: `工作项 ${role} 已存在(${openForRole.job_id}),请先完成它` };
34
+ const fresh = snapshot.accepted_jobs.find(j => j.role === role);
35
+ if (!fresh) {
36
+ staleRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
37
+ }
38
+ else {
39
+ for (const bf of fresh.boundFiles) {
40
+ const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
41
+ if (currentSha !== bf.sha) {
42
+ staleRoles.push({ role, reason: `${role} 绑定文件 ${bf.path} 已变化` });
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ if (staleRoles.length > 0) {
49
+ const newJobs = staleRoles.map(({ role }) => {
50
+ const boundFiles = bindDocPaths.filter(p => existsSync(join(changeRoot, p))).map(p => ({ path: p, sha: sha256File(join(changeRoot, p)) ?? "sha256:missing" }));
51
+ return { job_id: newJobId(change, role), role, state: "requested", boundFiles, packet_digest: sha256Text(JSON.stringify({ role, boundFiles })), created_from_transition: transitionName, created_at: new Date().toISOString() };
52
+ });
53
+ return {
54
+ fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
55
+ newJobs, reason: staleRoles.map(s => s.reason).join("; "),
56
+ };
57
+ }
58
+ return null; // 全部满足
59
+ }
60
+ /**
61
+ * 已迁移到 format.ts:findTaskInLines / parseTasksMd / tasksStructureDigest
62
+ * 以下保留 findTaskLine 作为兼容 wrapper(内部调用 format.ts)
63
+ */
64
+ function findTaskLine(lines, taskId) {
65
+ return findTaskInLines(lines, taskId);
66
+ }
67
+ /**
68
+ * 统一 transition 提交协议——所有校验在锁内。
69
+ */
70
+ export function commitTransition(projectRoot, change, changeRoot, opts) {
71
+ const { name } = opts;
72
+ return withLock(projectRoot, change, () => {
73
+ ensureChangeLayout(projectRoot, change);
74
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
75
+ const worldDigest = snapshotDigest(snapshot);
76
+ const idemKey = idempotencyKey(name, opts.idempotencyInputs ?? {}, worldDigest);
77
+ const events = readEvents(projectRoot, change);
78
+ const existing = events.find(e => e.idempotency_key === idemKey && e.event_type === "transition_commit");
79
+ if (existing) {
80
+ const p = existing.payload;
81
+ return {
82
+ transition: name, outcome: p.outcome,
83
+ from_state: p.from_state, to_state: p.to_state,
84
+ created_jobs: p.created_job_ids ?? [], message: "幂等返回", events_written: 0,
85
+ };
86
+ }
87
+ const decision = opts.decide(snapshot);
88
+ if ("skip" in decision) {
89
+ return {
90
+ transition: name, outcome: "advanced",
91
+ from_state: snapshot.state, to_state: snapshot.state,
92
+ created_jobs: [], message: decision.message, events_written: 0,
93
+ };
94
+ }
95
+ const { fromState, toState, outcome, newJobs = [], reason, extraEvents = [] } = decision;
96
+ if (fromState !== snapshot.state) {
97
+ return {
98
+ transition: name, outcome: "advanced",
99
+ from_state: snapshot.state, to_state: snapshot.state,
100
+ created_jobs: [], message: `from_state 不匹配:期望 ${fromState},实际 ${snapshot.state}`,
101
+ events_written: 0,
102
+ };
103
+ }
104
+ const transitionId = newTransitionId();
105
+ // prepare
106
+ appendEvent(projectRoot, change, makeEvent(change, "transition_prepare", {
107
+ transition: name, transition_id: transitionId, from_state: fromState, to_state: toState, reason,
108
+ }, { transitionId, idempotencyKey: idemKey, prevSnapshotDigest: worldDigest }));
109
+ // BLOCKER-1 修复:postCommit 在 commit 写入前执行。
110
+ // 如果 postCommit 抛错 → 只有 prepare(无 commit)→ 幂等重试会跳过 prepare 重新执行。
111
+ // 如果 postCommit 成功 → commit + extraEvents 写入 → 一致。
112
+ if (decision.postCommit) {
113
+ decision.postCommit(projectRoot, change, changeRoot);
114
+ }
115
+ // commit(postCommit 成功后才写)
116
+ appendEvent(projectRoot, change, makeEvent(change, "transition_commit", {
117
+ transition: name, from_state: fromState, to_state: toState,
118
+ outcome, created_job_ids: newJobs.map(j => j.job_id), new_jobs: newJobs, reason,
119
+ }, { transitionId, idempotencyKey: idemKey, prevSnapshotDigest: worldDigest }));
120
+ // extra events (task_started, task_completed, etc.)
121
+ for (const ex of extraEvents) {
122
+ appendEvent(projectRoot, change, makeEvent(change, ex.type, ex.payload, { transitionId }));
123
+ }
124
+ writeSnapshot(projectRoot, change, rebuildSnapshot(projectRoot, change, changeRoot));
125
+ return {
126
+ transition: name, outcome, from_state: fromState, to_state: toState,
127
+ created_jobs: newJobs.map(j => j.job_id),
128
+ message: outcome === "advanced" ? `状态推进:${fromState} → ${toState}` : `状态不变(${fromState}),创建了 ${newJobs.length} 个工作项`,
129
+ events_written: 1 + extraEvents.length,
130
+ };
131
+ });
132
+ }
133
+ // ===== propose-ready =====
134
+ export function proposeReady(projectRoot, change, changeRoot, risk = "normal") {
135
+ return commitTransition(projectRoot, change, changeRoot, {
136
+ name: "propose-ready", idempotencyInputs: { risk },
137
+ decide: (snapshot) => {
138
+ if (snapshot.state !== "propose")
139
+ return { skip: true, message: `当前状态 ${snapshot.state},不能 propose-ready` };
140
+ const tasksPath = join(changeRoot, "tasks.md");
141
+ if (!existsSync(tasksPath))
142
+ return { skip: true, message: "tasks.md 不存在" };
143
+ const tasksContent = readFileSync(tasksPath, "utf8");
144
+ if (!tasksContent.includes("# Tasks") && !tasksContent.includes("- [ ]"))
145
+ return { skip: true, message: "tasks.md 内容不像任务计划文档" };
146
+ if (risk !== "minimal") {
147
+ const artifactsDir = join(changeRoot, ".superspec", "artifacts");
148
+ for (const doc of ["discovery.md", "business-invariants.md", "test-contract.md"]) {
149
+ if (!existsSync(join(artifactsDir, doc)))
150
+ return { skip: true, message: `基础职责缺失:${doc} 不存在(risk=${risk} 需要)` };
151
+ }
152
+ }
153
+ // 通用 job 检查(用提取的 helper)
154
+ const requiredRoles = TRANSITION_REQUIREMENTS["propose-ready"]?.[risk] ?? [];
155
+ const reviewResult = checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, "propose-ready", ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"]);
156
+ if (reviewResult)
157
+ return reviewResult;
158
+ return { fromState: "propose", toState: "propose_ready", outcome: "advanced", reason: `risk=${risk},所有需求已满足` };
159
+ },
160
+ });
161
+ }
162
+ // ===== init =====
163
+ export function transitionInit(projectRoot, change, changeRoot) {
164
+ return commitTransition(projectRoot, change, changeRoot, {
165
+ name: "init", idempotencyInputs: { phase: "init" },
166
+ decide: () => {
167
+ const events = readEvents(projectRoot, change);
168
+ if (events.length > 0)
169
+ return { skip: true, message: "change 已初始化" };
170
+ return { fromState: "init", toState: "init", outcome: "advanced", reason: "引擎初始化" };
171
+ },
172
+ });
173
+ }
174
+ // ===== explore =====
175
+ export function transitionExplore(projectRoot, change, changeRoot) {
176
+ return commitTransition(projectRoot, change, changeRoot, {
177
+ name: "explore", idempotencyInputs: { phase: "explore" },
178
+ decide: (snapshot) => {
179
+ if (snapshot.state === "init")
180
+ return { fromState: "init", toState: "explore", outcome: "advanced", reason: "进入探索阶段" };
181
+ if (snapshot.state === "explore") {
182
+ const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
183
+ if (!existsSync(discoveryPath))
184
+ return { skip: true, message: "discovery.md 不存在" };
185
+ // explore→propose:校验 discovery + 通用 job 审查(normal+ 需 clarification-review)
186
+ const discoveryCheck = validateDiscovery(changeRoot);
187
+ if (!discoveryCheck.ok)
188
+ return { skip: true, message: discoveryCheck.message };
189
+ // 通用 job 审查(和 propose-ready 同一个 helper)
190
+ const requiredRoles = TRANSITION_REQUIREMENTS["explore"]?.["normal"] ?? [];
191
+ const reviewResult = checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, "explore", [".superspec/artifacts/discovery.md"]);
192
+ if (reviewResult)
193
+ return reviewResult;
194
+ return { fromState: "explore", toState: "propose", outcome: "advanced", reason: "探索完成" };
195
+ }
196
+ return { skip: true, message: `当前状态 ${snapshot.state},explore 不适用` };
197
+ },
198
+ });
199
+ }
200
+ // ===== start-apply =====
201
+ export function startApply(projectRoot, change, changeRoot) {
202
+ return commitTransition(projectRoot, change, changeRoot, {
203
+ name: "start-apply", idempotencyInputs: { phase: "start-apply" },
204
+ decide: (snapshot) => {
205
+ if (snapshot.state !== "propose_ready")
206
+ return { skip: true, message: `当前状态 ${snapshot.state},需要 propose_ready` };
207
+ return { fromState: "propose_ready", toState: "apply", outcome: "advanced", reason: "进入执行阶段" };
208
+ },
209
+ });
210
+ }
211
+ // ===== task-start =====
212
+ let attemptSeq = 0;
213
+ export function taskStart(projectRoot, change, changeRoot, taskId) {
214
+ return commitTransition(projectRoot, change, changeRoot, {
215
+ name: "task-start", idempotencyInputs: { task: taskId },
216
+ decide: (snapshot) => {
217
+ if (snapshot.state !== "apply")
218
+ return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
219
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
220
+ const lines = tasksContent.split("\n");
221
+ const taskLineIdx = findTaskLine(lines, taskId);
222
+ if (taskLineIdx < 0)
223
+ return { skip: true, message: `任务 ${taskId} 不存在` };
224
+ if (lines[taskLineIdx].match(/- \[x\]/))
225
+ return { skip: true, message: `任务 ${taskId} 已完成` };
226
+ const existing = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
227
+ if (existing)
228
+ return { skip: true, message: `任务 ${taskId} 已有活跃尝试` };
229
+ const structureDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
230
+ const attempt = {
231
+ attempt_id: `ATT-${taskId}-${Date.now()}-${++attemptSeq}`,
232
+ task_id: taskId, state: "active",
233
+ task_structure_digest: structureDigest,
234
+ declared_write_scope: [], pre_edit_source_fingerprint: null,
235
+ pre_edit_red_ref: null, executor_packet_digest: null,
236
+ executor_result_ref: null, post_edit_green_ref: null,
237
+ created_at: new Date().toISOString(),
238
+ };
239
+ return {
240
+ fromState: "apply", toState: "apply", outcome: "advanced",
241
+ reason: `创建任务 ${taskId} 执行尝试`,
242
+ extraEvents: [{ type: "task_started", payload: attempt }],
243
+ };
244
+ },
245
+ });
246
+ }
247
+ // ===== review-ready =====
248
+ export function reviewReady(projectRoot, change, changeRoot, risk = "normal") {
249
+ return commitTransition(projectRoot, change, changeRoot, {
250
+ name: "review-ready", idempotencyInputs: { phase: "review-ready", risk },
251
+ decide: (snapshot) => {
252
+ // 检查是否所有任务已完成
253
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
254
+ const allDone = !tasksContent.split("\n").some(l => l.includes("- [ ]"));
255
+ if (!allDone)
256
+ return { skip: true, message: "尚有未完成任务" };
257
+ // 如果当前是 apply,先推进到 apply_done
258
+ if (snapshot.state === "apply") {
259
+ return { fromState: "apply", toState: "apply_done", outcome: "advanced", reason: "所有任务完成" };
260
+ }
261
+ if (snapshot.state === "apply_done") {
262
+ // 检查是否有 final-audit job 需求
263
+ const finalAuditOpen = snapshot.open_jobs.find(j => j.role === "final-audit");
264
+ if (finalAuditOpen)
265
+ return { skip: true, message: `有待完成的最终审查工作项 ${finalAuditOpen.job_id}` };
266
+ const finalAuditAccepted = snapshot.accepted_jobs.find(j => j.role === "final-audit");
267
+ // minimal 直接推进;normal/strict 需要 final-audit
268
+ if (risk !== "minimal" && !finalAuditAccepted) {
269
+ // 创建 final-audit job
270
+ const docPaths = ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"];
271
+ const boundFiles = docPaths.filter(p => existsSync(join(changeRoot, p))).map(p => ({ path: p, sha: sha256File(join(changeRoot, p)) ?? "sha256:missing" }));
272
+ const job = {
273
+ job_id: newJobId(change, "final-audit"), role: "final-audit", state: "requested",
274
+ boundFiles, packet_digest: sha256Text(JSON.stringify({ role: "final-audit", boundFiles })),
275
+ created_from_transition: "review-ready", created_at: new Date().toISOString(),
276
+ };
277
+ return {
278
+ fromState: "apply_done", toState: "apply_done", outcome: "job_created",
279
+ newJobs: [job], reason: "创建最终审查工作项",
280
+ };
281
+ }
282
+ return { fromState: "apply_done", toState: "review", outcome: "advanced", reason: "最终审查已接受,进入审查阶段" };
283
+ }
284
+ return { skip: true, message: `当前状态 ${snapshot.state},review-ready 不适用` };
285
+ },
286
+ });
287
+ }
288
+ // ===== accept =====
289
+ export function accept(projectRoot, change, changeRoot) {
290
+ return commitTransition(projectRoot, change, changeRoot, {
291
+ name: "accept", idempotencyInputs: { phase: "accept" },
292
+ decide: (snapshot) => {
293
+ if (snapshot.state !== "review")
294
+ return { skip: true, message: `当前状态 ${snapshot.state},需要 review` };
295
+ return { fromState: "review", toState: "accepted", outcome: "advanced", reason: "审查通过" };
296
+ },
297
+ });
298
+ }
299
+ // ===== archive =====
300
+ export function archive(projectRoot, change, changeRoot) {
301
+ return commitTransition(projectRoot, change, changeRoot, {
302
+ name: "archive", idempotencyInputs: { phase: "archive" },
303
+ decide: (snapshot) => {
304
+ if (snapshot.state !== "accepted")
305
+ return { skip: true, message: `当前状态 ${snapshot.state},需要 accepted` };
306
+ // 构建保全清单(Phase 4 简化版:记录文档指纹 + specs/)
307
+ const manifest = {};
308
+ const docPaths = ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"];
309
+ for (const p of docPaths) {
310
+ manifest[p] = sha256File(join(changeRoot, p)) ?? "sha256:missing";
311
+ }
312
+ // specs/ 目录
313
+ const specsDir = join(changeRoot, "specs");
314
+ if (existsSync(specsDir)) {
315
+ for (const f of readdirSync(specsDir)) {
316
+ if (f.endsWith(".md"))
317
+ manifest[`specs/${f}`] = sha256File(join(specsDir, f)) ?? "sha256:missing";
318
+ }
319
+ }
320
+ return {
321
+ fromState: "accepted", toState: "archive", outcome: "advanced",
322
+ reason: "归档完成",
323
+ extraEvents: [{ type: "artifact_recorded", payload: { kind: "archive_preservation_manifest", manifest } }],
324
+ };
325
+ },
326
+ });
327
+ }
328
+ // ===== task-complete =====
329
+ export function taskComplete(projectRoot, change, changeRoot, taskId) {
330
+ return commitTransition(projectRoot, change, changeRoot, {
331
+ name: "task-complete", idempotencyInputs: { task: taskId, phase: "complete" },
332
+ decide: (snapshot) => {
333
+ if (snapshot.state !== "apply")
334
+ return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
335
+ const attempt = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
336
+ if (!attempt)
337
+ return { skip: true, message: `任务 ${taskId} 无活跃执行尝试` };
338
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
339
+ const currentDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
340
+ if (currentDigest !== attempt.task_structure_digest)
341
+ return { skip: true, message: "任务结构指纹不匹配" };
342
+ // 解析任务属性(委托给 format.ts 的 parseTasksMd)
343
+ const tasks = parseTasksMd(tasksContent);
344
+ const taskInfo = tasks.find(t => t.taskId === taskId);
345
+ const isTdd = taskInfo?.tddRequired ?? true;
346
+ const hasNoTddReason = taskInfo?.noTddReason != null;
347
+ // RED/GREEN 检查(HIGH-2 修复:按 attempt_id 匹配,避免多任务 digest 碰撞)
348
+ const events = readEvents(projectRoot, change);
349
+ let hasRed = false, hasGreen = false;
350
+ for (const ev of events) {
351
+ if (ev.event_type === "test_run_recorded") {
352
+ const tr = ev.payload;
353
+ // 匹配当前 attempt(优先按 attempt_id,回退到 digest)
354
+ const matches = tr.attempt_id === attempt.attempt_id ||
355
+ (!tr.attempt_id && tr.task_structure_digest === attempt.task_structure_digest);
356
+ if (matches) {
357
+ if (tr.semantic_status === "expected_failure" || tr.semantic_status === "characterization_pass")
358
+ hasRed = true;
359
+ if (tr.semantic_status === "expected_success")
360
+ hasGreen = true;
361
+ }
362
+ }
363
+ }
364
+ if (isTdd) {
365
+ if (!hasRed)
366
+ return { skip: true, message: `TDD 任务 ${taskId} 缺少 RED 证据` };
367
+ if (!hasGreen)
368
+ return { skip: true, message: `TDD 任务 ${taskId} 缺少 GREEN 证据` };
369
+ }
370
+ else {
371
+ if (!hasNoTddReason)
372
+ return { skip: true, message: `非 TDD 任务 ${taskId} 缺少 no_tdd_reason` };
373
+ }
374
+ // B1 修复:checkbox 写入移到 postCommit(commit 事件写入后执行)
375
+ return {
376
+ fromState: "apply", toState: "apply", outcome: "advanced",
377
+ reason: `任务 ${taskId} 完成`,
378
+ extraEvents: [{ type: "task_completed", payload: { task_id: taskId, attempt_id: attempt.attempt_id } }],
379
+ postCommit: (_pr, _ch, cr) => {
380
+ const lines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
381
+ const idx = findTaskLine(lines, taskId);
382
+ if (idx < 0 || !lines[idx].match(/- \[ \]/))
383
+ throw new Error(`找不到 ${taskId} 的未完成复选框`);
384
+ // H2 修复:应用前验证结构指纹
385
+ const beforeDigest = sha256Text(lines.join("\n").replace(/- \[[xX]\]/g, "- [ ]"));
386
+ lines[idx] = lines[idx].replace(/- \[ \]/, "- [x]");
387
+ writeFileSync(join(cr, "tasks.md"), lines.join("\n"));
388
+ // H2 修复:应用后验证只有目标变了
389
+ const afterLines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
390
+ const afterDigest = sha256Text(afterLines.join("\n").replace(/- \[[xX]\]/g, "- [ ]"));
391
+ if (afterDigest !== beforeDigest) {
392
+ afterLines[idx] = afterLines[idx].replace(/- \[x\]/, "- [ ]");
393
+ writeFileSync(join(cr, "tasks.md"), afterLines.join("\n"));
394
+ throw new Error(`复选框补丁导致结构变化:${taskId}`);
395
+ }
396
+ },
397
+ };
398
+ },
399
+ });
400
+ }