spec-ctx 0.4.0 → 0.5.0

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.
@@ -1,14 +1,14 @@
1
1
  // REQ-CHK-004 the rules of check and one fix hint per rule. The check summary prints the hint of each rule it found.
2
2
  /** `hard` rules set exit 1. `advisory` rules print a warning and keep exit 0. */
3
3
  export const RULES = {
4
- "missing-id": { level: "hard", hint: "Add an ID to the header. Run specctx adopt --dry-run to see the plan." },
4
+ "missing-id": { level: "hard", hint: "Add an ID to the header. Run {run} adopt --dry-run to see the plan." },
5
5
  "scenario-id-mismatch": { level: "hard", hint: "Give the scenario the number of its requirement." },
6
6
  "bad-ref": { level: "hard", hint: "Write only IDs on the Refs line, separated by commas." },
7
7
  "task-without-req": { level: "hard", hint: "Cite a requirement in brackets on the task line, for example [REQ-ABC-001]." },
8
8
  "task-without-verify": { level: "hard", hint: "Add Verify: and a command in backticks to the task line." },
9
- "duplicate-id": { level: "hard", hint: "Give one of the two entries a new ID from specctx id new." },
9
+ "duplicate-id": { level: "hard", hint: "Give one of the two entries a new ID from {run} id new." },
10
10
  "duplicate-change-key": { level: "hard", hint: "Rename one change folder, so that each key names one change." },
11
- "reused-id": { level: "hard", hint: "The ID is retired. Get a new ID from specctx id new." },
11
+ "reused-id": { level: "hard", hint: "The ID is retired. Get a new ID from {run} id new." },
12
12
  // REQ-CHK-007 the hint names the string literal cause and the fix (033/D3)
13
13
  "dangling-ref": {
14
14
  level: "hard",
@@ -20,13 +20,13 @@ export const RULES = {
20
20
  "unknown-cap": { level: "hard", hint: "Add the code to caps under [ids] in spec-ctx.toml." },
21
21
  uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
22
22
  "scenario-untested": { level: "hard", hint: "Name the scenario ID in a test with a // Covers: <SCN> tag." },
23
- "done-without-evidence": { level: "hard", hint: "Run specctx task done. It runs the Verify command and writes the evidence." },
23
+ "done-without-evidence": { level: "hard", hint: "Run {run} task done. It runs the Verify command and writes the evidence." },
24
24
  // REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
25
- "stale-evidence": { level: "hard", hint: "Run specctx task reopen <task> <why>, then specctx task done <task>." },
26
- "evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
25
+ "stale-evidence": { level: "hard", hint: "Run {run} task reopen <task> <why>, then {run} task done <task>." },
26
+ "evidence-without-done": { level: "hard", hint: "Run {run} task done, or remove the evidence file." },
27
27
  // REQ-CHK-005 advisory in plain check. check --strict reports it as a hard finding (028/D5).
28
28
  "weak-verify": { level: "advisory", hint: "Name a test file or a command whose exit code proves the task." },
29
- "manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
29
+ "manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run {run} task done again." },
30
30
  // REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
31
31
  // REQ-ADOPT-010 FROM copies the main header and TO copies the delta header, each in its own form (027/D4).
32
32
  "retitle-needs-renamed": {
@@ -34,6 +34,7 @@ export const RULES = {
34
34
  hint: "Add to the delta before the MODIFIED block. Write each ID as its header writes it, bare or in brackets:\n## RENAMED Requirements\n- FROM: `### Requirement: <ID> <old title>`\n- TO: `### Requirement: <ID> <new title>`",
35
35
  },
36
36
  };
37
- export function hintOf(rule) {
38
- return RULES[rule]?.hint ?? "Read the check rules in the check spec.";
37
+ /** REQ-CLI-010 `{run}` in a hint stands for the run prefix of `[cli] run` (043/D1). */
38
+ export function hintOf(rule, run = "specctx") {
39
+ return (RULES[rule]?.hint ?? "Read the check rules in the check spec.").replaceAll("{run}", run);
39
40
  }
package/dist/cli/args.js CHANGED
@@ -8,7 +8,9 @@ export const COMMAND_FLAGS = {
8
8
  query: { kind: "string", change: "string", all: "bool", limit: "int" },
9
9
  related: { depth: "int", all: "bool" },
10
10
  bundle: { budget: "int", depth: "int", all: "bool", block: "bool" },
11
+ context: { budget: "int", depth: "int" },
11
12
  check: { strict: "bool" },
13
+ report: {},
12
14
  stats: { change: "string", write: "bool" },
13
15
  gate: { strict: "bool", markdown: "string", rerun: "bool", base: "string" },
14
16
  "id new": { for: "string", count: "int" },
@@ -20,7 +22,7 @@ export const COMMAND_FLAGS = {
20
22
  "task reopen": {},
21
23
  "handoff write": {},
22
24
  "handoff ask": { change: "string", plan: "bool" },
23
- "handoff resolve": {},
25
+ "handoff resolve": { approve: "bool", reject: "bool" },
24
26
  adopt: { "dry-run": "bool" },
25
27
  "baseline write": {},
26
28
  "baseline prune": {},
@@ -2,8 +2,11 @@
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";
9
+ import { note } from "../journal/note.js";
7
10
  /** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
8
11
  async function runCheck(root, cfg, data, strict) {
9
12
  const { check } = await import("../check/check.js");
@@ -26,12 +29,13 @@ async function runCheck(root, cfg, data, strict) {
26
29
  * REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
27
30
  * A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
28
31
  */
29
- export function checkText(r, tty) {
32
+ export function checkText(r, tty, run = "specctx", deferred = []) {
30
33
  // REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
31
34
  // REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
32
35
  const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
33
36
  + r.warnings.map((w) => `warning: ${w}\n`).join("");
34
- const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
37
+ // REQ-CHK-010 a deferred line is not a finding. It comes before the baselined line, which stays last (052/D3).
38
+ const base = deferred.map((d) => `deferred: ${d.change} ${d.untested} untested delta item${d.untested === 1 ? "" : "s"}, ${d.open} open task${d.open === 1 ? "" : "s"}\n`).join("") + (r.baselined ? `baselined: ${r.baselined}\n` : "");
35
39
  const n = r.findings.length + r.warnings.length;
36
40
  if (!n)
37
41
  return paint("dim", "0 findings\n", tty) + base;
@@ -62,7 +66,7 @@ export function checkText(r, tty) {
62
66
  const rules = [...count.keys()].sort();
63
67
  return body
64
68
  + "\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("")
69
+ + rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule, run)}\n`).join("")
66
70
  + base;
67
71
  }
68
72
  export async function run(p, io) {
@@ -73,8 +77,21 @@ export async function run(p, io) {
73
77
  return init(p, io);
74
78
  const root = findRoot(io.cwd, f.root);
75
79
  requireOpenSpec(root);
80
+ // REQ-JRN-001 the root is known now, so the bin entry may write the journal
81
+ note(io, { root });
82
+ // REQ-JRN-004 report runs before the config load, so a broken config still reports
83
+ if (cmd === "report") {
84
+ if (p.positional.length)
85
+ throw new UsageExit("report takes no arguments");
86
+ const { buildReport } = await import("../journal/report.js");
87
+ const { VERSION } = await import("../version.js");
88
+ io.out(buildReport(root, new Date(), { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }).text);
89
+ return 0;
90
+ }
76
91
  const { loadConfigOrExit } = await import("./config.js");
77
92
  const cfg = loadConfigOrExit(root);
93
+ // REQ-JRN-006 the exit 3 line names the report command with this prefix
94
+ note(io, { run: cfg.cli.run });
78
95
  const { dumps } = await import("../util/json.js");
79
96
  const print = (v) => io.out(dumps(v) + "\n");
80
97
  if (cmd === "build") {
@@ -103,7 +120,7 @@ export async function run(p, io) {
103
120
  io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
104
121
  // 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).
105
122
  else
106
- io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
123
+ io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: ${runCommand(cfg.cli.run, "adopt")}\n`);
107
124
  return plan.edits.length ? 1 : 0;
108
125
  }
109
126
  if (cmd === "baseline write" || cmd === "baseline prune") {
@@ -163,7 +180,7 @@ export async function run(p, io) {
163
180
  void check;
164
181
  const data = buildIndex(root, cfg);
165
182
  const r = await runCheck(root, cfg, data, strict);
166
- return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty) };
183
+ return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty, cfg.cli.run) };
167
184
  },
168
185
  }, io);
169
186
  }
@@ -176,12 +193,17 @@ export async function run(p, io) {
176
193
  if (cmd === "query" && !f.change)
177
194
  need(1, "at least one term");
178
195
  if (cmd === "related") {
179
- need(1, "one ID");
196
+ need(1, "one ID, path, or change");
180
197
  if (p.positional.length > 1)
181
- throw new UsageExit("related takes one ID");
198
+ throw new UsageExit("related takes one ID, path, or change");
182
199
  }
183
200
  if (cmd === "bundle")
184
- need(1, "at least one ID");
201
+ need(1, "at least one ID, path, or change");
202
+ if (cmd === "context") {
203
+ need(1, "one task, ID, path, or change");
204
+ if (p.positional.length > 1)
205
+ throw new UsageExit("context takes one task, ID, path, or change");
206
+ }
185
207
  if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
186
208
  throw new UsageExit(`${cmd} takes no arguments`);
187
209
  const { data, rebuilt } = loadIndex(root, cfg);
@@ -231,15 +253,35 @@ export async function run(p, io) {
231
253
  }
232
254
  case "related": {
233
255
  const { canon } = await import("../index/canonical.js");
234
- const { related } = await import("../graph/related.js");
235
- const id = p.positional[0];
256
+ const { related, mainVersion } = await import("../graph/related.js");
236
257
  const ids = canon(entries);
258
+ let id = p.positional[0];
259
+ let isPath = false;
237
260
  if (!ids.has(id) && !(all && entries.some((e) => e.id === id))) {
238
- io.err(`unknown id ${id}\n`);
239
- return 2;
261
+ // REQ-QRY-011 a change key or folder name is a change. REQ-QRY-005 any other input is a path in the repository.
262
+ const { resolveFileInput, changeInput } = await import("../graph/path.js");
263
+ const ch = changeInput(entries, id);
264
+ if (ch)
265
+ id = ch;
266
+ else {
267
+ const r = resolveFileInput(root, io.cwd, id);
268
+ if ("error" in r) {
269
+ io.err(`${r.error}\n`);
270
+ return 2;
271
+ }
272
+ id = r.rel;
273
+ isPath = true;
274
+ }
240
275
  }
241
276
  const rows = [];
242
277
  for (const [i, reason] of related(entries, id, f.depth ?? 1, all)) {
278
+ // REQ-QRY-010 the pair of a main version stands for the non-canonical copy in the main spec
279
+ if (!all && reason === `main version of ${i}`) {
280
+ const m = mainVersion(entries, i);
281
+ if (m)
282
+ rows.push([m, reason]);
283
+ continue;
284
+ }
243
285
  const copies = all ? entries.filter((e) => e.id === i) : ids.has(i) ? [ids.get(i)] : [];
244
286
  // canonical copy first, the rest in index order
245
287
  copies.sort((a, b) => Number(!a.canonical) - Number(!b.canonical));
@@ -248,37 +290,56 @@ export async function run(p, io) {
248
290
  }
249
291
  if (json)
250
292
  print(rows.map(([e, reason]) => ({ ...e, reason })));
293
+ else if (isPath && rows.length === 0)
294
+ io.out(`no links: ${id}\n`);
251
295
  else
252
296
  io.out(rows.map(([e, r]) => `${e.kind} ${e.id} ${e.status} ${e.file}:${e.line} ${r}\n`).join(""));
253
297
  return 0;
254
298
  }
299
+ case "context":
255
300
  case "bundle": {
256
301
  const { canon } = await import("../index/canonical.js");
257
302
  const { bundle } = await import("../graph/bundle.js");
303
+ const { resolveInputs } = await import("../graph/input.js");
258
304
  const ids = canon(entries);
259
- const unknown = p.positional.filter((i) => !ids.has(i));
260
- if (unknown.length) {
261
- io.err(`unknown id(s): ${unknown.join(", ")}\n`);
305
+ // REQ-QRY-018 a negative budget is a usage error (050/D11)
306
+ if (f.budget !== undefined && f.budget < 0)
307
+ throw new UsageExit("--budget must be 0 or more");
308
+ // REQ-QRY-005 an input that is no known ID is a path in the repository
309
+ const { wanted, paths, errors, taskId } = resolveInputs(root, io.cwd, entries, p.positional);
310
+ if (errors.length) {
311
+ io.err(errors.map((l) => l + "\n").join(""));
262
312
  return 2;
263
313
  }
264
314
  // REQ-QRY-004 --block prints the requirement blocks verbatim and nothing else (034/D2)
265
315
  if (f.block) {
266
- const notReq = p.positional.filter((i) => ids.get(i).kind !== "req");
316
+ const notReq = p.positional.filter((i) => ids.get(i)?.kind !== "req");
267
317
  if (notReq.length) {
268
318
  io.err(`not a requirement: ${notReq.join(", ")}\n`);
269
319
  return 2;
270
320
  }
271
321
  const { reqBlocks } = await import("../graph/bundle.js");
272
322
  const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
323
+ // REQ-QRY-019 block mode has no budget, so budget is null and the text is complete (050/D6)
273
324
  if (json)
274
- print({ text, omitted: [] });
325
+ print({ text, omitted: [], chars: (await import("../util/pystr.js")).cpLen(text), budget: null, complete: true });
275
326
  else
276
327
  io.out(text);
277
328
  return 0;
278
329
  }
279
- const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
330
+ // 047/D5 a task input passes the entry that findTask picks, so an active task wins over an archived one
331
+ const task = taskId === undefined ? undefined : (await import("../tasks/tasks.js")).findTask(root, entries, taskId).entry;
332
+ const b = bundle(root, entries, wanted, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, { all, task });
333
+ // REQ-JRN-002 only context emits the block signals. The sink is set only for the bin entry.
334
+ if (cmd === "context" && io.journal) {
335
+ const { contextSignals } = await import("../journal/signals.js");
336
+ for (const signal of contextSignals(entries, b))
337
+ note(io, { signal });
338
+ }
280
339
  if (json)
281
340
  print(b);
341
+ else if (b.text === "" && wanted.every((w) => paths.has(w)))
342
+ io.out(wanted.map((w) => `no links: ${w}\n`).join(""));
282
343
  else
283
344
  io.out(b.text);
284
345
  return 0;
@@ -307,10 +368,12 @@ export async function run(p, io) {
307
368
  const r = await runCheck(root, cfg, data, !!f.strict);
308
369
  // REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
309
370
  const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
371
+ // REQ-CHK-010 only this case computes the deferred notice. runCheck and gate stay as they are (052/D5).
372
+ const deferred = f.strict ? (await import("../check/check.js")).deferredOf(entries, data) : [];
310
373
  if (json)
311
- print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
374
+ print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings, ...(f.strict ? { deferred } : {}) });
312
375
  else
313
- io.out(checkText(r, io.tty));
376
+ io.out(checkText(r, io.tty, cfg.cli.run, deferred));
314
377
  return r.findings.length ? 1 : 0;
315
378
  }
316
379
  }
@@ -323,18 +386,28 @@ async function init(p, io) {
323
386
  const root = resolve(io.cwd, p.flags.root ?? ".");
324
387
  const all = !!p.flags.all;
325
388
  const r = runInit(root, { schema: all || !!p.flags.schema, skills: all || !!p.flags.skills, force: !!p.flags.force });
389
+ // REQ-CLI-010 init prints its next step with the run prefix of the config it wrote or kept (043/D1)
390
+ const { loadConfigOrExit } = await import("./config.js");
391
+ const run = (() => {
392
+ try {
393
+ return loadConfigOrExit(root).cli.run;
394
+ }
395
+ catch {
396
+ return defaults().cli.run;
397
+ }
398
+ })();
326
399
  if (p.flags.json)
327
400
  io.out(JSON.stringify(r, null, 2) + "\n");
328
401
  else if (r.nothing)
329
- io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
402
+ io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", `next: ${runCommand(run, "status")}\n`, io.tty));
330
403
  else {
331
404
  io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
332
405
  io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
333
406
  io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
334
407
  io.out(r.notes.map((n) => `${n}\n`).join(""));
335
408
  // REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
336
- const again = ["specctx init", all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
337
- io.out(paint("dim", `next: ${r.changed.length ? again : "specctx status"}\n`, io.tty));
409
+ const again = [runCommand(run, "init"), all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
410
+ io.out(paint("dim", `next: ${r.changed.length ? again : runCommand(run, "status")}\n`, io.tty));
338
411
  }
339
412
  return 0;
340
413
  }
package/dist/cli/help.js CHANGED
@@ -38,19 +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>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. 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
- ["--budget <n>", "Stop at n characters. The default is 8000."],
56
+ ["--budget <n>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. 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."],
51
59
  ["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
52
60
  ],
53
- exit: [OK, ["2", "A usage error, an unknown ID, or an ID that is not a requirement."], 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",
54
62
  },
55
63
  {
56
64
  name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
@@ -82,6 +90,11 @@ export const TABLE = [
82
90
  flags: [["--change <name>", "Show only this change."], ["--write", "Also write docs/metrics/<date>.md."]],
83
91
  exit: PLAIN, example: "specctx stats --write",
84
92
  },
93
+ {
94
+ name: "report", group: "Read", args: "", summary: "Print a bug report from the run journal in .ctx/local. Review it, then file it with `gh issue create`. Write nothing.",
95
+ flags: [],
96
+ exit: [OK, ["2", "A usage error."], INTERNAL], example: "specctx report > report.md",
97
+ },
85
98
  {
86
99
  name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
87
100
  flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint tells --baseline 0 README.md",
@@ -125,7 +138,11 @@ export const TABLE = [
125
138
  },
126
139
  {
127
140
  name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
128
- flags: [], exit: PLAIN, example: "specctx handoff resolve 1 \"yes, for one release\"",
141
+ flags: [
142
+ ["--approve", "Approve a plan or archive question. A plan or archive question needs this or --reject."],
143
+ ["--reject", "Reject a plan or archive question. The stop stays."],
144
+ ],
145
+ exit: PLAIN, example: "specctx handoff resolve 1 --approve \"yes, for one release\"",
129
146
  },
130
147
  // Setup
131
148
  {
@@ -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");
@@ -167,10 +171,13 @@ export async function runWorkflow(c) {
167
171
  // REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
168
172
  const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
169
173
  const { addQuestion } = await import("../handoff/questions.js");
170
- const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
174
+ // REQ-TASK-008 the fail file and the cap question hold no machine path. The printed tail stays as it is (056/D5).
175
+ const { redactPaths } = await import("../util/redact.js");
176
+ const kept = redactPaths(outTail, c.root);
177
+ const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, kept);
171
178
  let question = openQuestionFor(c.root, t.id)?.n ?? null;
172
179
  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;
180
+ question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, kept), "cap").n;
174
181
  }
175
182
  if (c.json)
176
183
  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 +191,7 @@ export async function runWorkflow(c) {
184
191
  c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
185
192
  // REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
186
193
  if (!c.json && question !== null)
187
- c.io.out(blockedLine(question) + "\n");
194
+ c.io.out(blockedLine(question, c.cfg.cli.run) + "\n");
188
195
  return 1;
189
196
  }
190
197
  const { sha256 } = await import("../util/hash.js");
@@ -192,11 +199,20 @@ export async function runWorkflow(c) {
192
199
  // the Verify command can change tagged files, so the digests come from the tree after the run
193
200
  const { loadIndex } = await import("../index/build.js");
194
201
  const after = loadIndex(c.root, c.cfg).data.entries;
202
+ // REQ-TASK-008 the stderr tail holds no machine path. verify.command stays as the task line has it (056/D5, 056/D6).
203
+ const { redactPaths } = await import("../util/redact.js");
195
204
  const rel = writeEvidence(c.root, t, after, {
196
- command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: tail(r.stderr.toString("utf8")),
205
+ command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: redactPaths(tail(r.stderr.toString("utf8")), c.root),
197
206
  }, "command", undefined, t.verify.cwd);
198
207
  setBox(c.root, t, true);
199
208
  clearCurrent(c.root, t.id);
209
+ // REQ-LOOP-014 a pass closes the attempt-cap question of the task. Plan, design, and archive questions stay (044/D3).
210
+ const { isCapQuestion } = await import("../loop/fails.js");
211
+ const { readQuestions, resolveQuestion } = await import("../handoff/questions.js");
212
+ for (const q of readQuestions(c.root)) {
213
+ if (isCapQuestion(q, t.id))
214
+ resolveQuestion(c.root, q.n, "Verify passed. Resolved by task done.");
215
+ }
200
216
  if (c.json)
201
217
  print(c, { evidence: rel, kind: "command", task: t.id });
202
218
  else
@@ -255,7 +271,10 @@ export async function runWorkflow(c) {
255
271
  const n = arg(c, 0, "a question number");
256
272
  if (!/^\d+$/.test(n))
257
273
  throw new UsageExit("handoff resolve needs a question number");
258
- const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "));
274
+ const approve = c.p.flags.approve === true;
275
+ const reject = c.p.flags.reject === true;
276
+ const verdict = approve && reject ? "both" : approve ? "approved" : reject ? "rejected" : undefined;
277
+ const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "), verdict);
259
278
  if (c.json)
260
279
  print(c, { open_questions: h.open_questions, resolved: h.resolved });
261
280
  else
package/dist/cli.js CHANGED
@@ -1,14 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  // REQ-CLI-001 bin entry. tests call main() from src/main.ts in process.
3
+ // REQ-JRN-001 this entry writes the run journal after main() returns. main() only fills the sink (057/D1).
3
4
  import { main } from "./main.js";
5
+ import { VERSION } from "./version.js";
4
6
  process.stdout.on("error", (e) => {
5
7
  if (e.code === "EPIPE")
6
8
  process.exit(0);
7
9
  });
8
- const code = await main(process.argv.slice(2), {
9
- cwd: process.cwd(),
10
- out: (s) => process.stdout.write(s),
11
- err: (s) => process.stderr.write(s),
12
- tty: process.stdout.isTTY === true,
13
- });
10
+ const argv = process.argv.slice(2);
11
+ const sink = { signals: new Set() };
12
+ const t0 = performance.now();
13
+ let code;
14
+ let thrown;
15
+ let failed = false;
16
+ try {
17
+ code = await main(argv, {
18
+ cwd: process.cwd(),
19
+ out: (s) => process.stdout.write(s),
20
+ err: (s) => process.stderr.write(s),
21
+ tty: process.stdout.isTTY === true,
22
+ journal: sink,
23
+ });
24
+ }
25
+ catch (e) {
26
+ failed = true;
27
+ thrown = e;
28
+ code = 3;
29
+ try {
30
+ const { errInfo } = await import("./journal/note.js");
31
+ sink.err = errInfo(e);
32
+ }
33
+ catch {
34
+ // no journal detail
35
+ }
36
+ }
37
+ try {
38
+ // REQ-JRN-001 a usage error before the root check keeps no root. Only the "?" command with no --root looks for one (057/D2).
39
+ if (sink.root === undefined && sink.cmd === "?" && !argv.some((a) => a === "--root" || a.startsWith("--root="))) {
40
+ const { findRoot } = await import("./cli/root.js");
41
+ sink.root = findRoot(process.cwd());
42
+ }
43
+ if (sink.root !== undefined && process.env.SPECCTX_JOURNAL !== "0") {
44
+ const { appendRun } = await import("./journal/journal.js");
45
+ appendRun(sink, { exit: code, ms: performance.now() - t0, version: VERSION });
46
+ }
47
+ }
48
+ catch {
49
+ // the journal never changes the exit code or the output
50
+ }
51
+ if (failed)
52
+ throw thrown;
14
53
  process.exitCode = code;
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 {
@@ -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. */