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.
@@ -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 { loadConfig, ConfigError } = await import("../config.js");
77
- let cfg;
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: specctx adopt\n`);
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
- io.err(`unknown id ${id}\n`);
240
- return 2;
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
- const unknown = p.positional.filter((i) => !ids.has(i));
261
- if (unknown.length) {
262
- io.err(`unknown id(s): ${unknown.join(", ")}\n`);
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
- const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
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", "next: specctx status\n", io.tty));
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 = ["specctx init", all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
323
- io.out(paint("dim", `next: ${r.changed.length ? again : "specctx status"}\n`, io.tty));
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: "related", group: "Read", args: "<ID>", summary: "Show the entries that link to an ID.",
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 or an unknown ID."], INTERNAL], example: "specctx related REQ-IDX-001",
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 unknown ID."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
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: [["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."]],
118
- exit: PLAIN, example: "specctx handoff ask --change 008 \"plan ready for 008-tasks. Approve to start the tasks?\"",
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: [], exit: PLAIN, example: "specctx handoff resolve 1 \"yes, for one release\"",
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
  {
@@ -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
- const bundleCommand = `specctx bundle ${t.reqs.join(" ")} --budget ${c.cfg.bundle.budget}`;
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
- const b = bundle(c.root, entries, known, c.cfg.bundle.budget, c.cfg.bundle.depth);
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 specctx query --change ${t.change} --kind req to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
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 specctx task done ${t.id}.`);
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 specctx task start ${t.id} again.`);
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 h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change);
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 h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "));
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(`${CONFIG_FILE}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
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. */