spec-ctx 0.2.0 → 0.4.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.
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
@@ -48,8 +48,9 @@ export const TABLE = [
48
48
  ["--budget <n>", "Stop at n characters. The default is 8000."],
49
49
  ["--depth <n>", "Follow links n steps. The default is 1."],
50
50
  ["--all", "Show all copies of an ID."],
51
+ ["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
51
52
  ],
52
- exit: [OK, ["2", "A usage error or an unknown ID."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
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",
53
54
  },
54
55
  {
55
56
  name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
@@ -62,6 +63,8 @@ export const TABLE = [
62
63
  flags: [
63
64
  ["--strict", "Run `check` and `openspec validate` in strict mode."],
64
65
  ["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
66
+ ["--rerun", "Run only the rerun step. Run the command of each task that the range closed. Compare its exit code with the evidence."],
67
+ ["--base <ref>", "With --rerun, take the merge base with this ref. The default is [gate] base."],
65
68
  ],
66
69
  exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
67
70
  },
@@ -114,7 +117,11 @@ export const TABLE = [
114
117
  { 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
118
  {
116
119
  name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
117
- flags: [], exit: PLAIN, example: "specctx handoff ask \"Keep the old flag?\"",
120
+ flags: [
121
+ ["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."],
122
+ ["--plan", "Mark the question as the plan approval. Only this mark ends the plan stop. It needs --change."],
123
+ ],
124
+ exit: PLAIN, example: "specctx handoff ask --change 008 --plan \"plan ready for 008-tasks. Approve to start the tasks?\"",
118
125
  },
119
126
  {
120
127
  name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
@@ -64,7 +64,11 @@ export function statusReport(root, entries) {
64
64
  open_questions: openQuestions(root),
65
65
  };
66
66
  }
67
- export function statusLines(r) {
67
+ /**
68
+ * REQ-CLI-007 the status lines. The last line is always `next: ` and the command of the decision
69
+ * of `specctx next`, or `none` when the decision has no command (030/D1, 030/D2).
70
+ */
71
+ export function statusLines(r, next) {
68
72
  const out = r.changes.map((c) => `${c.change} done ${c.done} open ${c.open}`);
69
73
  if (!r.changes.length) {
70
74
  out.push("no active change");
@@ -82,19 +86,6 @@ export function statusLines(r) {
82
86
  for (const q of r.open_questions)
83
87
  out.push(`- ${q}`);
84
88
  }
85
- const next = nextStep(r);
86
- if (next)
87
- out.push(`next: ${next}`);
89
+ out.push(`next: ${next ?? "none"}`);
88
90
  return out;
89
91
  }
90
- /** REQ-CLI-007 the next step by state: an open task, then a change with every task done, then uncovered REQs. */
91
- export function nextStep(r) {
92
- if (r.changes.some((c) => c.open > 0))
93
- return "specctx task next";
94
- const ready = r.changes.find((c) => c.done > 0 && c.open === 0);
95
- if (ready)
96
- return `archive ${ready.change}`;
97
- if (r.uncovered > 0)
98
- return "specctx check";
99
- return null;
100
- }
@@ -240,7 +240,10 @@ export async function runWorkflow(c) {
240
240
  }
241
241
  case "handoff ask": {
242
242
  const { ask } = await import("../handoff/handoff.js");
243
- const h = ask(c.root, entries, c.p.positional.join(" "));
243
+ const plan = c.p.flags.plan === true;
244
+ if (plan && c.p.flags.change === undefined)
245
+ throw new UsageExit("--plan needs --change");
246
+ const h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change, plan);
244
247
  if (c.json)
245
248
  print(c, { open_questions: h.open_questions });
246
249
  else
package/dist/config.js CHANGED
@@ -43,7 +43,7 @@ export function loadConfig(root) {
43
43
  raw = parse(readFileSync(p, "utf8"));
44
44
  }
45
45
  catch (e) {
46
- throw new ConfigError(`${CONFIG_FILE}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
46
+ throw new ConfigError(`${p}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
47
47
  }
48
48
  const sec = (name) => {
49
49
  const v = raw[name];
@@ -0,0 +1,169 @@
1
+ // REQ-VER-013 REQ-VER-005 REQ-VER-006 REQ-VER-007 the rerun step: the tasks a range closed, the compare, the groups (036/D1).
2
+ import { spawnSync } from "node:child_process";
3
+ import { changeKey, changeOf } from "../index/scan.js";
4
+ import { cmpStr } from "../util/pystr.js";
5
+ function git(root, args) {
6
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
7
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
8
+ }
9
+ /** REQ-VER-005 the one line of a failed merge base. A shallow clone gets a second sentence. */
10
+ export function noMergeBaseLine(root, base) {
11
+ const shallow = git(root, ["rev-parse", "--is-shallow-repository"]);
12
+ const tail = shallow.ok && shallow.out === "true" ? ". This clone is shallow" : "";
13
+ return `rerun: no merge base with ${base}, fetch full history (fetch-depth: 0)${tail}`;
14
+ }
15
+ /** REQ-VER-005 the task ID order: the change key, then the group and the number as integers. */
16
+ export function cmpTaskId(a, b) {
17
+ const [ka, na = ""] = a.split("/");
18
+ const [kb, nb = ""] = b.split("/");
19
+ const k = cmpStr(ka, kb);
20
+ if (k !== 0)
21
+ return k;
22
+ const pa = na.split(".").map(Number);
23
+ const pb = nb.split(".").map(Number);
24
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
25
+ const d = (pa[i] ?? -1) - (pb[i] ?? -1);
26
+ if (d !== 0)
27
+ return d < 0 ? -1 : 1;
28
+ }
29
+ return 0;
30
+ }
31
+ /** REQ-VER-005 the task lines of one tasks.md text, with the key that the index would give them. */
32
+ export function parseTasksFile(path, text, taskRe) {
33
+ const key = changeKey(changeOf(path));
34
+ const archived = path.includes("/archive/");
35
+ const out = [];
36
+ for (const line of text.split(/\r?\n/)) {
37
+ const m = taskRe.exec(line);
38
+ if (m)
39
+ out.push({ id: `${key}/${m[2]}`, done: m[1].toLowerCase() === "x", archived });
40
+ }
41
+ return out;
42
+ }
43
+ /** REQ-VER-005 one state per ID. An active copy wins over an archived copy, as findTask does. */
44
+ export function mergeStates(states) {
45
+ const out = new Map();
46
+ for (const s of states) {
47
+ const had = out.get(s.id);
48
+ if (!had || (had.archived && !s.archived))
49
+ out.set(s.id, s);
50
+ }
51
+ return out;
52
+ }
53
+ /** REQ-VER-005 the state of every task in the tasks.md files at a commit. */
54
+ export function tasksAt(root, sha, taskRe) {
55
+ const ls = git(root, ["ls-tree", "-z", "-r", "--name-only", sha, "--", "openspec/changes"]);
56
+ const all = [];
57
+ if (!ls.ok)
58
+ return new Map();
59
+ for (const p of ls.out.split("\0").filter((x) => x.endsWith("/tasks.md")).sort(cmpStr)) {
60
+ const show = spawnSync("git", ["show", `${sha}:${p}`], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
61
+ if (show.status === 0)
62
+ all.push(...parseTasksFile(p, show.stdout, taskRe));
63
+ }
64
+ return mergeStates(all);
65
+ }
66
+ /** REQ-VER-005 the index entries of tasks, one per ID. The active copy wins. */
67
+ export function headStates(entries) {
68
+ const out = new Map();
69
+ for (const e of entries) {
70
+ if (e.kind !== "task")
71
+ continue;
72
+ const archived = e.file.includes("/archive/");
73
+ const had = out.get(e.id);
74
+ if (!had || (had.state.archived && !archived))
75
+ out.set(e.id, { state: { id: e.id, done: e.status === "done", archived }, entry: e });
76
+ }
77
+ return out;
78
+ }
79
+ /** REQ-VER-010 the commit that HEAD names, or "" when git fails. */
80
+ export function headSha(root) {
81
+ const h = git(root, ["rev-parse", "HEAD"]);
82
+ return h.ok ? h.out : "";
83
+ }
84
+ /** REQ-VER-010 the line for a merge base equal to HEAD. */
85
+ export const HEAD_BASE_LINE = "rerun: merge base is HEAD, nothing to compare. On push, pass --base <the commit before the push>";
86
+ /** REQ-VER-013 the line for a base of forty zeros, the `before` of a push that creates a branch. */
87
+ export const ZERO_BASE_LINE = "rerun: base is the zero commit (a new branch), nothing to compare";
88
+ /** REQ-VER-013 a base that ends the rerun before the merge base: the zero commit passes, an unknown commit fails. */
89
+ export function baseProblem(root, base) {
90
+ if (/^0{40}$/.test(base))
91
+ return { ok: true, line: ZERO_BASE_LINE };
92
+ const known = !base.startsWith("-") && git(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).ok;
93
+ if (known)
94
+ return null;
95
+ return { ok: false, line: `rerun: base ${base} is not in this clone. Fetch it or use fetch-depth: 0` };
96
+ }
97
+ /**
98
+ * REQ-VER-005 the tasks that are done at HEAD and open or absent at the merge base, in ID order.
99
+ * `entries` is the live index. `taskRe` is the task regex of the scanner.
100
+ */
101
+ export function closedTasks(root, base, entries, taskRe) {
102
+ const mb = git(root, ["merge-base", "HEAD", base]);
103
+ if (!mb.ok || !mb.out)
104
+ return { ok: false, line: noMergeBaseLine(root, base) };
105
+ const before = tasksAt(root, mb.out, taskRe);
106
+ const tasks = [];
107
+ for (const [id, { state, entry }] of headStates(entries)) {
108
+ if (state.done && !before.get(id)?.done)
109
+ tasks.push(entry);
110
+ }
111
+ tasks.sort((a, b) => cmpTaskId(a.id, b.id));
112
+ return { ok: true, mergeBase: mb.out, tasks };
113
+ }
114
+ /** REQ-VER-006 compare one closed task with its evidence, in the fixed order of 036/D4. */
115
+ export function compare(verify, evidence, strict, weak) {
116
+ if (evidence !== null && evidence.kind === "manual")
117
+ return strict ? { act: "fail", kind: "manual" } : { act: "warn", why: "manual" };
118
+ if (evidence === null)
119
+ return { act: "fail", kind: "no-evidence" };
120
+ const cwd = evidence.cwd ?? ".";
121
+ if (verify.command !== evidence.command || (verify.cwd ?? ".") !== cwd) {
122
+ return { act: "fail", kind: "verify-drift", detail: `tasks.md has ${verify.command ?? "no command"}${verify.cwd ? ` (cwd ${verify.cwd})` : ""}, evidence has ${evidence.command ?? "no command"}${evidence.cwd ? ` (cwd ${evidence.cwd})` : ""}` };
123
+ }
124
+ if (evidence.command === null)
125
+ return { act: "fail", kind: "no-evidence" };
126
+ if (strict && weak(evidence.command))
127
+ return { act: "fail", kind: "weak-verify" };
128
+ return { act: "run", command: evidence.command, cwd, expected: evidence.exit };
129
+ }
130
+ /** REQ-VER-007 the key that makes two runs the same run. */
131
+ export const runKey = (v) => `${v.cwd}\u0000${v.command}`;
132
+ /** REQ-VER-007 one group per frozen command and cwd, in the ID order of the first task. */
133
+ export function groupRuns(items) {
134
+ const sorted = [...items].sort((a, b) => cmpTaskId(a.id, b.id));
135
+ const groups = new Map();
136
+ for (const it of sorted) {
137
+ const key = runKey(it);
138
+ const g = groups.get(key);
139
+ if (g)
140
+ g.members.push(it);
141
+ else
142
+ groups.set(key, { key, command: it.command, cwd: it.cwd, members: [it] });
143
+ }
144
+ return [...groups.values()];
145
+ }
146
+ /** REQ-VER-006 ok when the exit code equals the evidence. A timeout or a different code is a mismatch. */
147
+ export function judge(expected, o) {
148
+ return !o.timedOut && o.exit !== null && o.exit === expected ? "ok" : "mismatch";
149
+ }
150
+ const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
151
+ /** REQ-VER-007 the line that runs the frozen command on a developer machine as the rerun runs it. */
152
+ export function reproduceLine(command, cwd) {
153
+ return `reproduce: cd ${shq(cwd)} && env CI=1 SPECCTX_RERUN=1 sh -c ${shq(command)}`;
154
+ }
155
+ /** REQ-VER-007 the report of a mismatch. The output tail is the last 20 lines. */
156
+ export function formatMismatch(m) {
157
+ const tailLines = m.outcome.output.replace(/\n$/, "") === "" ? [] : m.outcome.output.replace(/\n$/, "").split("\n").slice(-20);
158
+ const actual = m.outcome.timedOut ? `timed out after ${m.timeoutS}s` : `exit ${m.outcome.exit}`;
159
+ return [
160
+ `${m.id}: mismatch`,
161
+ ` at ${m.file}:${m.line}`,
162
+ ` evidence ${m.evidence}`,
163
+ ` expected exit ${m.expected}`,
164
+ ` actual ${actual}`,
165
+ " output (last 20 lines):",
166
+ ...tailLines.map((l) => ` ${l}`),
167
+ ` ${reproduceLine(m.command, m.cwd)}`,
168
+ ].join("\n") + "\n";
169
+ }
@@ -44,6 +44,11 @@ function rstrip(s) {
44
44
  export function provenance(e, reason) {
45
45
  return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
46
46
  }
47
+ /** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
48
+ export function reqBlocks(root, reqs) {
49
+ const files = new Lines(root);
50
+ return reqs.map((e) => blockText(e, files)).join("\n");
51
+ }
47
52
  export function bundle(root, entries, wanted, budget, depth, all = false) {
48
53
  const ids = canon(entries);
49
54
  const blocks = [];
@@ -2,7 +2,8 @@
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { UsageExit } from "../cli/errors.js";
5
- import { changeKey } from "../index/scan.js";
5
+ import { changeKey, matchesChange } from "../index/scan.js";
6
+ import { activeFolders } from "../loop/next.js";
6
7
  import { listEvidence } from "../tasks/evidence.js";
7
8
  import { activeChanges, nextTask } from "../tasks/tasks.js";
8
9
  import { cmpStr, splitLines } from "../util/pystr.js";
@@ -144,13 +145,32 @@ function refresh(root, entries) {
144
145
  save(root, h);
145
146
  return h;
146
147
  }
147
- /** REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6). */
148
- export function ask(root, entries, text) {
149
- const q = addQuestion(root, currentTask(root), text);
148
+ /**
149
+ * REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6).
150
+ * REQ-LOOP-005 with a change, the question carries the active change folder, so next blocks the change (029/D1).
151
+ */
152
+ export function ask(root, entries, text, change, plan = false) {
153
+ let task = currentTask(root);
154
+ let stage;
155
+ if (change !== undefined) {
156
+ const folder = activeFolders(root).find((f) => matchesChange(f, change));
157
+ if (folder === undefined)
158
+ throw new UsageExit(`no active change ${change}`);
159
+ task = folder;
160
+ stage = stageOf(entries, folder, plan);
161
+ }
162
+ const q = addQuestion(root, task, text, stage);
150
163
  const h = refresh(root, entries);
151
164
  h.asked = q.n;
152
165
  return h;
153
166
  }
167
+ /** REQ-LOOP-007 archive when the change has a task and every task is [x]. Else plan with the flag and design without it. The text plays no part (041/D2). */
168
+ export function stageOf(entries, folder, plan = false) {
169
+ const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
170
+ if (tasks.length > 0 && tasks.every((t) => t.status === "done"))
171
+ return "archive";
172
+ return plan ? "plan" : "design";
173
+ }
154
174
  /** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
155
175
  export function resolve(root, entries, n, answer) {
156
176
  resolveQuestion(root, n, answer);
@@ -33,13 +33,15 @@ export function oneLine(text) {
33
33
  return text.replace(/\s*\n\s*/g, " ").trim();
34
34
  }
35
35
  /** REQ-HND-002 append an open question with the next n. Returns the new question. */
36
- export function addQuestion(root, task, text) {
36
+ export function addQuestion(root, task, text, stage) {
37
37
  const q = oneLine(text);
38
38
  if (!q)
39
39
  throw new UsageExit("handoff ask needs a question");
40
40
  const qs = readQuestions(root);
41
41
  const n = qs.reduce((m, x) => Math.max(m, x.n), 0) + 1;
42
42
  const item = { n, asked_at: isoNow(), task, text: q, status: "open" };
43
+ if (stage !== undefined)
44
+ item.stage = stage;
43
45
  qs.push(item);
44
46
  writeQuestions(root, qs);
45
47
  return item;
package/dist/ids/ids.js CHANGED
@@ -39,6 +39,10 @@ export function newIds(root, cfg, entries, o) {
39
39
  const checkCap = (cap) => {
40
40
  if (!cap)
41
41
  throw new UsageExit(`id new ${kind} needs a capability code`);
42
+ // REQ-IDS-005 a code with a number is a scenario number typed in the code slot (038/D4)
43
+ const num = /^([A-Z]{2,6})-\d+$/.exec(cap);
44
+ if (num)
45
+ throw new UsageExit(`the capability code ${cap} holds a number. ${kind === "SCN" ? `Use id new SCN ${num[1]} --for REQ-${num[1]}-<NNN>.` : `Use id new ${kind} ${num[1]}.`}`);
42
46
  if (!/^[A-Z]{2,6}$/.test(cap))
43
47
  throw new UsageExit(`the capability code ${cap} must be 2 to 6 upper case letters`);
44
48
  // REQ-IDS-001 an empty caps list accepts any code. A non-empty list must hold the code.
@@ -16,7 +16,9 @@ const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
16
16
  const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
17
17
  const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
18
18
  const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})`);
19
- const COVER_TAG = new RegExp(`Covers:${S}*(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)`);
19
+ // REQ-IDX-007 the rest of the line after Covers:, and each REQ ID in it with word boundaries (042/D4)
20
+ const COVER_TAG = /Covers:(.*)$/s;
21
+ const REQ_ID = /\bREQ-[A-Z]{2,6}-\d{3}\b/g;
20
22
  const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
21
23
  const COMMA = new RegExp(`${S}*,${S}*`);
22
24
  const SKIP_DIRS = new Set(["node_modules", ".git"]);
@@ -348,7 +350,9 @@ export function scanSource(sc, rpath, entries, scnNamed) {
348
350
  if (isTest) {
349
351
  const m = COVER_TAG.exec(line);
350
352
  if (m) {
351
- for (const rid of m[1].split(COMMA)) {
353
+ // a Covers line never counts as a code tag, with or without a REQ ID
354
+ const ids = [...new Set(m[1].match(REQ_ID) ?? [])];
355
+ for (const rid of ids) {
352
356
  entries.push(entry({ id: `${rpath}:${i}`, kind: "test", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
353
357
  }
354
358
  continue;