spec-ctx 0.3.0 → 0.4.1
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 +119 -16
- package/action.yml +8 -0
- package/dist/adopt/write.js +9 -4
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +23 -0
- package/dist/check/hints.js +16 -11
- package/dist/cli/args.js +5 -4
- package/dist/cli/commands.js +89 -29
- package/dist/cli/config.js +14 -0
- package/dist/cli/gate.js +133 -4
- package/dist/cli/help.js +25 -7
- package/dist/cli/workflow.js +28 -11
- package/dist/config.js +9 -1
- package/dist/gate/rerun.js +169 -0
- package/dist/graph/bundle.js +145 -26
- package/dist/graph/files.js +31 -0
- package/dist/graph/input.js +37 -0
- package/dist/graph/links.js +96 -0
- package/dist/graph/path.js +54 -0
- package/dist/graph/query.js +3 -0
- package/dist/graph/related.js +58 -11
- package/dist/handoff/handoff.js +11 -7
- package/dist/handoff/questions.js +9 -1
- package/dist/ids/ids.js +4 -0
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +42 -11
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +28 -12
- package/dist/loop/fails.js +15 -8
- package/dist/loop/next.js +89 -37
- package/dist/runcmd.js +5 -0
- package/dist/util/time.js +1 -0
- package/dist/verify/run.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +9 -4
package/dist/cli/commands.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { UsageExit } from "./errors.js";
|
|
4
4
|
import { findRoot, requireOpenSpec } from "./root.js";
|
|
5
|
+
import { defaults } from "../config.js";
|
|
5
6
|
import { hintOf } from "../check/hints.js";
|
|
6
7
|
import { paint } from "./color.js";
|
|
8
|
+
import { runCommand } from "../runcmd.js";
|
|
7
9
|
/** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
|
|
8
10
|
async function runCheck(root, cfg, data, strict) {
|
|
9
11
|
const { check } = await import("../check/check.js");
|
|
@@ -26,7 +28,7 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
26
28
|
* REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
|
|
27
29
|
* A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
|
|
28
30
|
*/
|
|
29
|
-
export function checkText(r, tty) {
|
|
31
|
+
export function checkText(r, tty, run = "specctx") {
|
|
30
32
|
// REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
|
|
31
33
|
// REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
|
|
32
34
|
const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
|
|
@@ -62,7 +64,7 @@ export function checkText(r, tty) {
|
|
|
62
64
|
const rules = [...count.keys()].sort();
|
|
63
65
|
return body
|
|
64
66
|
+ "\n" + paint("dim", `${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`, tty)
|
|
65
|
-
+ rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule)}\n`).join("")
|
|
67
|
+
+ rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule, run)}\n`).join("")
|
|
66
68
|
+ base;
|
|
67
69
|
}
|
|
68
70
|
export async function run(p, io) {
|
|
@@ -73,16 +75,8 @@ export async function run(p, io) {
|
|
|
73
75
|
return init(p, io);
|
|
74
76
|
const root = findRoot(io.cwd, f.root);
|
|
75
77
|
requireOpenSpec(root);
|
|
76
|
-
const {
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
cfg = loadConfig(root);
|
|
80
|
-
}
|
|
81
|
-
catch (e) {
|
|
82
|
-
if (e instanceof ConfigError)
|
|
83
|
-
throw new UsageExit(e.message);
|
|
84
|
-
throw e;
|
|
85
|
-
}
|
|
78
|
+
const { loadConfigOrExit } = await import("./config.js");
|
|
79
|
+
const cfg = loadConfigOrExit(root);
|
|
86
80
|
const { dumps } = await import("../util/json.js");
|
|
87
81
|
const print = (v) => io.out(dumps(v) + "\n");
|
|
88
82
|
if (cmd === "build") {
|
|
@@ -111,7 +105,7 @@ export async function run(p, io) {
|
|
|
111
105
|
io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
|
|
112
106
|
// REQ-CLI-007 the dry run ends with the next step. REQ-ADOPT-001 the exit code stays 1 and the last line says why (017/D5).
|
|
113
107
|
else
|
|
114
|
-
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next:
|
|
108
|
+
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: ${runCommand(cfg.cli.run, "adopt")}\n`);
|
|
115
109
|
return plan.edits.length ? 1 : 0;
|
|
116
110
|
}
|
|
117
111
|
if (cmd === "baseline write" || cmd === "baseline prune") {
|
|
@@ -147,6 +141,13 @@ export async function run(p, io) {
|
|
|
147
141
|
if (cmd === "gate") {
|
|
148
142
|
if (p.positional.length)
|
|
149
143
|
throw new UsageExit("gate takes no arguments");
|
|
144
|
+
// REQ-CLI-009 the rerun mode runs only the rerun step
|
|
145
|
+
if (f.base !== undefined && !f.rerun)
|
|
146
|
+
throw new UsageExit("--base needs --rerun");
|
|
147
|
+
if (f.rerun) {
|
|
148
|
+
const { runRerun } = await import("./gate.js");
|
|
149
|
+
return runRerun(root, cfg, { strict: !!f.strict, base: f.base || cfg.gate.base, json }, io);
|
|
150
|
+
}
|
|
150
151
|
const { runGate } = await import("./gate.js");
|
|
151
152
|
const strict = !!f.strict;
|
|
152
153
|
return runGate(root, {
|
|
@@ -164,7 +165,7 @@ export async function run(p, io) {
|
|
|
164
165
|
void check;
|
|
165
166
|
const data = buildIndex(root, cfg);
|
|
166
167
|
const r = await runCheck(root, cfg, data, strict);
|
|
167
|
-
return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty) };
|
|
168
|
+
return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty, cfg.cli.run) };
|
|
168
169
|
},
|
|
169
170
|
}, io);
|
|
170
171
|
}
|
|
@@ -177,12 +178,17 @@ export async function run(p, io) {
|
|
|
177
178
|
if (cmd === "query" && !f.change)
|
|
178
179
|
need(1, "at least one term");
|
|
179
180
|
if (cmd === "related") {
|
|
180
|
-
need(1, "one ID");
|
|
181
|
+
need(1, "one ID, path, or change");
|
|
181
182
|
if (p.positional.length > 1)
|
|
182
|
-
throw new UsageExit("related takes one ID");
|
|
183
|
+
throw new UsageExit("related takes one ID, path, or change");
|
|
183
184
|
}
|
|
184
185
|
if (cmd === "bundle")
|
|
185
|
-
need(1, "at least one ID");
|
|
186
|
+
need(1, "at least one ID, path, or change");
|
|
187
|
+
if (cmd === "context") {
|
|
188
|
+
need(1, "one task, ID, path, or change");
|
|
189
|
+
if (p.positional.length > 1)
|
|
190
|
+
throw new UsageExit("context takes one task, ID, path, or change");
|
|
191
|
+
}
|
|
186
192
|
if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
|
|
187
193
|
throw new UsageExit(`${cmd} takes no arguments`);
|
|
188
194
|
const { data, rebuilt } = loadIndex(root, cfg);
|
|
@@ -232,15 +238,35 @@ export async function run(p, io) {
|
|
|
232
238
|
}
|
|
233
239
|
case "related": {
|
|
234
240
|
const { canon } = await import("../index/canonical.js");
|
|
235
|
-
const { related } = await import("../graph/related.js");
|
|
236
|
-
const id = p.positional[0];
|
|
241
|
+
const { related, mainVersion } = await import("../graph/related.js");
|
|
237
242
|
const ids = canon(entries);
|
|
243
|
+
let id = p.positional[0];
|
|
244
|
+
let isPath = false;
|
|
238
245
|
if (!ids.has(id) && !(all && entries.some((e) => e.id === id))) {
|
|
239
|
-
|
|
240
|
-
|
|
246
|
+
// REQ-QRY-011 a change key or folder name is a change. REQ-QRY-005 any other input is a path in the repository.
|
|
247
|
+
const { resolveFileInput, changeInput } = await import("../graph/path.js");
|
|
248
|
+
const ch = changeInput(entries, id);
|
|
249
|
+
if (ch)
|
|
250
|
+
id = ch;
|
|
251
|
+
else {
|
|
252
|
+
const r = resolveFileInput(root, io.cwd, id);
|
|
253
|
+
if ("error" in r) {
|
|
254
|
+
io.err(`${r.error}\n`);
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
id = r.rel;
|
|
258
|
+
isPath = true;
|
|
259
|
+
}
|
|
241
260
|
}
|
|
242
261
|
const rows = [];
|
|
243
262
|
for (const [i, reason] of related(entries, id, f.depth ?? 1, all)) {
|
|
263
|
+
// REQ-QRY-010 the pair of a main version stands for the non-canonical copy in the main spec
|
|
264
|
+
if (!all && reason === `main version of ${i}`) {
|
|
265
|
+
const m = mainVersion(entries, i);
|
|
266
|
+
if (m)
|
|
267
|
+
rows.push([m, reason]);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
244
270
|
const copies = all ? entries.filter((e) => e.id === i) : ids.has(i) ? [ids.get(i)] : [];
|
|
245
271
|
// canonical copy first, the rest in index order
|
|
246
272
|
copies.sort((a, b) => Number(!a.canonical) - Number(!b.canonical));
|
|
@@ -249,22 +275,46 @@ export async function run(p, io) {
|
|
|
249
275
|
}
|
|
250
276
|
if (json)
|
|
251
277
|
print(rows.map(([e, reason]) => ({ ...e, reason })));
|
|
278
|
+
else if (isPath && rows.length === 0)
|
|
279
|
+
io.out(`no links: ${id}\n`);
|
|
252
280
|
else
|
|
253
281
|
io.out(rows.map(([e, r]) => `${e.kind} ${e.id} ${e.status} ${e.file}:${e.line} ${r}\n`).join(""));
|
|
254
282
|
return 0;
|
|
255
283
|
}
|
|
284
|
+
case "context":
|
|
256
285
|
case "bundle": {
|
|
257
286
|
const { canon } = await import("../index/canonical.js");
|
|
258
287
|
const { bundle } = await import("../graph/bundle.js");
|
|
288
|
+
const { resolveInputs } = await import("../graph/input.js");
|
|
259
289
|
const ids = canon(entries);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
290
|
+
// REQ-QRY-005 an input that is no known ID is a path in the repository
|
|
291
|
+
const { wanted, paths, errors, taskId } = resolveInputs(root, io.cwd, entries, p.positional);
|
|
292
|
+
if (errors.length) {
|
|
293
|
+
io.err(errors.map((l) => l + "\n").join(""));
|
|
263
294
|
return 2;
|
|
264
295
|
}
|
|
265
|
-
|
|
296
|
+
// REQ-QRY-004 --block prints the requirement blocks verbatim and nothing else (034/D2)
|
|
297
|
+
if (f.block) {
|
|
298
|
+
const notReq = p.positional.filter((i) => ids.get(i)?.kind !== "req");
|
|
299
|
+
if (notReq.length) {
|
|
300
|
+
io.err(`not a requirement: ${notReq.join(", ")}\n`);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
const { reqBlocks } = await import("../graph/bundle.js");
|
|
304
|
+
const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
|
|
305
|
+
if (json)
|
|
306
|
+
print({ text, omitted: [] });
|
|
307
|
+
else
|
|
308
|
+
io.out(text);
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
// 047/D5 a task input passes the entry that findTask picks, so an active task wins over an archived one
|
|
312
|
+
const task = taskId === undefined ? undefined : (await import("../tasks/tasks.js")).findTask(root, entries, taskId).entry;
|
|
313
|
+
const b = bundle(root, entries, wanted, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, { all, task });
|
|
266
314
|
if (json)
|
|
267
315
|
print(b);
|
|
316
|
+
else if (b.text === "" && wanted.every((w) => paths.has(w)))
|
|
317
|
+
io.out(wanted.map((w) => `no links: ${w}\n`).join(""));
|
|
268
318
|
else
|
|
269
319
|
io.out(b.text);
|
|
270
320
|
return 0;
|
|
@@ -296,7 +346,7 @@ export async function run(p, io) {
|
|
|
296
346
|
if (json)
|
|
297
347
|
print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
|
|
298
348
|
else
|
|
299
|
-
io.out(checkText(r, io.tty));
|
|
349
|
+
io.out(checkText(r, io.tty, cfg.cli.run));
|
|
300
350
|
return r.findings.length ? 1 : 0;
|
|
301
351
|
}
|
|
302
352
|
}
|
|
@@ -309,18 +359,28 @@ async function init(p, io) {
|
|
|
309
359
|
const root = resolve(io.cwd, p.flags.root ?? ".");
|
|
310
360
|
const all = !!p.flags.all;
|
|
311
361
|
const r = runInit(root, { schema: all || !!p.flags.schema, skills: all || !!p.flags.skills, force: !!p.flags.force });
|
|
362
|
+
// REQ-CLI-010 init prints its next step with the run prefix of the config it wrote or kept (043/D1)
|
|
363
|
+
const { loadConfigOrExit } = await import("./config.js");
|
|
364
|
+
const run = (() => {
|
|
365
|
+
try {
|
|
366
|
+
return loadConfigOrExit(root).cli.run;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return defaults().cli.run;
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
312
372
|
if (p.flags.json)
|
|
313
373
|
io.out(JSON.stringify(r, null, 2) + "\n");
|
|
314
374
|
else if (r.nothing)
|
|
315
|
-
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim",
|
|
375
|
+
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", `next: ${runCommand(run, "status")}\n`, io.tty));
|
|
316
376
|
else {
|
|
317
377
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
318
378
|
io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
|
|
319
379
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
320
380
|
io.out(r.notes.map((n) => `${n}\n`).join(""));
|
|
321
381
|
// REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
|
|
322
|
-
const again = ["
|
|
323
|
-
io.out(paint("dim", `next: ${r.changed.length ? again : "
|
|
382
|
+
const again = [runCommand(run, "init"), all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
|
|
383
|
+
io.out(paint("dim", `next: ${r.changed.length ? again : runCommand(run, "status")}\n`, io.tty));
|
|
324
384
|
}
|
|
325
385
|
return 0;
|
|
326
386
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// REQ-DIST-009 the one function that every command uses to read spec-ctx.toml (033/D4).
|
|
2
|
+
import { ConfigError, loadConfig } from "../config.js";
|
|
3
|
+
import { UsageExit } from "./errors.js";
|
|
4
|
+
/** Read the config. A config error becomes a usage error: one line on stderr and exit 2, never a stack. */
|
|
5
|
+
export function loadConfigOrExit(root) {
|
|
6
|
+
try {
|
|
7
|
+
return loadConfig(root);
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
if (e instanceof ConfigError)
|
|
11
|
+
throw new UsageExit(e.message);
|
|
12
|
+
throw e;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/cli/gate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// REQ-CLI-004 specctx gate: check, the baseline against the merge base, both linters, openspec validate, spec length. Stops at the first failure.
|
|
1
|
+
// REQ-LOOP-011 REQ-STAT-005 REQ-CLI-009 REQ-CLI-004 specctx gate: check, the baseline against the merge base, both linters, openspec validate, spec length. Stops at the first failure.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
5
|
import { anyMatch, globRe } from "../util/glob.js";
|
|
6
6
|
import { cmpStr } from "../util/pystr.js";
|
|
7
7
|
const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
|
|
@@ -45,7 +45,7 @@ export const GATE_STEPS = ["check", "baseline", "lint tells", "lint ste", "valid
|
|
|
45
45
|
/** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
|
|
46
46
|
export function appendGateLog(root, line) {
|
|
47
47
|
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
48
|
-
const sorted = { at: line.at, duration_s: line.duration_s, failed_step: line.failed_step, ok: line.ok, steps: line.steps };
|
|
48
|
+
const sorted = { at: line.at, duration_s: line.duration_s, failed_step: line.failed_step, ok: line.ok, steps: line.steps, strict: line.strict };
|
|
49
49
|
appendFileSync(join(root, GATE_LOG), JSON.stringify(sorted) + "\n");
|
|
50
50
|
}
|
|
51
51
|
export async function runGate(root, o, io) {
|
|
@@ -53,7 +53,7 @@ export async function runGate(root, o, io) {
|
|
|
53
53
|
const ran = [];
|
|
54
54
|
const r = await gateSteps(root, o, io, ran);
|
|
55
55
|
const { isoNow } = await import("../util/time.js");
|
|
56
|
-
appendGateLog(root, { at: isoNow(), ok: r === null, failed_step: r, duration_s: Math.round((Date.now() - start) / 1000), steps: [...ran] });
|
|
56
|
+
appendGateLog(root, { at: isoNow(), ok: r === null, failed_step: r, duration_s: Math.round((Date.now() - start) / 1000), steps: [...ran], strict: o.strict });
|
|
57
57
|
return r === null ? 0 : 1;
|
|
58
58
|
}
|
|
59
59
|
/** The gate steps. Returns null when every step passed, else the name of the failed step. */
|
|
@@ -203,3 +203,132 @@ export function longRequirements(root) {
|
|
|
203
203
|
}
|
|
204
204
|
return out;
|
|
205
205
|
}
|
|
206
|
+
/** REQ-CLI-009 the recursion guard: a rerun that a rerun started stops at once. */
|
|
207
|
+
export const RERUN_GUARD = "SPECCTX_RERUN";
|
|
208
|
+
/**
|
|
209
|
+
* REQ-CLI-009 REQ-VER-005 REQ-VER-006 REQ-VER-007 `gate --rerun`. It runs the Verify command of each task that the range
|
|
210
|
+
* closed, one group at a time, and compares the exit code with the evidence. It writes no evidence.
|
|
211
|
+
*/
|
|
212
|
+
export async function runRerun(root, cfg, o, io) {
|
|
213
|
+
if (process.env[RERUN_GUARD]) {
|
|
214
|
+
io.out(o.json ? JSON.stringify({ ok: true, skipped: `${RERUN_GUARD} is set` }) + "\n" : `rerun: skipped, ${RERUN_GUARD} is set\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
const { dumps } = await import("../util/json.js");
|
|
219
|
+
const { loadIndex } = await import("../index/build.js");
|
|
220
|
+
const { scanner } = await import("../index/scan.js");
|
|
221
|
+
const { closedTasks, compare, groupRuns, judge, formatMismatch } = await import("../gate/rerun.js");
|
|
222
|
+
const { isWeakVerify, parseVerify } = await import("../verify/parse.js");
|
|
223
|
+
const { evidenceRel } = await import("../tasks/tasks.js");
|
|
224
|
+
const { runVerify } = await import("../verify/run.js");
|
|
225
|
+
const { isoNow } = await import("../util/time.js");
|
|
226
|
+
const finish = (ok) => {
|
|
227
|
+
appendGateLog(root, { at: isoNow(), ok, failed_step: ok ? null : "rerun", duration_s: Math.round((Date.now() - start) / 1000), steps: ["rerun"], strict: o.strict });
|
|
228
|
+
return ok ? 0 : 1;
|
|
229
|
+
};
|
|
230
|
+
// REQ-VER-013 a zero base passes and a base that this clone lacks fails, before any merge base (042/D3)
|
|
231
|
+
const { baseProblem } = await import("../gate/rerun.js");
|
|
232
|
+
const bp = baseProblem(root, o.base);
|
|
233
|
+
if (bp) {
|
|
234
|
+
io.out(o.json ? dumps({ base: o.base, ...(bp.ok ? { warning: bp.line } : { error: bp.line }), merge_base: null, ok: bp.ok, tasks: [] }) + "\n" : bp.line + "\n");
|
|
235
|
+
return finish(bp.ok);
|
|
236
|
+
}
|
|
237
|
+
const { data } = loadIndex(root, cfg);
|
|
238
|
+
const closed = closedTasks(root, o.base, data.entries, scanner(root, cfg).taskRe);
|
|
239
|
+
if (!closed.ok) {
|
|
240
|
+
io.out(o.json ? dumps({ base: o.base, error: closed.line, merge_base: null, ok: false, tasks: [] }) + "\n" : closed.line + "\n");
|
|
241
|
+
return finish(false);
|
|
242
|
+
}
|
|
243
|
+
// REQ-VER-010 a merge base at HEAD compares nothing. Say so and pass (041/D4).
|
|
244
|
+
const { headSha, HEAD_BASE_LINE } = await import("../gate/rerun.js");
|
|
245
|
+
if (closed.mergeBase === headSha(root)) {
|
|
246
|
+
io.out(o.json ? dumps({ base: o.base, merge_base: closed.mergeBase, ok: true, tasks: [], warning: HEAD_BASE_LINE }) + "\n" : HEAD_BASE_LINE + "\n");
|
|
247
|
+
return finish(true);
|
|
248
|
+
}
|
|
249
|
+
const lines = [];
|
|
250
|
+
const pending = [];
|
|
251
|
+
for (const e of closed.tasks) {
|
|
252
|
+
const evPath = evidenceRel(e);
|
|
253
|
+
let ev = null;
|
|
254
|
+
try {
|
|
255
|
+
const j = JSON.parse(readFileSync(join(root, evPath), "utf8"));
|
|
256
|
+
ev = { kind: j.kind, command: j.verify?.command ?? null, exit: j.verify?.exit ?? null, ...(j.cwd !== undefined ? { cwd: j.cwd } : {}) };
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
ev = null;
|
|
260
|
+
}
|
|
261
|
+
const tl = readFileSync(join(root, e.file), "utf8").split(/\r?\n/)[e.line - 1] ?? "";
|
|
262
|
+
const spec = parseVerify(tl);
|
|
263
|
+
const v = compare({ command: spec.command, ...(spec.cwd !== undefined ? { cwd: spec.cwd } : {}) }, ev, o.strict, isWeakVerify);
|
|
264
|
+
const base = { id: e.id, status: "", kind: "ok", file: e.file, line: e.line, evidence: evPath };
|
|
265
|
+
if (v.act === "warn")
|
|
266
|
+
lines.push({ ...base, status: "warn manual", kind: "warn" });
|
|
267
|
+
else if (v.act === "fail")
|
|
268
|
+
lines.push({ ...base, status: v.kind, kind: "fail", ...(v.detail ? { detail: v.detail } : {}) });
|
|
269
|
+
else {
|
|
270
|
+
const l = { ...base, status: "ok" };
|
|
271
|
+
lines.push(l);
|
|
272
|
+
pending.push({ id: e.id, command: v.command, cwd: v.cwd, expected: v.expected, line: l });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// REQ-VER-007 one run per frozen command and cwd. Groups run one at a time.
|
|
276
|
+
const env = { ...process.env, CI: "1", [RERUN_GUARD]: "1" };
|
|
277
|
+
for (const g of groupRuns(pending)) {
|
|
278
|
+
const dir = resolve(root, g.cwd);
|
|
279
|
+
let out;
|
|
280
|
+
if (dir !== resolve(root) && !dir.startsWith(resolve(root) + sep))
|
|
281
|
+
out = { exit: null, timedOut: false, output: `cwd ${g.cwd} is outside the project root\n` };
|
|
282
|
+
// REQ-VER-009 a missing cwd and a spawn error are a mismatch of the group, not a crash (041/D3)
|
|
283
|
+
else if (!existsSync(dir))
|
|
284
|
+
out = { exit: null, timedOut: false, output: `cwd does not exist: ${g.cwd}\n` };
|
|
285
|
+
else {
|
|
286
|
+
try {
|
|
287
|
+
const r = await runVerify(g.command, dir, cfg.verify.timeout_s, env);
|
|
288
|
+
out = { exit: r.exit, timedOut: r.timedOut, output: r.output };
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
out = { exit: null, timedOut: false, output: `spawn failed: ${e instanceof Error ? e.message : String(e)}\n` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const m of g.members) {
|
|
295
|
+
if (judge(m.expected, out) === "ok")
|
|
296
|
+
continue;
|
|
297
|
+
m.line.status = "mismatch";
|
|
298
|
+
m.line.kind = "fail";
|
|
299
|
+
m.line.expected_exit = m.expected;
|
|
300
|
+
m.line.actual_exit = out.exit;
|
|
301
|
+
m.line.timed_out = out.timedOut;
|
|
302
|
+
if (out.exit === null && !out.timedOut)
|
|
303
|
+
m.line.detail = out.output.trim().split("\n")[0] ?? "";
|
|
304
|
+
m.line.report = formatMismatch({ id: m.id, file: m.line.file, line: m.line.line, evidence: m.line.evidence, expected: m.expected, outcome: out, command: g.command, cwd: g.cwd, timeoutS: cfg.verify.timeout_s });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const failed = lines.filter((l) => l.kind === "fail");
|
|
308
|
+
const ok = failed.length === 0;
|
|
309
|
+
if (o.json) {
|
|
310
|
+
io.out(dumps({
|
|
311
|
+
base: o.base,
|
|
312
|
+
merge_base: closed.mergeBase,
|
|
313
|
+
ok,
|
|
314
|
+
tasks: lines.map((l) => {
|
|
315
|
+
const { report, ...rest } = l;
|
|
316
|
+
void report;
|
|
317
|
+
return rest;
|
|
318
|
+
}),
|
|
319
|
+
}) + "\n");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
if (!lines.length)
|
|
323
|
+
io.out("rerun: no closed tasks\n");
|
|
324
|
+
for (const l of lines) {
|
|
325
|
+
if (l.report)
|
|
326
|
+
io.out(l.report);
|
|
327
|
+
else
|
|
328
|
+
io.out(`${l.id}: ${l.status}${l.detail ? ` (${l.detail})` : ""}\n`);
|
|
329
|
+
}
|
|
330
|
+
if (lines.length)
|
|
331
|
+
io.out(`rerun: ${lines.length} task(s), ${failed.length} failure(s)\n`);
|
|
332
|
+
}
|
|
333
|
+
return finish(ok);
|
|
334
|
+
}
|
package/dist/cli/help.js
CHANGED
|
@@ -38,18 +38,27 @@ export const TABLE = [
|
|
|
38
38
|
exit: PLAIN, example: "specctx query lockfile --kind req",
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
|
-
name: "
|
|
41
|
+
name: "context", group: "Read", args: "<task|ID|path|change>", summary: "Print what an agent needs before it edits a task, an ID, a file, or a change.",
|
|
42
|
+
flags: [
|
|
43
|
+
["--budget <n>", "Stop at n characters. The default is 8000."],
|
|
44
|
+
["--depth <n>", "Follow links n steps. The default is 1."],
|
|
45
|
+
],
|
|
46
|
+
exit: [OK, ["2", "A usage error, an unknown ID, or a bad path. It takes one input. Use `bundle` for several inputs or for `--block`."], INTERNAL], example: "specctx context 045/2.1",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "related", group: "Read", args: "<ID|path|change>", summary: "Show the entries that link to an ID, a file, or a change.",
|
|
42
50
|
flags: [["--depth <n>", "Follow links n steps. The default is 1."], ["--all", "Show all copies of an ID."]],
|
|
43
|
-
exit: [OK, ["2", "A usage error
|
|
51
|
+
exit: [OK, ["2", "A usage error, an unknown ID, or a path that is not a file in the repository."], INTERNAL], example: "specctx related src/graph/bundle.ts",
|
|
44
52
|
},
|
|
45
53
|
{
|
|
46
|
-
name: "bundle", group: "Read", args: "<ID...>", summary: "Print the spec text for the IDs and their links.",
|
|
54
|
+
name: "bundle", group: "Read", args: "<ID|path|change...>", summary: "Print the spec text for the IDs, files, or changes and their links.",
|
|
47
55
|
flags: [
|
|
48
56
|
["--budget <n>", "Stop at n characters. The default is 8000."],
|
|
49
57
|
["--depth <n>", "Follow links n steps. The default is 1."],
|
|
50
58
|
["--all", "Show all copies of an ID."],
|
|
59
|
+
["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
|
|
51
60
|
],
|
|
52
|
-
exit: [OK, ["2", "A usage error or an
|
|
61
|
+
exit: [OK, ["2", "A usage error, an unknown ID, a bad path, or an ID that is not a requirement."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
|
|
53
62
|
},
|
|
54
63
|
{
|
|
55
64
|
name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
|
|
@@ -62,6 +71,8 @@ export const TABLE = [
|
|
|
62
71
|
flags: [
|
|
63
72
|
["--strict", "Run `check` and `openspec validate` in strict mode."],
|
|
64
73
|
["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
|
|
74
|
+
["--rerun", "Run only the rerun step. Run the command of each task that the range closed. Compare its exit code with the evidence."],
|
|
75
|
+
["--base <ref>", "With --rerun, take the merge base with this ref. The default is [gate] base."],
|
|
65
76
|
],
|
|
66
77
|
exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
|
|
67
78
|
},
|
|
@@ -114,12 +125,19 @@ export const TABLE = [
|
|
|
114
125
|
{ name: "handoff write", group: "Write", args: "", summary: "Write .ctx/handoff.md from the index and the evidence.", flags: [], exit: PLAIN, example: "specctx handoff write" },
|
|
115
126
|
{
|
|
116
127
|
name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
|
|
117
|
-
flags: [
|
|
118
|
-
|
|
128
|
+
flags: [
|
|
129
|
+
["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."],
|
|
130
|
+
["--plan", "Mark the question as the plan approval. Only this mark ends the plan stop. It needs --change."],
|
|
131
|
+
],
|
|
132
|
+
exit: PLAIN, example: "specctx handoff ask --change 008 --plan \"plan ready for 008-tasks. Approve to start the tasks?\"",
|
|
119
133
|
},
|
|
120
134
|
{
|
|
121
135
|
name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
|
|
122
|
-
flags: [
|
|
136
|
+
flags: [
|
|
137
|
+
["--approve", "Approve a plan or archive question. A plan or archive question needs this or --reject."],
|
|
138
|
+
["--reject", "Reject a plan or archive question. The stop stays."],
|
|
139
|
+
],
|
|
140
|
+
exit: PLAIN, example: "specctx handoff resolve 1 --approve \"yes, for one release\"",
|
|
123
141
|
},
|
|
124
142
|
// Setup
|
|
125
143
|
{
|
package/dist/cli/workflow.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
1
|
+
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-007 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { dumps } from "../util/json.js";
|
|
5
5
|
import { paint } from "./color.js";
|
|
6
6
|
import { UsageExit } from "./errors.js";
|
|
7
|
+
import { runCommand } from "../runcmd.js";
|
|
8
|
+
/** REQ-CLI-010 a command to print, with the run prefix of the config (043/D1). */
|
|
9
|
+
const rc = (c, args) => runCommand(c.cfg.cli.run, args);
|
|
7
10
|
const arg = (c, n, what) => {
|
|
8
11
|
const v = c.p.positional[n];
|
|
9
12
|
if (v === undefined)
|
|
@@ -62,7 +65,8 @@ export async function runWorkflow(c) {
|
|
|
62
65
|
}
|
|
63
66
|
return 1;
|
|
64
67
|
}
|
|
65
|
-
|
|
68
|
+
// REQ-TASK-007 the command names the answer that task start prints
|
|
69
|
+
const bundleCommand = rc(c, `context ${t.id} --budget ${c.cfg.bundle.budget}`);
|
|
66
70
|
if (c.json) {
|
|
67
71
|
print(c, { bundle_command: bundleCommand, change: t.change, id: t.id, reqs: t.reqs, text: t.text, verify: t.verify.command });
|
|
68
72
|
}
|
|
@@ -82,10 +86,10 @@ export async function runWorkflow(c) {
|
|
|
82
86
|
const { canon } = await import("../index/canonical.js");
|
|
83
87
|
const { bundle } = await import("../graph/bundle.js");
|
|
84
88
|
const ids = canon(entries);
|
|
85
|
-
const known = t.reqs.filter((r) => ids.has(r));
|
|
86
89
|
for (const r of t.reqs.filter((x) => !ids.has(x)))
|
|
87
90
|
c.io.err(`warning: task ${t.id} cites ${r}, which the index does not hold\n`);
|
|
88
|
-
|
|
91
|
+
// 047/D5 the entry that findTask found goes in, so an active task wins over an archived one with its number
|
|
92
|
+
const b = bundle(c.root, entries, [t.id], c.cfg.bundle.budget, c.cfg.bundle.depth, { task: t.entry });
|
|
89
93
|
const { isoNow } = await import("../util/time.js");
|
|
90
94
|
const { CURRENT_PATH } = await import("../handoff/handoff.js");
|
|
91
95
|
mkdirSync(join(c.root, ".ctx"), { recursive: true });
|
|
@@ -94,7 +98,7 @@ export async function runWorkflow(c) {
|
|
|
94
98
|
// REQ-TASK-002 the Verify command is frozen here, so task done can refuse a changed one (019/D3)
|
|
95
99
|
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id, verify: t.verify.command }) + "\n");
|
|
96
100
|
// REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
|
|
97
|
-
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run
|
|
101
|
+
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run ${rc(c, `query --change ${t.change} --kind req`)} to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
98
102
|
if (c.json)
|
|
99
103
|
print(c, { bundle: b, hint, task: t.id });
|
|
100
104
|
else
|
|
@@ -113,7 +117,7 @@ export async function runWorkflow(c) {
|
|
|
113
117
|
throw new UsageExit("--manual needs a note");
|
|
114
118
|
// REQ-TASK-003 a task with a Verify command gets no manual note (019/D1, ADR-004)
|
|
115
119
|
if (t.verify.command !== null)
|
|
116
|
-
throw new UsageExit(`task ${t.id} has a Verify command. Run it with
|
|
120
|
+
throw new UsageExit(`task ${t.id} has a Verify command. Run it with ${rc(c, `task done ${t.id}`)}.`);
|
|
117
121
|
if (t.done && hasEvidence(c.root, t))
|
|
118
122
|
throw new UsageExit(`task ${t.id} is done`);
|
|
119
123
|
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
@@ -137,7 +141,7 @@ export async function runWorkflow(c) {
|
|
|
137
141
|
if (!dry) {
|
|
138
142
|
const frozen = readCurrent(c.root);
|
|
139
143
|
if (frozen && frozen.task === t.id && typeof frozen.verify === "string" && frozen.verify !== t.verify.command) {
|
|
140
|
-
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run
|
|
144
|
+
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run ${rc(c, `task start ${t.id}`)} again.`);
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
const { runVerify, tail } = await import("../verify/run.js");
|
|
@@ -170,7 +174,7 @@ export async function runWorkflow(c) {
|
|
|
170
174
|
const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
|
|
171
175
|
let question = openQuestionFor(c.root, t.id)?.n ?? null;
|
|
172
176
|
if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
|
|
173
|
-
question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail)).n;
|
|
177
|
+
question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail), "cap").n;
|
|
174
178
|
}
|
|
175
179
|
if (c.json)
|
|
176
180
|
print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
|
|
@@ -184,7 +188,7 @@ export async function runWorkflow(c) {
|
|
|
184
188
|
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
185
189
|
// REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
|
|
186
190
|
if (!c.json && question !== null)
|
|
187
|
-
c.io.out(blockedLine(question) + "\n");
|
|
191
|
+
c.io.out(blockedLine(question, c.cfg.cli.run) + "\n");
|
|
188
192
|
return 1;
|
|
189
193
|
}
|
|
190
194
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -197,6 +201,13 @@ export async function runWorkflow(c) {
|
|
|
197
201
|
}, "command", undefined, t.verify.cwd);
|
|
198
202
|
setBox(c.root, t, true);
|
|
199
203
|
clearCurrent(c.root, t.id);
|
|
204
|
+
// REQ-LOOP-014 a pass closes the attempt-cap question of the task. Plan, design, and archive questions stay (044/D3).
|
|
205
|
+
const { isCapQuestion } = await import("../loop/fails.js");
|
|
206
|
+
const { readQuestions, resolveQuestion } = await import("../handoff/questions.js");
|
|
207
|
+
for (const q of readQuestions(c.root)) {
|
|
208
|
+
if (isCapQuestion(q, t.id))
|
|
209
|
+
resolveQuestion(c.root, q.n, "Verify passed. Resolved by task done.");
|
|
210
|
+
}
|
|
200
211
|
if (c.json)
|
|
201
212
|
print(c, { evidence: rel, kind: "command", task: t.id });
|
|
202
213
|
else
|
|
@@ -240,7 +251,10 @@ export async function runWorkflow(c) {
|
|
|
240
251
|
}
|
|
241
252
|
case "handoff ask": {
|
|
242
253
|
const { ask } = await import("../handoff/handoff.js");
|
|
243
|
-
const
|
|
254
|
+
const plan = c.p.flags.plan === true;
|
|
255
|
+
if (plan && c.p.flags.change === undefined)
|
|
256
|
+
throw new UsageExit("--plan needs --change");
|
|
257
|
+
const h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change, plan);
|
|
244
258
|
if (c.json)
|
|
245
259
|
print(c, { open_questions: h.open_questions });
|
|
246
260
|
else
|
|
@@ -252,7 +266,10 @@ export async function runWorkflow(c) {
|
|
|
252
266
|
const n = arg(c, 0, "a question number");
|
|
253
267
|
if (!/^\d+$/.test(n))
|
|
254
268
|
throw new UsageExit("handoff resolve needs a question number");
|
|
255
|
-
const
|
|
269
|
+
const approve = c.p.flags.approve === true;
|
|
270
|
+
const reject = c.p.flags.reject === true;
|
|
271
|
+
const verdict = approve && reject ? "both" : approve ? "approved" : reject ? "rejected" : undefined;
|
|
272
|
+
const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "), verdict);
|
|
256
273
|
if (c.json)
|
|
257
274
|
print(c, { open_questions: h.open_questions, resolved: h.resolved });
|
|
258
275
|
else
|
package/dist/config.js
CHANGED
|
@@ -21,6 +21,7 @@ export function defaults() {
|
|
|
21
21
|
// REQ-DIST-002 the gate lints nothing unless the project opts in (022/D1, ADR-006 item 9)
|
|
22
22
|
gate: { markdown: [], openspec: DEFAULT_OPENSPEC, base: "origin/main" },
|
|
23
23
|
loop: { max_attempts: 3, archive: "ask" },
|
|
24
|
+
cli: { run: "specctx" },
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export class ConfigError extends Error {
|
|
@@ -43,7 +44,7 @@ export function loadConfig(root) {
|
|
|
43
44
|
raw = parse(readFileSync(p, "utf8"));
|
|
44
45
|
}
|
|
45
46
|
catch (e) {
|
|
46
|
-
throw new ConfigError(`${
|
|
47
|
+
throw new ConfigError(`${p}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
|
|
47
48
|
}
|
|
48
49
|
const sec = (name) => {
|
|
49
50
|
const v = raw[name];
|
|
@@ -122,6 +123,13 @@ export function loadConfig(root) {
|
|
|
122
123
|
throw new ConfigError(`${CONFIG_FILE}: loop.archive must be "ask" or "auto".`);
|
|
123
124
|
cfg.loop.archive = v;
|
|
124
125
|
}
|
|
126
|
+
// REQ-CLI-010 the run prefix of printed commands (043/D1)
|
|
127
|
+
const cli = sec("cli");
|
|
128
|
+
if ("run" in cli) {
|
|
129
|
+
if (typeof cli.run !== "string" || !cli.run.trim())
|
|
130
|
+
throw new ConfigError(`${CONFIG_FILE}: cli.run must be a string that is not empty.`);
|
|
131
|
+
cfg.cli.run = cli.run.trim();
|
|
132
|
+
}
|
|
125
133
|
return cfg;
|
|
126
134
|
}
|
|
127
135
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|