@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 +17 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.js +4 -0
- package/dist/next.js +100 -16
- package/dist/sync.js +11 -10
- package/dist/transition.d.ts +1 -0
- package/dist/transition.js +38 -5
- package/package.json +1 -1
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 {
|
|
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, "
|
|
142
|
-
reason: "
|
|
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
|
-
/** 粗粒度失效:检查
|
|
82
|
-
function checkStaleJobs(
|
|
81
|
+
/** 粗粒度失效:检查 job 的 boundFiles 是否仍匹配当前文档 */
|
|
82
|
+
function checkStaleJobs(jobs, changeRoot) {
|
|
83
83
|
const stale = [];
|
|
84
|
-
for (const job of
|
|
84
|
+
for (const job of jobs) {
|
|
85
85
|
for (const bf of job.boundFiles) {
|
|
86
|
-
const current =
|
|
87
|
-
if (current
|
|
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
|
|
118
|
-
|
|
119
|
-
const
|
|
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:
|
|
129
|
+
open_jobs: freshOpen,
|
|
129
130
|
accepted_jobs: freshAccepted,
|
|
130
131
|
active_task_attempts: activeAttempts,
|
|
131
132
|
pending_user_decisions: [],
|
package/dist/transition.d.ts
CHANGED
|
@@ -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;
|
package/dist/transition.js
CHANGED
|
@@ -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
|
|
285
|
-
|
|
286
|
-
|
|
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
|
});
|