@peterxiaoyang/superspec 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { installProject } from "./install.js";
5
5
  import { writeSnapshot } from "./store.js";
6
6
  import { rebuildSnapshot } from "./sync.js";
7
7
  import { next as nextCmd } from "./next.js";
8
- import { proposeReady, commitTransition, transitionInit, transitionExplore, startApply, taskStart, taskComplete, reviewReady, accept, archive } from "./transition.js";
8
+ import { proposeReady, commitTransition, transitionInit, transitionExplore, startApply, taskStart, taskComplete, reopen, reviewReady, accept, archive } from "./transition.js";
9
9
  import { recordJobSubmit, recordUserDecision, jobsList, jobsPacket } from "./record.js";
10
10
  import { recordTestRun } from "./task.js";
11
11
  import { probeOpenSpec, openspecStatus, changeRoot } from "./openspec.js";
@@ -62,6 +62,7 @@ async function main(argv) {
62
62
  transition 子命令:
63
63
  init / explore / sync / next / propose-ready / start-apply
64
64
  task-start --task <T> / task-complete --task <T>
65
+ reopen --to apply --reason <TEXT>
65
66
  review-ready / accept / archive
66
67
 
67
68
  record 子命令:
@@ -215,6 +216,21 @@ jobs 子命令:
215
216
  console.log(JSON.stringify(result, null, 2));
216
217
  return result.events_written === 0 ? 1 : 0;
217
218
  }
219
+ case "reopen": {
220
+ const to = opts.to;
221
+ const reason = opts.reason;
222
+ if (!to) {
223
+ console.error("reopen 需要 --to");
224
+ return 1;
225
+ }
226
+ if (!reason) {
227
+ console.error("reopen 需要 --reason");
228
+ return 1;
229
+ }
230
+ const result = reopen(projectRoot, change, cr, to, reason);
231
+ console.log(JSON.stringify(result, null, 2));
232
+ return result.events_written === 0 ? 1 : 0;
233
+ }
218
234
  case "review-ready": {
219
235
  const risk = opts.risk ?? "strict";
220
236
  const result = reviewReady(projectRoot, change, cr, risk);
package/dist/format.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface ParsedTask {
24
24
  }
25
25
  /** 解析 tasks.md 的全部任务行 */
26
26
  export declare function parseTasksMd(content: string): ParsedTask[];
27
+ /** 返回未完成任务 */
28
+ export declare function pendingTasksInContent(content: string): ParsedTask[];
27
29
  /** 在 tasks.md 中按 taskId 精确查找任务(词边界,不误判子串) */
28
30
  export declare function findTaskInLines(lines: string[], taskId: string): number;
29
31
  /** tasks.md 结构指纹(复选框归一化) */
package/dist/format.js CHANGED
@@ -94,6 +94,10 @@ export function parseTasksMd(content) {
94
94
  }
95
95
  return tasks;
96
96
  }
97
+ /** 返回未完成任务 */
98
+ export function pendingTasksInContent(content) {
99
+ return parseTasksMd(content).filter(task => !task.done);
100
+ }
97
101
  /** 在 tasks.md 中按 taskId 精确查找任务(词边界,不误判子串) */
98
102
  export function findTaskInLines(lines, taskId) {
99
103
  for (let i = 0; i < lines.length; i++) {
package/dist/next.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { rebuildSnapshot } from "./sync.js";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions } from "./format.js";
5
+ import { readEvents, sha256Text } from "./store.js";
6
+ import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
6
7
  const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
7
8
  function isActiveProposalReviewJob(job) {
8
9
  return job.created_from_transition === "propose-ready" && ACTIVE_PROPOSAL_REVIEW_ROLES.has(job.role);
@@ -16,6 +17,50 @@ function transitionCommand(change, name, extra = "") {
16
17
  function riskFlag(risk) {
17
18
  return risk === "strict" ? "" : `--risk ${risk}`;
18
19
  }
20
+ function pendingTaskIds(changeRoot) {
21
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
22
+ return pendingTasksInContent(tasksContent).map(task => task.taskId);
23
+ }
24
+ function reopenCommand(change, pending) {
25
+ return transitionCommand(change, "reopen", `--to apply --reason "pending tasks: ${pending.join(", ")}"`);
26
+ }
27
+ function taskCompletionReadiness(projectRoot, change, changeRoot, attempt) {
28
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
29
+ const tasks = parseTasksMd(tasksContent);
30
+ const taskInfo = tasks.find(task => task.taskId === attempt.task_id);
31
+ const missing = [];
32
+ if (!taskInfo)
33
+ return { ready: false, missing: [`任务 ${attempt.task_id} 不存在`] };
34
+ const currentDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
35
+ // Keep the wording aligned with task-complete; no command should be suggested if it would fail this guard.
36
+ if (currentDigest !== attempt.task_structure_digest)
37
+ missing.push("任务结构指纹");
38
+ if (!taskInfo.tddRequired) {
39
+ if (!taskInfo.noTddReason)
40
+ missing.push("no_tdd_reason");
41
+ return { ready: missing.length === 0, missing };
42
+ }
43
+ let hasRed = false;
44
+ let hasGreen = false;
45
+ for (const ev of readEvents(projectRoot, change)) {
46
+ if (ev.event_type !== "test_run_recorded")
47
+ continue;
48
+ const tr = ev.payload;
49
+ const matches = tr.attempt_id === attempt.attempt_id ||
50
+ (!tr.attempt_id && tr.task_structure_digest === attempt.task_structure_digest);
51
+ if (!matches)
52
+ continue;
53
+ if (tr.semantic_status === "expected_failure" || tr.semantic_status === "characterization_pass")
54
+ hasRed = true;
55
+ if (tr.semantic_status === "expected_success")
56
+ hasGreen = true;
57
+ }
58
+ if (!hasRed)
59
+ missing.push("RED 证据");
60
+ if (!hasGreen)
61
+ missing.push("GREEN 证据");
62
+ return { ready: missing.length === 0, missing };
63
+ }
19
64
  /** next 命令:读 snapshot,返回唯一可执行路径 */
20
65
  export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
21
66
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
@@ -114,6 +159,36 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
114
159
  };
115
160
  }
116
161
  case "apply": {
162
+ // 检查是否所有任务已完成
163
+ const pending = pendingTaskIds(changeRoot);
164
+ if (pending.length > 0) {
165
+ const activePending = snapshot.active_task_attempts.find(attempt => attempt.state === "active" && pending.includes(attempt.task_id));
166
+ if (activePending) {
167
+ const readiness = taskCompletionReadiness(projectRoot, change, changeRoot, activePending);
168
+ if (readiness.ready) {
169
+ return {
170
+ state: "apply",
171
+ path: "next_command",
172
+ next_command: transitionCommand(change, "task-complete", `--task ${activePending.task_id}`),
173
+ reason: `任务 ${activePending.task_id} 证据已登记,可以完成`,
174
+ missing_inputs: [],
175
+ };
176
+ }
177
+ const ask = {
178
+ question: `任务 ${activePending.task_id} 已开始,请先登记 ${readiness.missing.join("、")} 后继续`,
179
+ allowed_answers: ["证据已登记"],
180
+ scope: `apply_active_task_${activePending.task_id}`,
181
+ };
182
+ return { state: "apply", path: "ask_user", ask_user: ask, reason: `任务 ${activePending.task_id} 缺少完成证据` };
183
+ }
184
+ return {
185
+ state: "apply",
186
+ path: "next_command",
187
+ next_command: transitionCommand(change, "task-start", `--task ${pending[0]}`),
188
+ reason: `执行中:下一个未完成任务 ${pending[0]}`,
189
+ missing_inputs: [],
190
+ };
191
+ }
117
192
  // 有 open job → 做
118
193
  if (snapshot.open_jobs.length > 0) {
119
194
  return {
@@ -123,27 +198,25 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
123
198
  reason: `有 ${snapshot.open_jobs.length} 个待完成工作项`,
124
199
  };
125
200
  }
126
- // 检查是否所有任务已完成
127
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
128
- const allDone = !tasksContent.split("\n").some(l => l.includes("- [ ]"));
129
- if (allDone) {
130
- return {
131
- state: "apply",
132
- path: "next_command",
133
- next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
134
- reason: "所有任务完成,进入审查",
135
- missing_inputs: [],
136
- };
137
- }
138
201
  return {
139
202
  state: "apply",
140
203
  path: "next_command",
141
- next_command: transitionCommand(change, "task-start", "--task TASK-XXX"),
142
- reason: "执行中:查看 tasks.md 找下一个未完成任务",
204
+ next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
205
+ reason: "所有任务完成,进入审查",
143
206
  missing_inputs: [],
144
207
  };
145
208
  }
146
209
  case "apply_done": {
210
+ const pending = pendingTaskIds(changeRoot);
211
+ if (pending.length > 0) {
212
+ return {
213
+ state: "apply_done",
214
+ path: "next_command",
215
+ next_command: reopenCommand(change, pending),
216
+ reason: `发现未完成任务 ${pending[0]},回到执行阶段`,
217
+ missing_inputs: [],
218
+ };
219
+ }
147
220
  if (snapshot.open_jobs.length > 0) {
148
221
  return {
149
222
  state: "apply_done",
@@ -160,7 +233,17 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
160
233
  missing_inputs: [],
161
234
  };
162
235
  }
163
- case "review":
236
+ case "review": {
237
+ const pending = pendingTaskIds(changeRoot);
238
+ if (pending.length > 0) {
239
+ return {
240
+ state: "review",
241
+ path: "next_command",
242
+ next_command: reopenCommand(change, pending),
243
+ reason: `发现未完成任务 ${pending[0]},回到执行阶段`,
244
+ missing_inputs: [],
245
+ };
246
+ }
164
247
  return {
165
248
  state: "review",
166
249
  path: "next_command",
@@ -168,6 +251,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
168
251
  reason: "审查完成,提交接受",
169
252
  missing_inputs: [],
170
253
  };
254
+ }
171
255
  case "accepted":
172
256
  return {
173
257
  state: "accepted",
package/dist/sync.js CHANGED
@@ -78,13 +78,13 @@ function replayEvents(events) {
78
78
  }
79
79
  return { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition };
80
80
  }
81
- /** 粗粒度失效:检查 accepted job 的 boundFiles 是否仍匹配当前文档 */
82
- function checkStaleJobs(acceptedJobs, documentDigests) {
81
+ /** 粗粒度失效:检查 job 的 boundFiles 是否仍匹配当前文档 */
82
+ function checkStaleJobs(jobs, changeRoot) {
83
83
  const stale = [];
84
- for (const job of acceptedJobs) {
84
+ for (const job of jobs) {
85
85
  for (const bf of job.boundFiles) {
86
- const current = documentDigests[bf.path];
87
- if (current && current !== bf.sha) {
86
+ const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
87
+ if (current !== bf.sha) {
88
88
  stale.push({
89
89
  job_id: job.job_id,
90
90
  reason: `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`,
@@ -113,10 +113,11 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
113
113
  const documentDigests = computeDocumentDigests(changeRoot, TRACKED_DOCS);
114
114
  const tsDigest = tasksStructureDigest(changeRoot);
115
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));
116
+ // 粗粒度失效检查(只读,不写事件):snapshot 只暴露当前可执行/可复用 job。
117
+ const staleOpenInfo = checkStaleJobs(openJobs, changeRoot);
118
+ const staleAcceptedInfo = checkStaleJobs(acceptedJobs, changeRoot);
119
+ const freshOpen = openJobs.filter(j => !staleOpenInfo.some(s => s.job_id === j.job_id));
120
+ const freshAccepted = acceptedJobs.filter(j => !staleAcceptedInfo.some(s => s.job_id === j.job_id));
120
121
  return {
121
122
  change_id: change,
122
123
  state,
@@ -125,7 +126,7 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
125
126
  document_digests: documentDigests,
126
127
  tasks_structure_digest: tsDigest,
127
128
  task_statuses: taskStatuses,
128
- open_jobs: openJobs,
129
+ open_jobs: freshOpen,
129
130
  accepted_jobs: freshAccepted,
130
131
  active_task_attempts: activeAttempts,
131
132
  pending_user_decisions: [],
@@ -28,6 +28,7 @@ export declare function transitionInit(projectRoot: string, change: string, chan
28
28
  export declare function transitionExplore(projectRoot: string, change: string, changeRoot: string, risk?: "minimal" | "normal" | "strict"): TransitionResult;
29
29
  export declare function startApply(projectRoot: string, change: string, changeRoot: string): TransitionResult;
30
30
  export declare function taskStart(projectRoot: string, change: string, changeRoot: string, taskId: string): TransitionResult;
31
+ export declare function reopen(projectRoot: string, change: string, changeRoot: string, to: State, reason: string): TransitionResult;
31
32
  export declare function reviewReady(projectRoot: string, change: string, changeRoot: string, risk?: "minimal" | "normal" | "strict"): TransitionResult;
32
33
  export declare function accept(projectRoot: string, change: string, changeRoot: string): TransitionResult;
33
34
  export declare function archive(projectRoot: string, change: string, changeRoot: string): TransitionResult;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, stagingDir, sha256File, sha256Text, } from "./store.js";
5
5
  import { rebuildSnapshot } from "./sync.js";
6
- import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, tasksStructureDigest } from "./format.js";
6
+ import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, pendingTasksInContent, tasksStructureDigest } from "./format.js";
7
7
  let transitionSeq = 0;
8
8
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
9
9
  let jobSeq = 0;
@@ -78,6 +78,13 @@ function historicalProposeReadyRoles(projectRoot, change) {
78
78
  function findTaskLine(lines, taskId) {
79
79
  return findTaskInLines(lines, taskId);
80
80
  }
81
+ function pendingTaskIds(changeRoot) {
82
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
83
+ return pendingTasksInContent(tasksContent).map(task => task.taskId);
84
+ }
85
+ function formatPendingTaskMessage(ids, action) {
86
+ return `尚有未完成任务:${ids.join(", ")};${action}`;
87
+ }
81
88
  /**
82
89
  * 统一 transition 提交协议——所有校验在锁内。
83
90
  */
@@ -275,16 +282,39 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
275
282
  },
276
283
  });
277
284
  }
285
+ // ===== reopen =====
286
+ export function reopen(projectRoot, change, changeRoot, to, reason) {
287
+ return commitTransition(projectRoot, change, changeRoot, {
288
+ name: "reopen", idempotencyInputs: { to, reason },
289
+ decide: (snapshot) => {
290
+ if (to !== "apply")
291
+ return { skip: true, message: `reopen 当前只支持 --to apply,不支持 ${to}` };
292
+ if (!reason || reason.trim() === "")
293
+ return { skip: true, message: "reopen 需要非空 --reason" };
294
+ if (snapshot.state !== "apply_done" && snapshot.state !== "review") {
295
+ return { skip: true, message: `当前状态 ${snapshot.state},不能 reopen 到 apply` };
296
+ }
297
+ const pending = pendingTaskIds(changeRoot);
298
+ if (pending.length === 0)
299
+ return { skip: true, message: "没有未完成任务,不能 reopen 到 apply" };
300
+ return {
301
+ fromState: snapshot.state,
302
+ toState: "apply",
303
+ outcome: "advanced",
304
+ reason: `${reason.trim()}(pending tasks: ${pending.join(", ")})`,
305
+ };
306
+ },
307
+ });
308
+ }
278
309
  // ===== review-ready =====
279
310
  export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
280
311
  return commitTransition(projectRoot, change, changeRoot, {
281
312
  name: "review-ready", idempotencyInputs: { phase: "review-ready", risk },
282
313
  decide: (snapshot) => {
283
314
  // 检查是否所有任务已完成
284
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
285
- const allDone = !tasksContent.split("\n").some(l => l.includes("- [ ]"));
286
- if (!allDone)
287
- return { skip: true, message: "尚有未完成任务" };
315
+ const pending = pendingTaskIds(changeRoot);
316
+ if (pending.length > 0)
317
+ return { skip: true, message: formatPendingTaskMessage(pending, "请先通过 next/reopen 继续执行") };
288
318
  // 如果当前是 apply,先推进到 apply_done
289
319
  if (snapshot.state === "apply") {
290
320
  return { fromState: "apply", toState: "apply_done", outcome: "advanced", reason: "所有任务完成" };
@@ -322,6 +352,9 @@ export function accept(projectRoot, change, changeRoot) {
322
352
  decide: (snapshot) => {
323
353
  if (snapshot.state !== "review")
324
354
  return { skip: true, message: `当前状态 ${snapshot.state},需要 review` };
355
+ const pending = pendingTaskIds(changeRoot);
356
+ if (pending.length > 0)
357
+ return { skip: true, message: formatPendingTaskMessage(pending, "请先 reopen --to apply 继续执行") };
325
358
  return { fromState: "review", toState: "accepted", outcome: "advanced", reason: "审查通过" };
326
359
  },
327
360
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=20.19.0" },