@peterxiaoyang/superspec 0.1.20 → 0.1.22
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 +1 -1
- package/dist/cli.js +319 -10
- 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/README.md
CHANGED
|
@@ -238,7 +238,7 @@ superspec status
|
|
|
238
238
|
superspec update
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
这条命令会先检查 npm 上的 latest 版本;如果有新版,会自动执行全局升级并用新版 CLI 重新同步项目入口。同步内容包括补齐 `.superspec/changes` 运行时目录,并把当前 CLI 内置的 `.codex/skills/superspec-*`、`.codex/prompts/*.md`、`.codex/agents/*.toml` 同步到项目里。
|
|
242
242
|
|
|
243
243
|
## 进阶信息
|
|
244
244
|
|
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SuperSpec 流程引擎 — CLI 入口
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
4
6
|
import { installProject } from "./install.js";
|
|
5
7
|
import { writeSnapshot } from "./store.js";
|
|
6
8
|
import { rebuildSnapshot } from "./sync.js";
|
|
7
9
|
import { next as nextCmd } from "./next.js";
|
|
8
|
-
import { proposeReady, commitTransition, transitionInit, transitionExplore, startApply, taskStart, taskComplete, reviewReady, accept, archive } from "./transition.js";
|
|
10
|
+
import { proposeReady, commitTransition, transitionInit, transitionExplore, startApply, taskStart, taskComplete, reopen, reviewReady, accept, archive } from "./transition.js";
|
|
9
11
|
import { recordJobSubmit, recordUserDecision, jobsList, jobsPacket } from "./record.js";
|
|
10
12
|
import { recordTestRun } from "./task.js";
|
|
11
13
|
import { probeOpenSpec, openspecStatus, changeRoot } from "./openspec.js";
|
|
12
14
|
import { SUPERSPEC_VERSION } from "./version.js";
|
|
15
|
+
const PACKAGE_NAME = "@peterxiaoyang/superspec";
|
|
13
16
|
// ===== 参数解析 =====
|
|
14
17
|
function parseArgs(argv) {
|
|
15
18
|
const [command, subcommand, ...rest] = argv;
|
|
@@ -39,6 +42,279 @@ function parseFlags(args) {
|
|
|
39
42
|
}
|
|
40
43
|
return opts;
|
|
41
44
|
}
|
|
45
|
+
function parseVersion(version) {
|
|
46
|
+
const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
47
|
+
if (!match)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
major: Number(match[1]),
|
|
51
|
+
minor: Number(match[2]),
|
|
52
|
+
patch: Number(match[3]),
|
|
53
|
+
prerelease: match[4] ?? null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function compareVersions(a, b) {
|
|
57
|
+
const left = parseVersion(a);
|
|
58
|
+
const right = parseVersion(b);
|
|
59
|
+
if (!left || !right)
|
|
60
|
+
return a.localeCompare(b);
|
|
61
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
62
|
+
if (left[key] !== right[key])
|
|
63
|
+
return left[key] - right[key];
|
|
64
|
+
}
|
|
65
|
+
if (left.prerelease === right.prerelease)
|
|
66
|
+
return 0;
|
|
67
|
+
if (left.prerelease == null)
|
|
68
|
+
return 1;
|
|
69
|
+
if (right.prerelease == null)
|
|
70
|
+
return -1;
|
|
71
|
+
return left.prerelease.localeCompare(right.prerelease);
|
|
72
|
+
}
|
|
73
|
+
function commandErrorMessage(err) {
|
|
74
|
+
if (!err || typeof err !== "object")
|
|
75
|
+
return String(err);
|
|
76
|
+
const maybe = err;
|
|
77
|
+
const stderr = maybe.stderr ? Buffer.from(maybe.stderr).toString("utf8").trim() : "";
|
|
78
|
+
const stdout = maybe.stdout ? Buffer.from(maybe.stdout).toString("utf8").trim() : "";
|
|
79
|
+
return stderr || stdout || maybe.message || String(err);
|
|
80
|
+
}
|
|
81
|
+
class SelfUpdateError extends Error {
|
|
82
|
+
phase;
|
|
83
|
+
latest;
|
|
84
|
+
constructor(phase, message, latest = null) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "SelfUpdateError";
|
|
87
|
+
this.phase = phase;
|
|
88
|
+
this.latest = latest;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isTestMode() {
|
|
92
|
+
return process.env.SUPERSPEC_TEST_MODE === "1" || process.env.NODE_ENV === "test";
|
|
93
|
+
}
|
|
94
|
+
function testEnv(name) {
|
|
95
|
+
return isTestMode() ? process.env[name] : undefined;
|
|
96
|
+
}
|
|
97
|
+
function selfUpdateError(phase, err, latest = null) {
|
|
98
|
+
return new SelfUpdateError(phase, commandErrorMessage(err), latest);
|
|
99
|
+
}
|
|
100
|
+
function attachSelfUpdateLatest(err, phase, latest) {
|
|
101
|
+
if (err instanceof SelfUpdateError) {
|
|
102
|
+
return new SelfUpdateError(err.phase, err.message, err.latest ?? latest);
|
|
103
|
+
}
|
|
104
|
+
return selfUpdateError(phase, err, latest);
|
|
105
|
+
}
|
|
106
|
+
function selfUpdateFailurePayload(err) {
|
|
107
|
+
if (err instanceof SelfUpdateError) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
message: err.message,
|
|
111
|
+
self_update: {
|
|
112
|
+
updated: false,
|
|
113
|
+
from: SUPERSPEC_VERSION,
|
|
114
|
+
to: err.latest,
|
|
115
|
+
phase: err.phase,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: commandErrorMessage(err),
|
|
122
|
+
self_update: {
|
|
123
|
+
updated: false,
|
|
124
|
+
from: SUPERSPEC_VERSION,
|
|
125
|
+
to: null,
|
|
126
|
+
phase: "unknown",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function npmLatestVersion() {
|
|
131
|
+
const testError = testEnv("SUPERSPEC_TEST_NPM_VIEW_ERROR");
|
|
132
|
+
if (testError)
|
|
133
|
+
throw new SelfUpdateError("npm_view", testError);
|
|
134
|
+
const testLatest = testEnv("SUPERSPEC_TEST_LATEST_VERSION");
|
|
135
|
+
if (testLatest)
|
|
136
|
+
return testLatest;
|
|
137
|
+
try {
|
|
138
|
+
const output = execFileSync("npm", ["view", PACKAGE_NAME, "version"], { encoding: "utf8" });
|
|
139
|
+
return output.trim().replace(/^"|"$/g, "");
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
throw selfUpdateError("npm_view", err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function installLatestGlobal() {
|
|
146
|
+
const testError = testEnv("SUPERSPEC_TEST_GLOBAL_INSTALL_ERROR");
|
|
147
|
+
if (testError)
|
|
148
|
+
throw new SelfUpdateError("global_install", testError);
|
|
149
|
+
if (testEnv("SUPERSPEC_TEST_SKIP_GLOBAL_INSTALL") === "1")
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
execFileSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw selfUpdateError("global_install", err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function parseSuperSpecVersion(output) {
|
|
162
|
+
const match = output.trim().match(/^SuperSpec\s+(.+)$/);
|
|
163
|
+
return match?.[1]?.trim() ?? null;
|
|
164
|
+
}
|
|
165
|
+
function pathCliVersion() {
|
|
166
|
+
const testError = testEnv("SUPERSPEC_TEST_CLI_VERSION_ERROR");
|
|
167
|
+
if (testError)
|
|
168
|
+
throw new Error(testError);
|
|
169
|
+
const testVersion = testEnv("SUPERSPEC_TEST_CLI_VERSION");
|
|
170
|
+
if (testVersion)
|
|
171
|
+
return testVersion;
|
|
172
|
+
const testOutput = testEnv("SUPERSPEC_TEST_CLI_VERSION_OUTPUT");
|
|
173
|
+
if (testOutput) {
|
|
174
|
+
const parsed = parseSuperSpecVersion(testOutput);
|
|
175
|
+
if (!parsed)
|
|
176
|
+
throw new Error(`无法解析 superspec --version 输出:${testOutput}`);
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
const output = execFileSync("superspec", ["--version"], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
env: process.env,
|
|
182
|
+
});
|
|
183
|
+
const parsed = parseSuperSpecVersion(output);
|
|
184
|
+
if (!parsed)
|
|
185
|
+
throw new Error(`无法解析 superspec --version 输出:${output.trim()}`);
|
|
186
|
+
return parsed;
|
|
187
|
+
}
|
|
188
|
+
function assertUpdatedCliVersion(latest) {
|
|
189
|
+
let actual;
|
|
190
|
+
try {
|
|
191
|
+
actual = pathCliVersion();
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
throw selfUpdateError("version_check", err, latest);
|
|
195
|
+
}
|
|
196
|
+
if (actual !== latest) {
|
|
197
|
+
throw new SelfUpdateError("version_mismatch", `全局 superspec 版本仍为 ${actual},期望 ${latest}。请检查 npm 全局 bin 是否在 PATH 前置。`, latest);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function rerunUpdatedCli(projectRoot, args) {
|
|
201
|
+
const testError = testEnv("SUPERSPEC_TEST_RERUN_ERROR");
|
|
202
|
+
if (testError)
|
|
203
|
+
throw new SelfUpdateError("rerun", testError);
|
|
204
|
+
const testOutput = testEnv("SUPERSPEC_TEST_RERUN_OUTPUT");
|
|
205
|
+
if (testOutput)
|
|
206
|
+
return testOutput;
|
|
207
|
+
try {
|
|
208
|
+
return execFileSync("superspec", args, {
|
|
209
|
+
cwd: projectRoot,
|
|
210
|
+
encoding: "utf8",
|
|
211
|
+
env: process.env,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw selfUpdateError("rerun", err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function updateSelfIfNeeded(projectRoot, rerunArgs) {
|
|
219
|
+
const latest = npmLatestVersion();
|
|
220
|
+
if (compareVersions(latest, SUPERSPEC_VERSION) <= 0)
|
|
221
|
+
return { updated: false, latest };
|
|
222
|
+
try {
|
|
223
|
+
installLatestGlobal();
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
throw attachSelfUpdateLatest(err, "global_install", latest);
|
|
227
|
+
}
|
|
228
|
+
assertUpdatedCliVersion(latest);
|
|
229
|
+
let output;
|
|
230
|
+
try {
|
|
231
|
+
output = rerunUpdatedCli(projectRoot, rerunArgs);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
throw attachSelfUpdateLatest(err, "rerun", latest);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
updated: true,
|
|
238
|
+
latest,
|
|
239
|
+
output,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function updatedCliOutput(output, latest) {
|
|
243
|
+
const trimmed = output.trim();
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(trimmed);
|
|
246
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
247
|
+
throw new Error("新版 CLI 输出不是 JSON object");
|
|
248
|
+
}
|
|
249
|
+
const payload = {
|
|
250
|
+
...parsed,
|
|
251
|
+
self_update: {
|
|
252
|
+
updated: true,
|
|
253
|
+
from: SUPERSPEC_VERSION,
|
|
254
|
+
to: latest,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
exitCode: payload.ok === false ? 1 : 0,
|
|
259
|
+
text: JSON.stringify(payload, null, 2),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
const failure = new SelfUpdateError("rerun_output", "新版 CLI 输出不是 JSON object", latest);
|
|
264
|
+
return {
|
|
265
|
+
exitCode: 1,
|
|
266
|
+
text: JSON.stringify(selfUpdateFailurePayload(failure), null, 2),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function askToUpdateSelfIfNeeded(projectRoot, rerunArgs) {
|
|
271
|
+
const assumeTty = testEnv("SUPERSPEC_TEST_ASSUME_TTY") === "1";
|
|
272
|
+
if (!assumeTty && (!process.stdin.isTTY || !process.stdout.isTTY))
|
|
273
|
+
return { updated: false, latest: null };
|
|
274
|
+
let latest;
|
|
275
|
+
try {
|
|
276
|
+
latest = npmLatestVersion();
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
console.error(`SuperSpec 检查最新版本失败:${commandErrorMessage(err)}。继续使用当前版本。`);
|
|
280
|
+
return { updated: false, latest: null };
|
|
281
|
+
}
|
|
282
|
+
if (compareVersions(latest, SUPERSPEC_VERSION) <= 0)
|
|
283
|
+
return { updated: false, latest };
|
|
284
|
+
const testAnswer = testEnv("SUPERSPEC_TEST_PROMPT_ANSWER");
|
|
285
|
+
if (testAnswer !== undefined) {
|
|
286
|
+
if (/^n(o)?$/i.test(testAnswer.trim()))
|
|
287
|
+
return { updated: false, latest };
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
291
|
+
try {
|
|
292
|
+
const answer = await rl.question(`发现 SuperSpec ${latest} 可用,当前为 ${SUPERSPEC_VERSION}。是否先升级再继续? [Y/n] `);
|
|
293
|
+
if (/^n(o)?$/i.test(answer.trim()))
|
|
294
|
+
return { updated: false, latest };
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
rl.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
installLatestGlobal();
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw attachSelfUpdateLatest(err, "global_install", latest);
|
|
305
|
+
}
|
|
306
|
+
assertUpdatedCliVersion(latest);
|
|
307
|
+
try {
|
|
308
|
+
return {
|
|
309
|
+
updated: true,
|
|
310
|
+
latest,
|
|
311
|
+
output: rerunUpdatedCli(projectRoot, rerunArgs),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
throw attachSelfUpdateLatest(err, "rerun", latest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
42
318
|
// ===== 初始化 transition init =====
|
|
43
319
|
// ===== init/explore 现在在 transition.ts 中(走统一锁内路径)=====
|
|
44
320
|
// ===== 主分发 =====
|
|
@@ -56,12 +332,13 @@ async function main(argv) {
|
|
|
56
332
|
jobs <子命令> --change <C> 工作项管理(见下)
|
|
57
333
|
install 安装项目工作流入口
|
|
58
334
|
init --scope project install 的兼容别名
|
|
59
|
-
update
|
|
335
|
+
update 升级 CLI 到 npm latest 并同步项目工作流模板
|
|
60
336
|
version 版本号
|
|
61
337
|
|
|
62
338
|
transition 子命令:
|
|
63
339
|
init / explore / sync / next / propose-ready / start-apply
|
|
64
340
|
task-start --task <T> / task-complete --task <T>
|
|
341
|
+
reopen --to apply --reason <TEXT>
|
|
65
342
|
review-ready / accept / archive
|
|
66
343
|
|
|
67
344
|
record 子命令:
|
|
@@ -91,19 +368,37 @@ jobs 子命令:
|
|
|
91
368
|
return 1;
|
|
92
369
|
}
|
|
93
370
|
try {
|
|
371
|
+
if (opts["skip-self-update"] !== "true") {
|
|
372
|
+
const rerunArgs = command === "init"
|
|
373
|
+
? ["init", "--scope", "project", "--skip-self-update"]
|
|
374
|
+
: ["install", "--skip-self-update"];
|
|
375
|
+
const selfUpdate = await askToUpdateSelfIfNeeded(projectRoot, rerunArgs);
|
|
376
|
+
if (selfUpdate.updated) {
|
|
377
|
+
const rerun = updatedCliOutput(selfUpdate.output, selfUpdate.latest);
|
|
378
|
+
console.log(rerun.text);
|
|
379
|
+
return rerun.exitCode;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
94
382
|
console.log(JSON.stringify(installProject(projectRoot)));
|
|
95
383
|
return 0;
|
|
96
384
|
}
|
|
97
385
|
catch (err) {
|
|
98
|
-
console.log(JSON.stringify(
|
|
99
|
-
|
|
100
|
-
message: err
|
|
101
|
-
}));
|
|
386
|
+
console.log(JSON.stringify(err instanceof SelfUpdateError
|
|
387
|
+
? selfUpdateFailurePayload(err)
|
|
388
|
+
: { ok: false, message: commandErrorMessage(err) }));
|
|
102
389
|
return 1;
|
|
103
390
|
}
|
|
104
391
|
}
|
|
105
392
|
if (command === "update") {
|
|
106
393
|
try {
|
|
394
|
+
if (opts["skip-self-update"] !== "true") {
|
|
395
|
+
const selfUpdate = updateSelfIfNeeded(projectRoot, ["update", "--skip-self-update"]);
|
|
396
|
+
if (selfUpdate.updated) {
|
|
397
|
+
const rerun = updatedCliOutput(selfUpdate.output, selfUpdate.latest);
|
|
398
|
+
console.log(rerun.text);
|
|
399
|
+
return rerun.exitCode;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
107
402
|
const result = installProject(projectRoot, { allowLegacyState: true });
|
|
108
403
|
console.log(JSON.stringify({
|
|
109
404
|
...result,
|
|
@@ -112,10 +407,9 @@ jobs 子命令:
|
|
|
112
407
|
return 0;
|
|
113
408
|
}
|
|
114
409
|
catch (err) {
|
|
115
|
-
console.log(JSON.stringify(
|
|
116
|
-
|
|
117
|
-
message: err
|
|
118
|
-
}));
|
|
410
|
+
console.log(JSON.stringify(err instanceof SelfUpdateError
|
|
411
|
+
? selfUpdateFailurePayload(err)
|
|
412
|
+
: { ok: false, message: commandErrorMessage(err) }));
|
|
119
413
|
return 1;
|
|
120
414
|
}
|
|
121
415
|
}
|
|
@@ -215,6 +509,21 @@ jobs 子命令:
|
|
|
215
509
|
console.log(JSON.stringify(result, null, 2));
|
|
216
510
|
return result.events_written === 0 ? 1 : 0;
|
|
217
511
|
}
|
|
512
|
+
case "reopen": {
|
|
513
|
+
const to = opts.to;
|
|
514
|
+
const reason = opts.reason;
|
|
515
|
+
if (!to) {
|
|
516
|
+
console.error("reopen 需要 --to");
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
519
|
+
if (!reason) {
|
|
520
|
+
console.error("reopen 需要 --reason");
|
|
521
|
+
return 1;
|
|
522
|
+
}
|
|
523
|
+
const result = reopen(projectRoot, change, cr, to, reason);
|
|
524
|
+
console.log(JSON.stringify(result, null, 2));
|
|
525
|
+
return result.events_written === 0 ? 1 : 0;
|
|
526
|
+
}
|
|
218
527
|
case "review-ready": {
|
|
219
528
|
const risk = opts.risk ?? "strict";
|
|
220
529
|
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
|
});
|