@peterxiaoyang/superspec 0.1.21 → 0.1.23
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 +302 -9
- package/dist/next.js +38 -0
- package/dist/record.js +31 -4
- package/dist/review.d.ts +17 -0
- package/dist/review.js +159 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +50 -0
- package/dist/sync.js +9 -13
- package/dist/task.js +7 -2
- package/dist/transition.d.ts +1 -0
- package/dist/transition.js +93 -15
- package/dist/types.d.ts +7 -0
- package/package.json +16 -5
- package/templates/workflow/skills/superspec-review/SKILL.md +11 -0
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,6 +1,8 @@
|
|
|
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";
|
|
@@ -10,6 +12,7 @@ import { recordJobSubmit, recordUserDecision, jobsList, jobsPacket } from "./rec
|
|
|
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,7 +332,7 @@ 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 子命令:
|
|
@@ -92,19 +368,37 @@ jobs 子命令:
|
|
|
92
368
|
return 1;
|
|
93
369
|
}
|
|
94
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
|
+
}
|
|
95
382
|
console.log(JSON.stringify(installProject(projectRoot)));
|
|
96
383
|
return 0;
|
|
97
384
|
}
|
|
98
385
|
catch (err) {
|
|
99
|
-
console.log(JSON.stringify(
|
|
100
|
-
|
|
101
|
-
message: err
|
|
102
|
-
}));
|
|
386
|
+
console.log(JSON.stringify(err instanceof SelfUpdateError
|
|
387
|
+
? selfUpdateFailurePayload(err)
|
|
388
|
+
: { ok: false, message: commandErrorMessage(err) }));
|
|
103
389
|
return 1;
|
|
104
390
|
}
|
|
105
391
|
}
|
|
106
392
|
if (command === "update") {
|
|
107
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
|
+
}
|
|
108
402
|
const result = installProject(projectRoot, { allowLegacyState: true });
|
|
109
403
|
console.log(JSON.stringify({
|
|
110
404
|
...result,
|
|
@@ -113,10 +407,9 @@ jobs 子命令:
|
|
|
113
407
|
return 0;
|
|
114
408
|
}
|
|
115
409
|
catch (err) {
|
|
116
|
-
console.log(JSON.stringify(
|
|
117
|
-
|
|
118
|
-
message: err
|
|
119
|
-
}));
|
|
410
|
+
console.log(JSON.stringify(err instanceof SelfUpdateError
|
|
411
|
+
? selfUpdateFailurePayload(err)
|
|
412
|
+
: { ok: false, message: commandErrorMessage(err) }));
|
|
120
413
|
return 1;
|
|
121
414
|
}
|
|
122
415
|
}
|
package/dist/next.js
CHANGED
|
@@ -3,6 +3,7 @@ import { rebuildSnapshot } from "./sync.js";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { readEvents, sha256Text } from "./store.js";
|
|
6
|
+
import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
|
|
6
7
|
import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
|
|
7
8
|
const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
|
|
8
9
|
function isActiveProposalReviewJob(job) {
|
|
@@ -244,6 +245,43 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
|
|
|
244
245
|
missing_inputs: [],
|
|
245
246
|
};
|
|
246
247
|
}
|
|
248
|
+
const reviewVerifierJobs = snapshot.open_jobs.filter(isReviewReadyVerifier);
|
|
249
|
+
if (reviewVerifierJobs.length > 0) {
|
|
250
|
+
return {
|
|
251
|
+
state: "review",
|
|
252
|
+
path: "required_job",
|
|
253
|
+
required_jobs: reviewVerifierJobs.map(j => ({
|
|
254
|
+
job_id: j.job_id,
|
|
255
|
+
role: j.role,
|
|
256
|
+
packet_command: packetCommand(change, j.job_id),
|
|
257
|
+
})),
|
|
258
|
+
reason: `有 ${reviewVerifierJobs.length} 个待完成最终验证工作项`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const events = readEvents(projectRoot, change);
|
|
262
|
+
const policy = readReviewPolicyFromEvents(events);
|
|
263
|
+
if (!policy) {
|
|
264
|
+
return {
|
|
265
|
+
state: "review",
|
|
266
|
+
path: "next_command",
|
|
267
|
+
next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
|
|
268
|
+
reason: "缺少审查策略,先补 review-ready",
|
|
269
|
+
missing_inputs: [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (policy.requires_verifier) {
|
|
273
|
+
const currentEvidenceDigest = reviewEvidenceDigest(events);
|
|
274
|
+
const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
|
|
275
|
+
if (!verifierAccepted) {
|
|
276
|
+
return {
|
|
277
|
+
state: "review",
|
|
278
|
+
path: "next_command",
|
|
279
|
+
next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
|
|
280
|
+
reason: "缺少 fresh verifier,先补最终验证",
|
|
281
|
+
missing_inputs: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
247
285
|
return {
|
|
248
286
|
state: "review",
|
|
249
287
|
path: "next_command",
|
package/dist/record.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — record:工作项结果登记
|
|
2
2
|
import { readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, withLock, } from "./store.js";
|
|
4
|
+
import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, withLock, appendRawRecord, } from "./store.js";
|
|
5
|
+
import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
|
|
5
6
|
const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
|
|
6
7
|
const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
|
|
7
8
|
const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
|
|
@@ -97,6 +98,7 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
|
|
|
97
98
|
const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
|
|
98
99
|
// acceptance checks
|
|
99
100
|
const checks = [];
|
|
101
|
+
let parsedReport = null;
|
|
100
102
|
// 0. 报告格式和角色匹配(最小 JSON contract)
|
|
101
103
|
try {
|
|
102
104
|
const report = JSON.parse(reportContent);
|
|
@@ -104,7 +106,8 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
|
|
|
104
106
|
checks.push("报告必须是 JSON object");
|
|
105
107
|
}
|
|
106
108
|
else {
|
|
107
|
-
|
|
109
|
+
parsedReport = report;
|
|
110
|
+
const obj = parsedReport;
|
|
108
111
|
for (const field of REVIEW_REPORT_REQUIRED_FIELDS) {
|
|
109
112
|
if (!(field in obj))
|
|
110
113
|
checks.push(`报告缺少必填字段 ${field}`);
|
|
@@ -149,6 +152,10 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
|
|
|
149
152
|
checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
|
|
150
153
|
}
|
|
151
154
|
}
|
|
155
|
+
const reviewStaleReason = reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
|
|
156
|
+
if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
|
|
157
|
+
checks.push(reviewStaleReason);
|
|
158
|
+
}
|
|
152
159
|
// 2. 报告格式基本校验(非空 JSON 或文本)
|
|
153
160
|
if (!reportContent.trim()) {
|
|
154
161
|
checks.push("报告内容为空");
|
|
@@ -170,11 +177,13 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
|
|
|
170
177
|
};
|
|
171
178
|
}
|
|
172
179
|
// 接受
|
|
180
|
+
const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
|
|
173
181
|
const acceptEvent = makeEvent(change, "job_accepted", {
|
|
174
182
|
job_id: jobId,
|
|
175
183
|
role: job.role,
|
|
176
184
|
report_digest: reportDigest,
|
|
177
185
|
accepted_at: new Date().toISOString(),
|
|
186
|
+
...rawRef,
|
|
178
187
|
});
|
|
179
188
|
appendEvent(projectRoot, change, acceptEvent);
|
|
180
189
|
return {
|
|
@@ -189,6 +198,7 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
|
|
|
189
198
|
export function recordUserDecision(projectRoot, change, inputFile) {
|
|
190
199
|
return withLock(projectRoot, change, () => {
|
|
191
200
|
ensureChangeLayout(projectRoot, change);
|
|
201
|
+
const events = readEvents(projectRoot, change);
|
|
192
202
|
if (!existsSync(inputFile)) {
|
|
193
203
|
appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "file_not_found", path: inputFile }));
|
|
194
204
|
return { event_type: "user_decision_recorded", accepted: false, message: `决策文件不存在:${inputFile}` };
|
|
@@ -206,11 +216,26 @@ export function recordUserDecision(projectRoot, change, inputFile) {
|
|
|
206
216
|
appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "missing_scope_or_answer" }));
|
|
207
217
|
return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scope 或 answer" };
|
|
208
218
|
}
|
|
209
|
-
const
|
|
219
|
+
const inputDigest = sha256File(inputFile) ?? "sha256:unknown";
|
|
220
|
+
const existing = events.find(e => e.event_type === "user_decision_recorded"
|
|
221
|
+
&& e.payload.input_digest === inputDigest);
|
|
222
|
+
if (existing) {
|
|
223
|
+
return {
|
|
224
|
+
event_type: "user_decision_recorded",
|
|
225
|
+
accepted: true,
|
|
226
|
+
message: "幂等返回:同 user decision 已登记",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const normalizedDecision = {
|
|
210
230
|
scope: decision.scope,
|
|
211
231
|
question: decision.question ?? "",
|
|
212
232
|
answer: decision.answer,
|
|
213
|
-
|
|
233
|
+
};
|
|
234
|
+
const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
|
|
235
|
+
const event = makeEvent(change, "user_decision_recorded", {
|
|
236
|
+
...normalizedDecision,
|
|
237
|
+
input_digest: inputDigest,
|
|
238
|
+
...rawRef,
|
|
214
239
|
});
|
|
215
240
|
appendEvent(projectRoot, change, event);
|
|
216
241
|
return {
|
|
@@ -268,11 +293,13 @@ export function jobsPacket(projectRoot, change, jobId) {
|
|
|
268
293
|
role: job.role,
|
|
269
294
|
recommended_agent: recommendedAgentForRole(job.role),
|
|
270
295
|
boundFiles: job.boundFiles,
|
|
296
|
+
...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
|
|
271
297
|
packet_digest: job.packet_digest,
|
|
272
298
|
required_output_kind: "job_report_json",
|
|
273
299
|
output_contract_fields: requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
|
|
274
300
|
output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
|
|
275
301
|
output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
|
|
302
|
+
(job.review_evidence_digest ? `本工作项绑定的执行证据版本为 ${job.review_evidence_digest},` : "") +
|
|
276
303
|
(requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} reviewer 执行并在 reviewer.kind/id 中记录来源,` : "") +
|
|
277
304
|
`产出 JSON 报告文件并通过 superspec record job-submit 登记。` +
|
|
278
305
|
(requiresReviewer(job.role)
|
package/dist/review.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Event, Job, Ref } from "./types.ts";
|
|
2
|
+
export type ReviewRisk = "minimal" | "normal" | "strict";
|
|
3
|
+
export interface ReviewPolicy {
|
|
4
|
+
review_risk: ReviewRisk;
|
|
5
|
+
requires_verifier: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare const REVIEW_DOC_PATHS: string[];
|
|
8
|
+
export declare function assertCommitPayloadExtension(payload: Record<string, unknown>): void;
|
|
9
|
+
export declare function reviewPolicyForRisk(risk: ReviewRisk): ReviewPolicy;
|
|
10
|
+
export declare function readReviewPolicyFromEvents(events: Event[]): ReviewPolicy | null;
|
|
11
|
+
export declare function isReviewReadyVerifier(job: Job): boolean;
|
|
12
|
+
export declare function reviewBoundFiles(changeRoot: string): Ref[];
|
|
13
|
+
export declare function reviewEvidenceDigest(events: Event[]): string;
|
|
14
|
+
export declare function boundFilesStaleReason(job: Job, changeRoot: string): string | null;
|
|
15
|
+
export declare function reviewEvidenceStaleReason(job: Job, currentDigest: string): string | null;
|
|
16
|
+
export declare function reviewVerifierStaleReason(job: Job, changeRoot: string, currentEvidenceDigest: string): string | null;
|
|
17
|
+
export declare function isFreshReviewVerifier(job: Job, changeRoot: string, currentEvidenceDigest: string): boolean;
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SuperSpec review helpers: policy, verifier freshness, evidence digest
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sha256File, sha256Text } from "./store.js";
|
|
5
|
+
export const REVIEW_DOC_PATHS = [
|
|
6
|
+
"proposal.md",
|
|
7
|
+
"tasks.md",
|
|
8
|
+
"design.md",
|
|
9
|
+
".superspec/artifacts/discovery.md",
|
|
10
|
+
".superspec/artifacts/business-invariants.md",
|
|
11
|
+
".superspec/artifacts/test-contract.md",
|
|
12
|
+
];
|
|
13
|
+
const CORE_COMMIT_FIELDS = new Set([
|
|
14
|
+
"transition",
|
|
15
|
+
"from_state",
|
|
16
|
+
"to_state",
|
|
17
|
+
"outcome",
|
|
18
|
+
"created_job_ids",
|
|
19
|
+
"new_jobs",
|
|
20
|
+
"reason",
|
|
21
|
+
]);
|
|
22
|
+
export function assertCommitPayloadExtension(payload) {
|
|
23
|
+
for (const key of Object.keys(payload)) {
|
|
24
|
+
if (CORE_COMMIT_FIELDS.has(key)) {
|
|
25
|
+
throw new Error(`commitPayload 不能覆盖核心字段:${key}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function reviewPolicyForRisk(risk) {
|
|
30
|
+
return {
|
|
31
|
+
review_risk: risk,
|
|
32
|
+
requires_verifier: risk !== "minimal",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function isReviewPolicy(value) {
|
|
36
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
37
|
+
return false;
|
|
38
|
+
const obj = value;
|
|
39
|
+
return ((obj.review_risk === "minimal" || obj.review_risk === "normal" || obj.review_risk === "strict") &&
|
|
40
|
+
typeof obj.requires_verifier === "boolean");
|
|
41
|
+
}
|
|
42
|
+
export function readReviewPolicyFromEvents(events) {
|
|
43
|
+
for (const ev of events) {
|
|
44
|
+
if (ev.event_type !== "transition_commit")
|
|
45
|
+
continue;
|
|
46
|
+
const payload = ev.payload;
|
|
47
|
+
if (payload.transition !== "review-ready")
|
|
48
|
+
continue;
|
|
49
|
+
if (isReviewPolicy(payload.review_policy))
|
|
50
|
+
return payload.review_policy;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function isReviewReadyVerifier(job) {
|
|
55
|
+
return job.role === "verifier" && job.created_from_transition === "review-ready";
|
|
56
|
+
}
|
|
57
|
+
export function reviewBoundFiles(changeRoot) {
|
|
58
|
+
return REVIEW_DOC_PATHS
|
|
59
|
+
.filter(path => existsSync(join(changeRoot, path)))
|
|
60
|
+
.map(path => ({ path, sha: sha256File(join(changeRoot, path)) ?? "sha256:missing" }));
|
|
61
|
+
}
|
|
62
|
+
function evidenceSortKey(record) {
|
|
63
|
+
return [
|
|
64
|
+
record.kind,
|
|
65
|
+
record.task_id ?? "",
|
|
66
|
+
record.attempt_id ?? "",
|
|
67
|
+
record.task_structure_digest ?? "",
|
|
68
|
+
record.test_id ?? "",
|
|
69
|
+
record.semantic_status ?? "",
|
|
70
|
+
record.command ?? "",
|
|
71
|
+
record.cwd ?? "",
|
|
72
|
+
record.exit_code == null ? "" : String(record.exit_code),
|
|
73
|
+
record.target_fingerprint ?? "",
|
|
74
|
+
record.event_digest,
|
|
75
|
+
].join("\u0000");
|
|
76
|
+
}
|
|
77
|
+
export function reviewEvidenceDigest(events) {
|
|
78
|
+
const attemptsById = new Map();
|
|
79
|
+
const completed = [];
|
|
80
|
+
for (const ev of events) {
|
|
81
|
+
if (ev.event_type === "task_started") {
|
|
82
|
+
const attempt = ev.payload;
|
|
83
|
+
attemptsById.set(attempt.attempt_id, attempt);
|
|
84
|
+
}
|
|
85
|
+
else if (ev.event_type === "task_completed") {
|
|
86
|
+
const payload = ev.payload;
|
|
87
|
+
if (typeof payload.task_id !== "string" || typeof payload.attempt_id !== "string")
|
|
88
|
+
continue;
|
|
89
|
+
const attempt = attemptsById.get(payload.attempt_id);
|
|
90
|
+
completed.push({
|
|
91
|
+
task_id: payload.task_id,
|
|
92
|
+
attempt_id: payload.attempt_id,
|
|
93
|
+
task_structure_digest: attempt?.task_structure_digest ?? null,
|
|
94
|
+
event_digest: ev.event_digest,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const completedAttemptIds = new Set(completed.map(item => item.attempt_id));
|
|
99
|
+
const completedStructureDigests = new Set(completed
|
|
100
|
+
.map(item => item.task_structure_digest)
|
|
101
|
+
.filter((digest) => typeof digest === "string" && digest.length > 0));
|
|
102
|
+
const records = completed.map(item => ({
|
|
103
|
+
kind: "task_completed",
|
|
104
|
+
task_id: item.task_id,
|
|
105
|
+
attempt_id: item.attempt_id,
|
|
106
|
+
task_structure_digest: item.task_structure_digest,
|
|
107
|
+
event_digest: item.event_digest,
|
|
108
|
+
}));
|
|
109
|
+
for (const ev of events) {
|
|
110
|
+
if (ev.event_type !== "test_run_recorded")
|
|
111
|
+
continue;
|
|
112
|
+
const payload = ev.payload;
|
|
113
|
+
const attemptId = typeof payload.attempt_id === "string" ? payload.attempt_id : null;
|
|
114
|
+
const structureDigest = typeof payload.task_structure_digest === "string" ? payload.task_structure_digest : null;
|
|
115
|
+
const matchesCompletedAttempt = attemptId != null && completedAttemptIds.has(attemptId);
|
|
116
|
+
const matchesLegacyDigest = attemptId == null && structureDigest != null && completedStructureDigests.has(structureDigest);
|
|
117
|
+
if (!matchesCompletedAttempt && !matchesLegacyDigest)
|
|
118
|
+
continue;
|
|
119
|
+
records.push({
|
|
120
|
+
kind: "test_run_recorded",
|
|
121
|
+
test_id: typeof payload.test_id === "string" ? payload.test_id : null,
|
|
122
|
+
attempt_id: attemptId,
|
|
123
|
+
task_structure_digest: structureDigest,
|
|
124
|
+
semantic_status: typeof payload.semantic_status === "string" ? payload.semantic_status : null,
|
|
125
|
+
command: typeof payload.command === "string" ? payload.command : "",
|
|
126
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : "",
|
|
127
|
+
exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
|
|
128
|
+
target_fingerprint: typeof payload.target_fingerprint === "string" ? payload.target_fingerprint : null,
|
|
129
|
+
event_digest: ev.event_digest,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
records.sort((a, b) => evidenceSortKey(a).localeCompare(evidenceSortKey(b)));
|
|
133
|
+
return sha256Text(JSON.stringify(records));
|
|
134
|
+
}
|
|
135
|
+
export function boundFilesStaleReason(job, changeRoot) {
|
|
136
|
+
for (const bf of job.boundFiles) {
|
|
137
|
+
const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
|
|
138
|
+
if (current !== bf.sha) {
|
|
139
|
+
return `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
export function reviewEvidenceStaleReason(job, currentDigest) {
|
|
145
|
+
if (!isReviewReadyVerifier(job))
|
|
146
|
+
return null;
|
|
147
|
+
if (!job.review_evidence_digest)
|
|
148
|
+
return "最终验证工作项缺少执行证据版本";
|
|
149
|
+
if (job.review_evidence_digest !== currentDigest) {
|
|
150
|
+
return `最终验证执行证据版本已变化(${job.review_evidence_digest} → ${currentDigest})`;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
export function reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) {
|
|
155
|
+
return boundFilesStaleReason(job, changeRoot) ?? reviewEvidenceStaleReason(job, currentEvidenceDigest);
|
|
156
|
+
}
|
|
157
|
+
export function isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest) {
|
|
158
|
+
return isReviewReadyVerifier(job) && reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) == null;
|
|
159
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { Event, Snapshot, Ref } from "./types.ts";
|
|
2
|
+
export declare const RAW_RECORD_KINDS: readonly ["test-runs", "review-reports", "user-decisions"];
|
|
3
|
+
export type RawRecordKind = typeof RAW_RECORD_KINDS[number];
|
|
4
|
+
export interface RawRecordRef {
|
|
5
|
+
raw_kind: RawRecordKind;
|
|
6
|
+
raw_index: number;
|
|
7
|
+
raw_digest: string;
|
|
8
|
+
}
|
|
2
9
|
export declare function engineRoot(projectRoot: string): string;
|
|
3
10
|
export declare function changeDir(projectRoot: string, change: string): string;
|
|
4
11
|
export declare function eventsFile(projectRoot: string, change: string): string;
|
|
12
|
+
export declare function rawFile(projectRoot: string, change: string, kind: RawRecordKind): string;
|
|
5
13
|
export declare function snapshotFile(projectRoot: string, change: string): string;
|
|
6
14
|
export declare function lockFile(projectRoot: string, change: string): string;
|
|
7
15
|
export declare function stagingDir(projectRoot: string, change: string, transitionId: string): string;
|
|
@@ -21,6 +29,13 @@ export declare function makeEvent(change: string, eventType: Event["event_type"]
|
|
|
21
29
|
outputRefs?: Ref[];
|
|
22
30
|
}): Event;
|
|
23
31
|
export declare function eventsDigest(events: Event[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Append an accepted record input to the raw archive.
|
|
34
|
+
*
|
|
35
|
+
* Contract: call only while holding the per-change lock. The returned raw_index
|
|
36
|
+
* is the zero-based valid-record index, not necessarily the physical line.
|
|
37
|
+
*/
|
|
38
|
+
export declare function appendRawRecord(projectRoot: string, change: string, kind: RawRecordKind, record: unknown): RawRecordRef;
|
|
24
39
|
export declare function writeSnapshot(projectRoot: string, change: string, snapshot: Snapshot): void;
|
|
25
40
|
export declare function readSnapshot(projectRoot: string, change: string): Snapshot | null;
|
|
26
41
|
export declare function snapshotDigest(snapshot: Snapshot): string;
|
package/dist/store.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync
|
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { hostname } from "node:os";
|
|
6
6
|
// ===== 路径 =====
|
|
7
|
+
export const RAW_RECORD_KINDS = ["test-runs", "review-reports", "user-decisions"];
|
|
7
8
|
export function engineRoot(projectRoot) {
|
|
8
9
|
return join(projectRoot, ".superspec");
|
|
9
10
|
}
|
|
@@ -13,6 +14,9 @@ export function changeDir(projectRoot, change) {
|
|
|
13
14
|
export function eventsFile(projectRoot, change) {
|
|
14
15
|
return join(changeDir(projectRoot, change), "events.jsonl");
|
|
15
16
|
}
|
|
17
|
+
export function rawFile(projectRoot, change, kind) {
|
|
18
|
+
return join(changeDir(projectRoot, change), "raw", `${kind}.jsonl`);
|
|
19
|
+
}
|
|
16
20
|
export function snapshotFile(projectRoot, change) {
|
|
17
21
|
return join(changeDir(projectRoot, change), "snapshot.json");
|
|
18
22
|
}
|
|
@@ -109,6 +113,52 @@ export function makeEvent(change, eventType, payload, opts = {}) {
|
|
|
109
113
|
export function eventsDigest(events) {
|
|
110
114
|
return sha256Text(events.map(e => e.event_digest).join("\n"));
|
|
111
115
|
}
|
|
116
|
+
// ===== Raw 归档 =====
|
|
117
|
+
function countValidJsonlRecords(filePath) {
|
|
118
|
+
if (!existsSync(filePath))
|
|
119
|
+
return 0;
|
|
120
|
+
let count = 0;
|
|
121
|
+
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
122
|
+
if (!line.trim())
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
JSON.parse(line);
|
|
126
|
+
count++;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// raw JSONL 与 events.jsonl 一样容忍截断/损坏行;raw_index 按有效记录计数。
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return count;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Append an accepted record input to the raw archive.
|
|
136
|
+
*
|
|
137
|
+
* Contract: call only while holding the per-change lock. The returned raw_index
|
|
138
|
+
* is the zero-based valid-record index, not necessarily the physical line.
|
|
139
|
+
*/
|
|
140
|
+
export function appendRawRecord(projectRoot, change, kind, record) {
|
|
141
|
+
if (!RAW_RECORD_KINDS.includes(kind)) {
|
|
142
|
+
throw new Error(`Unsupported raw record kind: ${kind}`);
|
|
143
|
+
}
|
|
144
|
+
const rf = rawFile(projectRoot, change, kind);
|
|
145
|
+
mkdirSync(dirname(rf), { recursive: true });
|
|
146
|
+
const rawIndex = countValidJsonlRecords(rf);
|
|
147
|
+
const line = JSON.stringify(record);
|
|
148
|
+
const rawDigest = sha256Text(line);
|
|
149
|
+
const fd = openSync(rf, "a");
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(fd, line + "\n", "utf8");
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
raw_kind: kind,
|
|
158
|
+
raw_index: rawIndex,
|
|
159
|
+
raw_digest: rawDigest,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
112
162
|
// ===== 快照 =====
|
|
113
163
|
export function writeSnapshot(projectRoot, change, snapshot) {
|
|
114
164
|
writeFileSync(snapshotFile(projectRoot, change), JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
package/dist/sync.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — sync:从当前文档 + events.jsonl 重建 snapshot
|
|
2
2
|
import { readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { readEvents, eventsDigest, computeDocumentDigests,
|
|
4
|
+
import { readEvents, eventsDigest, computeDocumentDigests, sha256Text, ensureChangeLayout, } from "./store.js";
|
|
5
|
+
import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
|
|
5
6
|
const TRACKED_DOCS = [
|
|
6
7
|
"proposal.md", "design.md", "tasks.md",
|
|
7
8
|
".superspec/artifacts/discovery.md",
|
|
@@ -79,18 +80,12 @@ function replayEvents(events) {
|
|
|
79
80
|
return { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition };
|
|
80
81
|
}
|
|
81
82
|
/** 粗粒度失效:检查 job 的 boundFiles 是否仍匹配当前文档 */
|
|
82
|
-
function checkStaleJobs(jobs, changeRoot) {
|
|
83
|
+
function checkStaleJobs(jobs, changeRoot, currentReviewEvidenceDigest) {
|
|
83
84
|
const stale = [];
|
|
84
85
|
for (const job of jobs) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
stale.push({
|
|
89
|
-
job_id: job.job_id,
|
|
90
|
-
reason: `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`,
|
|
91
|
-
});
|
|
92
|
-
break; // 一个文件变就够了
|
|
93
|
-
}
|
|
86
|
+
const reason = reviewVerifierStaleReason(job, changeRoot, currentReviewEvidenceDigest);
|
|
87
|
+
if (reason) {
|
|
88
|
+
stale.push({ job_id: job.job_id, reason });
|
|
94
89
|
}
|
|
95
90
|
}
|
|
96
91
|
return stale;
|
|
@@ -113,9 +108,10 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
|
|
|
113
108
|
const documentDigests = computeDocumentDigests(changeRoot, TRACKED_DOCS);
|
|
114
109
|
const tsDigest = tasksStructureDigest(changeRoot);
|
|
115
110
|
const { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition } = replayEvents(events);
|
|
111
|
+
const currentReviewEvidenceDigest = reviewEvidenceDigest(events);
|
|
116
112
|
// 粗粒度失效检查(只读,不写事件):snapshot 只暴露当前可执行/可复用 job。
|
|
117
|
-
const staleOpenInfo = checkStaleJobs(openJobs, changeRoot);
|
|
118
|
-
const staleAcceptedInfo = checkStaleJobs(acceptedJobs, changeRoot);
|
|
113
|
+
const staleOpenInfo = checkStaleJobs(openJobs, changeRoot, currentReviewEvidenceDigest);
|
|
114
|
+
const staleAcceptedInfo = checkStaleJobs(acceptedJobs, changeRoot, currentReviewEvidenceDigest);
|
|
119
115
|
const freshOpen = openJobs.filter(j => !staleOpenInfo.some(s => s.job_id === j.job_id));
|
|
120
116
|
const freshAccepted = acceptedJobs.filter(j => !staleAcceptedInfo.some(s => s.job_id === j.job_id));
|
|
121
117
|
return {
|
package/dist/task.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — task:测试运行记录 + 任务结构指纹工具
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock } from "./store.js";
|
|
4
|
+
import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord } from "./store.js";
|
|
5
5
|
import { tasksStructureDigest as formatDigest } from "./format.js";
|
|
6
6
|
/** tasks.md 结构指纹(委托给 format.ts 统一实现) */
|
|
7
7
|
export function tasksStructureDigestOf(changeRoot) {
|
|
@@ -27,7 +27,7 @@ export function recordTestRun(projectRoot, change, inputFile) {
|
|
|
27
27
|
if (!tr.test_id || !tr.task_structure_digest) {
|
|
28
28
|
return { accepted: false, message: "缺少 test_id 或 task_structure_digest" };
|
|
29
29
|
}
|
|
30
|
-
const
|
|
30
|
+
const normalizedTestRun = {
|
|
31
31
|
test_id: tr.test_id,
|
|
32
32
|
task_structure_digest: tr.task_structure_digest,
|
|
33
33
|
attempt_id: tr.attempt_id ?? null,
|
|
@@ -37,6 +37,11 @@ export function recordTestRun(projectRoot, change, inputFile) {
|
|
|
37
37
|
semantic_status: tr.semantic_status ?? "unknown",
|
|
38
38
|
target_fingerprint: tr.target_fingerprint ?? null,
|
|
39
39
|
raw_log_ref: tr.raw_log_ref ?? null,
|
|
40
|
+
};
|
|
41
|
+
const rawRef = appendRawRecord(projectRoot, change, "test-runs", normalizedTestRun);
|
|
42
|
+
const event = makeEvent(change, "test_run_recorded", {
|
|
43
|
+
...normalizedTestRun,
|
|
44
|
+
...rawRef,
|
|
40
45
|
});
|
|
41
46
|
appendEvent(projectRoot, change, event);
|
|
42
47
|
return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
|
package/dist/transition.d.ts
CHANGED
package/dist/transition.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — transition:提交协议 + 所有 transition 处理器
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
4
|
-
import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey,
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, sha256File, sha256Text, } from "./store.js";
|
|
5
5
|
import { rebuildSnapshot } from "./sync.js";
|
|
6
|
+
import { assertCommitPayloadExtension, isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewBoundFiles, reviewEvidenceDigest, reviewPolicyForRisk, } from "./review.js";
|
|
6
7
|
import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, pendingTasksInContent, tasksStructureDigest } from "./format.js";
|
|
7
8
|
let transitionSeq = 0;
|
|
8
9
|
function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
|
|
@@ -85,6 +86,20 @@ function pendingTaskIds(changeRoot) {
|
|
|
85
86
|
function formatPendingTaskMessage(ids, action) {
|
|
86
87
|
return `尚有未完成任务:${ids.join(", ")};${action}`;
|
|
87
88
|
}
|
|
89
|
+
function hasRejectedReviewReadyVerifier(events) {
|
|
90
|
+
const reviewReadyVerifierIds = new Set();
|
|
91
|
+
for (const ev of events) {
|
|
92
|
+
if (ev.event_type !== "transition_commit")
|
|
93
|
+
continue;
|
|
94
|
+
const newJobs = ev.payload.new_jobs ?? [];
|
|
95
|
+
for (const job of newJobs) {
|
|
96
|
+
if (isReviewReadyVerifier(job))
|
|
97
|
+
reviewReadyVerifierIds.add(job.job_id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return events.some(ev => ev.event_type === "job_rejected" &&
|
|
101
|
+
reviewReadyVerifierIds.has(ev.payload.job_id ?? ""));
|
|
102
|
+
}
|
|
88
103
|
/**
|
|
89
104
|
* 统一 transition 提交协议——所有校验在锁内。
|
|
90
105
|
*/
|
|
@@ -113,7 +128,8 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
|
|
|
113
128
|
created_jobs: [], message: decision.message, events_written: 0,
|
|
114
129
|
};
|
|
115
130
|
}
|
|
116
|
-
const { fromState, toState, outcome, newJobs = [], reason, extraEvents = [], details } = decision;
|
|
131
|
+
const { fromState, toState, outcome, newJobs = [], reason, commitPayload = {}, extraEvents = [], details } = decision;
|
|
132
|
+
assertCommitPayloadExtension(commitPayload);
|
|
117
133
|
if (fromState !== snapshot.state) {
|
|
118
134
|
return {
|
|
119
135
|
transition: name, outcome: "advanced",
|
|
@@ -137,6 +153,7 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
|
|
|
137
153
|
appendEvent(projectRoot, change, makeEvent(change, "transition_commit", {
|
|
138
154
|
transition: name, from_state: fromState, to_state: toState,
|
|
139
155
|
outcome, created_job_ids: newJobs.map(j => j.job_id), new_jobs: newJobs, reason,
|
|
156
|
+
...commitPayload,
|
|
140
157
|
}, { transitionId, idempotencyKey: idemKey, prevSnapshotDigest: worldDigest }));
|
|
141
158
|
// extra events (task_started, task_completed, etc.)
|
|
142
159
|
for (const ex of extraEvents) {
|
|
@@ -311,35 +328,86 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
|
|
|
311
328
|
return commitTransition(projectRoot, change, changeRoot, {
|
|
312
329
|
name: "review-ready", idempotencyInputs: { phase: "review-ready", risk },
|
|
313
330
|
decide: (snapshot) => {
|
|
331
|
+
const events = readEvents(projectRoot, change);
|
|
332
|
+
const storedPolicy = readReviewPolicyFromEvents(events);
|
|
333
|
+
const policy = storedPolicy ?? reviewPolicyForRisk(risk);
|
|
334
|
+
const policyPayload = storedPolicy ? {} : { review_policy: policy };
|
|
335
|
+
const currentEvidenceDigest = reviewEvidenceDigest(events);
|
|
314
336
|
// 检查是否所有任务已完成
|
|
315
337
|
const pending = pendingTaskIds(changeRoot);
|
|
316
338
|
if (pending.length > 0)
|
|
317
339
|
return { skip: true, message: formatPendingTaskMessage(pending, "请先通过 next/reopen 继续执行") };
|
|
318
340
|
// 如果当前是 apply,先推进到 apply_done
|
|
319
341
|
if (snapshot.state === "apply") {
|
|
320
|
-
return {
|
|
342
|
+
return {
|
|
343
|
+
fromState: "apply", toState: "apply_done", outcome: "advanced",
|
|
344
|
+
reason: `所有任务完成;审查策略=${policy.review_risk}`,
|
|
345
|
+
commitPayload: policyPayload,
|
|
346
|
+
};
|
|
321
347
|
}
|
|
322
|
-
if (snapshot.state === "apply_done") {
|
|
323
|
-
const isReviewReadyVerifier = (job) => job.role === "verifier" && job.created_from_transition === "review-ready";
|
|
348
|
+
if (snapshot.state === "apply_done" || snapshot.state === "review") {
|
|
324
349
|
const verifierOpen = snapshot.open_jobs.find(isReviewReadyVerifier);
|
|
325
350
|
if (verifierOpen)
|
|
326
351
|
return { skip: true, message: `有待完成的最终验证工作项 ${verifierOpen.job_id}` };
|
|
327
|
-
const verifierAccepted = snapshot.accepted_jobs.find(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
352
|
+
const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
|
|
353
|
+
if (!policy.requires_verifier) {
|
|
354
|
+
if (snapshot.state === "apply_done") {
|
|
355
|
+
return {
|
|
356
|
+
fromState: "apply_done", toState: "review", outcome: "advanced",
|
|
357
|
+
reason: `审查策略=${policy.review_risk},无需最终验证,进入审查阶段`,
|
|
358
|
+
commitPayload: policyPayload,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (!storedPolicy) {
|
|
362
|
+
return {
|
|
363
|
+
fromState: "review", toState: "review", outcome: "advanced",
|
|
364
|
+
reason: `补写审查策略=${policy.review_risk}`,
|
|
365
|
+
commitPayload: policyPayload,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return { skip: true, message: "已在 review 状态,审查策略无需 verifier" };
|
|
369
|
+
}
|
|
370
|
+
if (!verifierAccepted) {
|
|
371
|
+
const boundFiles = reviewBoundFiles(changeRoot);
|
|
372
|
+
const previousVerifierRejected = hasRejectedReviewReadyVerifier(events);
|
|
332
373
|
const job = {
|
|
333
374
|
job_id: newJobId(change, "verifier"), role: "verifier", state: "requested",
|
|
334
|
-
boundFiles,
|
|
375
|
+
boundFiles, review_evidence_digest: currentEvidenceDigest,
|
|
376
|
+
packet_digest: sha256Text(JSON.stringify({
|
|
377
|
+
role: "verifier",
|
|
378
|
+
boundFiles,
|
|
379
|
+
review_evidence_digest: currentEvidenceDigest,
|
|
380
|
+
created_from_transition: "review-ready",
|
|
381
|
+
})),
|
|
335
382
|
created_from_transition: "review-ready", created_at: new Date().toISOString(),
|
|
336
383
|
};
|
|
337
384
|
return {
|
|
338
|
-
fromState:
|
|
339
|
-
newJobs: [job],
|
|
385
|
+
fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
|
|
386
|
+
newJobs: [job],
|
|
387
|
+
reason: previousVerifierRejected
|
|
388
|
+
? "此前 verifier 未通过;请先根据 findings 修改任务或文档,确认无需修改时再执行新的最终验证工作项"
|
|
389
|
+
: "创建最终验证工作项",
|
|
390
|
+
commitPayload: policyPayload,
|
|
391
|
+
...(previousVerifierRejected ? {
|
|
392
|
+
details: { advisory: "此前 verifier 未通过;请先根据 findings 修改任务或文档,确认无需修改时再执行新的最终验证工作项" },
|
|
393
|
+
} : {}),
|
|
340
394
|
};
|
|
341
395
|
}
|
|
342
|
-
|
|
396
|
+
if (snapshot.state === "apply_done") {
|
|
397
|
+
return {
|
|
398
|
+
fromState: "apply_done", toState: "review", outcome: "advanced",
|
|
399
|
+
reason: "最终验证已接受,进入审查阶段",
|
|
400
|
+
commitPayload: policyPayload,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (!storedPolicy) {
|
|
404
|
+
return {
|
|
405
|
+
fromState: "review", toState: "review", outcome: "advanced",
|
|
406
|
+
reason: `补写审查策略=${policy.review_risk}`,
|
|
407
|
+
commitPayload: policyPayload,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
|
|
343
411
|
}
|
|
344
412
|
return { skip: true, message: `当前状态 ${snapshot.state},review-ready 不适用` };
|
|
345
413
|
},
|
|
@@ -355,6 +423,16 @@ export function accept(projectRoot, change, changeRoot) {
|
|
|
355
423
|
const pending = pendingTaskIds(changeRoot);
|
|
356
424
|
if (pending.length > 0)
|
|
357
425
|
return { skip: true, message: formatPendingTaskMessage(pending, "请先 reopen --to apply 继续执行") };
|
|
426
|
+
const events = readEvents(projectRoot, change);
|
|
427
|
+
const policy = readReviewPolicyFromEvents(events);
|
|
428
|
+
if (!policy)
|
|
429
|
+
return { skip: true, message: "缺少审查策略,请先运行 review-ready" };
|
|
430
|
+
if (policy.requires_verifier) {
|
|
431
|
+
const currentEvidenceDigest = reviewEvidenceDigest(events);
|
|
432
|
+
const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
|
|
433
|
+
if (!verifierAccepted)
|
|
434
|
+
return { skip: true, message: "缺少 fresh verifier,请先运行 review-ready" };
|
|
435
|
+
}
|
|
358
436
|
return { fromState: "review", toState: "accepted", outcome: "advanced", reason: "审查通过" };
|
|
359
437
|
},
|
|
360
438
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface Job {
|
|
|
11
11
|
role: JobRole;
|
|
12
12
|
state: JobState;
|
|
13
13
|
boundFiles: Ref[];
|
|
14
|
+
review_evidence_digest?: string;
|
|
14
15
|
packet_digest: string;
|
|
15
16
|
created_from_transition: string;
|
|
16
17
|
created_at: string;
|
|
@@ -20,6 +21,7 @@ export interface JobPacket {
|
|
|
20
21
|
role: JobRole;
|
|
21
22
|
recommended_agent?: string;
|
|
22
23
|
boundFiles: Ref[];
|
|
24
|
+
review_evidence_digest?: string;
|
|
23
25
|
packet_digest: string;
|
|
24
26
|
required_output_kind: string;
|
|
25
27
|
output_contract_fields?: string[];
|
|
@@ -48,7 +50,12 @@ export interface TransitionCommitPayload {
|
|
|
48
50
|
to_state: State;
|
|
49
51
|
outcome: "advanced" | "job_created";
|
|
50
52
|
created_job_ids: string[];
|
|
53
|
+
new_jobs?: Job[];
|
|
51
54
|
reason: string;
|
|
55
|
+
review_policy?: {
|
|
56
|
+
review_risk: "minimal" | "normal" | "strict";
|
|
57
|
+
requires_verifier: boolean;
|
|
58
|
+
};
|
|
52
59
|
}
|
|
53
60
|
export interface Snapshot {
|
|
54
61
|
change_id: string;
|
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peterxiaoyang/superspec",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"engines": {
|
|
7
|
-
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.19.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"superspec": "dist/cli.js"
|
|
11
|
+
},
|
|
8
12
|
"exports": {
|
|
9
|
-
".": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/types.d.ts",
|
|
15
|
+
"default": "./dist/cli.js"
|
|
16
|
+
}
|
|
10
17
|
},
|
|
11
|
-
"files": [
|
|
18
|
+
"files": [
|
|
19
|
+
"README.md",
|
|
20
|
+
"dist",
|
|
21
|
+
"templates"
|
|
22
|
+
],
|
|
12
23
|
"scripts": {
|
|
13
24
|
"build": "node build.js",
|
|
14
25
|
"typecheck": "tsc --noEmit",
|
|
@@ -21,12 +21,23 @@ metadata:
|
|
|
21
21
|
|
|
22
22
|
next 返回需要 verifier 工作项时,先按返回的验证说明执行核对,再用 `superspec record job-submit --change "<change>" --job <JOB> --report <FILE>` 提交验证报告。
|
|
23
23
|
|
|
24
|
+
`record job-submit` 沿用现有 raw 归档:报告追加到 `raw/review-reports.jsonl`,不会为 review gate 新增 raw 文件类型。
|
|
25
|
+
|
|
24
26
|
## 本阶段做什么
|
|
25
27
|
|
|
26
28
|
1. **确认所有任务完成**:review-ready 会检查 tasks.md 无未完成项
|
|
27
29
|
2. **处理 verifier**:核对 proposal + 实现 + 测试契约一致性
|
|
28
30
|
3. **accept**:`superspec transition accept --change "<change>"`
|
|
29
31
|
|
|
32
|
+
## verifier gate 规则
|
|
33
|
+
|
|
34
|
+
- `review-ready` 首次运行会持久化审查策略;后续 risk 参数不会覆盖首次策略。
|
|
35
|
+
- `minimal` 不要求 verifier;`normal` 和 `strict` 要求 verifier。
|
|
36
|
+
- 当前 MVP 中 `strict` 等同 `normal`,不启用额外检查。
|
|
37
|
+
- 如果 review 状态缺少审查策略,先运行 `review-ready` 补策略,不直接 accept。
|
|
38
|
+
- verifier 工作项 packet 会包含绑定文档和 `review_evidence_digest`,用于确认审查对应的任务完成与 RED/GREEN 证据版本。
|
|
39
|
+
- verifier 通过后,如果绑定文档或已登记执行证据版本变化,需要重新运行 `review-ready` 创建新的 verifier。
|
|
40
|
+
|
|
30
41
|
## Guardrails
|
|
31
42
|
|
|
32
43
|
- 不改业务代码(审查阶段只读)
|