spec-ctx 0.1.2 → 0.2.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.
@@ -23,7 +23,8 @@ async function runCheck(root, cfg, data, strict) {
23
23
  */
24
24
  export function checkText(r, tty) {
25
25
  // REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
26
- const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}\n`, tty)).join("")
26
+ // REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
27
+ const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
27
28
  + r.warnings.map((w) => `warning: ${w}\n`).join("");
28
29
  const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
29
30
  const n = r.findings.length + r.warnings.length;
@@ -133,6 +134,11 @@ export async function run(p, io) {
133
134
  return runGate(root, {
134
135
  strict,
135
136
  markdown: f.markdown ?? cfg.gate.markdown.join(" "),
137
+ // REQ-DIST-002 the env var wins over [gate] openspec, for tests and one-off runs (018/D2)
138
+ openspec: process.env.SPECCTX_OPENSPEC ?? cfg.gate.openspec,
139
+ // REQ-ADOPT-008 the ref for the merge base, and the subject override for tests (020/D4)
140
+ base: cfg.gate.base,
141
+ baselineSubject: process.env.SPECCTX_BASELINE_SUBJECT,
136
142
  json,
137
143
  check: async () => {
138
144
  const { buildIndex } = await import("../index/build.js");
@@ -159,7 +165,7 @@ export async function run(p, io) {
159
165
  }
160
166
  if (cmd === "bundle")
161
167
  need(1, "at least one ID");
162
- if ((cmd === "status" || cmd === "check") && p.positional.length)
168
+ if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
163
169
  throw new UsageExit(`${cmd} takes no arguments`);
164
170
  const { data, rebuilt } = loadIndex(root, cfg);
165
171
  if (rebuilt)
@@ -181,6 +187,16 @@ export async function run(p, io) {
181
187
  io.out(statusLines(r).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
182
188
  return 0;
183
189
  }
190
+ case "next": {
191
+ // REQ-LOOP-001 the decision is a pure function of the files. --json prints the object, else the lines with next: last (024/D1).
192
+ const { decideNext, renderNext } = await import("../loop/next.js");
193
+ const d = decideNext(root, cfg, data, f.change);
194
+ if (json)
195
+ print(d);
196
+ else
197
+ io.out(renderNext(d).split("\n").map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l)).join("\n"));
198
+ return 0;
199
+ }
184
200
  case "query": {
185
201
  const { query, queryLine } = await import("../graph/query.js");
186
202
  const hits = query(entries, p.positional, {
@@ -275,6 +291,7 @@ async function init(p, io) {
275
291
  io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
276
292
  else {
277
293
  io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
294
+ io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
278
295
  io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
279
296
  io.out(r.notes.map((n) => `${n}\n`).join(""));
280
297
  // REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
package/dist/cli/gate.js CHANGED
@@ -1,10 +1,9 @@
1
- // REQ-CLI-004 specctx gate: check, both linters, openspec validate, spec length. Stops at the first failure.
1
+ // 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
4
  import { join } from "node:path";
5
5
  import { anyMatch, globRe } from "../util/glob.js";
6
6
  import { cmpStr } from "../util/pystr.js";
7
- export const DEFAULT_MARKDOWN = "openspec/**/*.md decisions/*.md docs/**/*.md *.md";
8
7
  const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
9
8
  export function markdownFiles(root, globs) {
10
9
  const res = globs.split(/\s+/).filter(Boolean).map(globRe);
@@ -42,7 +41,7 @@ export const GATE_LOG = ".ctx/gate-log.jsonl";
42
41
  /** REQ-STAT-005 the gate steps in order. The log names the step that failed. */
43
42
  /** REQ-CLI-004 the lint detail stops here unless --json asks for every line. */
44
43
  export const LINT_DETAIL_CAP = 50;
45
- export const GATE_STEPS = ["check", "lint tells", "lint ste", "validate", "spec-length"];
44
+ export const GATE_STEPS = ["check", "baseline", "lint tells", "lint ste", "validate", "spec-length"];
46
45
  /** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
47
46
  export function appendGateLog(root, line) {
48
47
  mkdirSync(join(root, ".ctx"), { recursive: true });
@@ -67,7 +66,13 @@ async function gateSteps(root, o, io, ran) {
67
66
  return "check";
68
67
  }
69
68
  io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
70
- // gate 2: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
69
+ // gate 2: REQ-ADOPT-008 the baseline may not gain entries against the merge base (020/D1 to D4)
70
+ ran.push("baseline");
71
+ const b = await baselineStep(root, o);
72
+ io.out(b.text);
73
+ if (!b.ok)
74
+ return "baseline";
75
+ // gate 3: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
71
76
  // REQ-ADOPT-007 the step fails only on a count above its baseline line.
72
77
  const { lintCounts, lintRises } = await import("../lint/counts.js");
73
78
  const { readBaseline } = await import("../baseline/baseline.js");
@@ -87,16 +92,15 @@ async function gateSteps(root, o, io, ran) {
87
92
  }
88
93
  io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
89
94
  ran.push("validate");
90
- // gate 3: openspec validate. SPECCTX_OPENSPEC replaces the command, for tests and offline use.
91
- const cmd = process.env.SPECCTX_OPENSPEC ?? "npx -y @fission-ai/openspec@latest";
92
- const v = spawnSync("/bin/sh", ["-c", `${cmd} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
95
+ // gate 4: openspec validate with the command the caller resolved (018/D2)
96
+ const v = spawnSync("/bin/sh", ["-c", `${o.openspec} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
93
97
  if (v.status !== 0) {
94
98
  io.out("validate: fail\n" + (v.stdout ?? "") + (v.stderr ?? ""));
95
99
  return "validate";
96
100
  }
97
101
  io.out(`validate: ok${o.strict ? " (strict)" : ""}\n`);
98
102
  ran.push("spec-length");
99
- // gate 4: the length of every requirement in the deltas of active changes
103
+ // gate 5: the length of every requirement in the deltas of active changes
100
104
  const long = longRequirements(root);
101
105
  if (long.length) {
102
106
  io.out(long.map((l) => `spec-length: fail ${l.id} ${l.chars} chars\n`).join(""));
@@ -105,6 +109,27 @@ async function gateSteps(root, o, io, ran) {
105
109
  io.out("spec-length: ok\n");
106
110
  return null;
107
111
  }
112
+ /** REQ-ADOPT-008 the baseline step: skipped, ok, or fail with the new entries (020/D3). */
113
+ async function baselineStep(root, o) {
114
+ const { baseAtMergeBase, headSubjects, isBaselineSubject, newEntries } = await import("../baseline/ratchet.js");
115
+ const { readBaseline } = await import("../baseline/baseline.js");
116
+ const subjects = o.baselineSubject !== undefined ? [o.baselineSubject] : headSubjects(root);
117
+ if (subjects.some(isBaselineSubject))
118
+ return { ok: true, text: "baseline: ok (baseline: commit)\n" };
119
+ const base = baseAtMergeBase(root, o.base);
120
+ if ("skip" in base)
121
+ return { ok: true, text: `baseline: skipped (${base.skip})\n` };
122
+ const current = [...readBaseline(root) ?? []];
123
+ const fresh = newEntries(current, base.lines);
124
+ if (!fresh.length)
125
+ return { ok: true, text: "baseline: ok\n" };
126
+ const shown = o.json ? fresh : fresh.slice(0, LINT_DETAIL_CAP);
127
+ const more = fresh.length - shown.length;
128
+ return {
129
+ ok: false,
130
+ text: `baseline: fail, ${fresh.length} new entr${fresh.length === 1 ? "y" : "ies"}\n` + shown.map((l) => l + "\n").join("") + (more ? `... ${more} more line(s)\n` : ""),
131
+ };
132
+ }
108
133
  // REQ-CLI-005 the limit strict validate applies to a requirement after archive
109
134
  export const MAX_REQUIREMENT_CHARS = 500;
110
135
  /** Lines inside a fenced code block, fence lines included. Same rule as OpenSpec. */
package/dist/cli/help.js CHANGED
@@ -22,6 +22,11 @@ const LINT_EXIT = [["0", "The hard findings are at the baseline or below."], ["1
22
22
  export const TABLE = [
23
23
  // Read
24
24
  { name: "status", group: "Read", args: "", summary: "Show the active changes, the current task, and the open questions.", flags: [], exit: PLAIN, example: "specctx status" },
25
+ {
26
+ name: "next", group: "Read", args: "", summary: "Name the next action of the loop from the files on disk. Write nothing.",
27
+ flags: [["--change <name>", "Decide for this change. The key or the full folder name works."]],
28
+ exit: PLAIN, example: "specctx next --json",
29
+ },
25
30
  {
26
31
  name: "query", group: "Read", args: "[term...]", summary: "Find the entries that contain all terms. With --change and no term, list every entry of that change.",
27
32
  flags: [
@@ -53,7 +58,7 @@ export const TABLE = [
53
58
  },
54
59
  {
55
60
  name: "gate", group: "Read", args: "",
56
- summary: "Run `check`, the markdown linters, `openspec validate`, and the length check. Stop at the first failure.",
61
+ summary: "Run `check`, the baseline against the merge base of [gate] base, the markdown linters, `openspec validate` with the command in [gate] openspec, and the length check. Stop at the first failure.",
57
62
  flags: [
58
63
  ["--strict", "Run `check` and `openspec validate` in strict mode."],
59
64
  ["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
@@ -119,8 +124,8 @@ export const TABLE = [
119
124
  {
120
125
  name: "init", group: "Setup", args: "", summary: "Write a default spec-ctx.toml file.",
121
126
  flags: [
122
- ["--schema", "Also install the spec-driven-ids schema and add its rules to openspec/config.yaml."],
123
- ["--skills", "Also install the three skills in .claude/skills and .agents/skills."],
127
+ ["--schema", "Also install the ID templates and add the spec-ctx rules to openspec/config.yaml."],
128
+ ["--skills", "Also install the specctx-run skill and its aliases in .claude/skills and .agents/skills."],
124
129
  ["--all", "Do all three."],
125
130
  ["--force", "Replace the files that have other content. spec-ctx.toml stays."],
126
131
  ],
@@ -132,11 +137,6 @@ export const TABLE = [
132
137
  exit: [["0", "The IDs are written, or nothing is left to adopt."], ["1", "The dry run found headers to adopt, or a new finding undid the write."], USAGE, INTERNAL],
133
138
  example: "specctx adopt --dry-run",
134
139
  },
135
- {
136
- name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task of an active change that has none.",
137
- flags: [["--manual <note>", "The note for the evidence. The flag is required."], ["--change <name>", "Look only in this change."]],
138
- exit: PLAIN, example: "specctx task backfill --manual \"done before spec-ctx\"",
139
- },
140
140
  {
141
141
  name: "baseline write", group: "Setup", args: "", summary: "Record the current debt in .ctx/baseline.json. `check` then hides it.",
142
142
  flags: [], exit: PLAIN, example: "specctx baseline write",
@@ -1,29 +1,13 @@
1
- // REQ-CLI-001 status reads the index, the handoff file, and the .openspec.yaml of each archive folder.
1
+ // REQ-CLI-001 status reads the index, the handoff file, questions.json, and the .openspec.yaml of each archive folder.
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { changeOf } from "../index/scan.js";
5
+ import { readQuestions, renderQuestions } from "../handoff/questions.js";
5
6
  import { cmpStr, pyStrip, splitLines } from "../util/pystr.js";
6
7
  export const HANDOFF_PATH = ".ctx/handoff.md";
7
- /** First line of each open_questions item in the handoff file. */
8
+ /** REQ-HND-003 the open questions as handoff.md renders them, read from .ctx/questions.json (022/D7). */
8
9
  export function openQuestions(root) {
9
- const p = join(root, HANDOFF_PATH);
10
- if (!existsSync(p))
11
- return [];
12
- const items = [];
13
- let inside = false;
14
- for (const line of splitLines(readFileSync(p).toString("utf8"))) {
15
- if (line.startsWith("open_questions:")) {
16
- inside = true;
17
- continue;
18
- }
19
- if (!inside)
20
- continue;
21
- if (line.startsWith("- "))
22
- items.push(pyStrip(line.slice(2)));
23
- else if (pyStrip(line) && !line.startsWith(" ") && !line.startsWith("\t"))
24
- break;
25
- }
26
- return items;
10
+ return renderQuestions(readQuestions(root)).open_questions;
27
11
  }
28
12
  /** REQ-HND-003 the current task from the handoff file. */
29
13
  function handoffCurrent(root) {
@@ -77,7 +61,7 @@ export function statusReport(root, entries) {
77
61
  uncovered: uncovered.length,
78
62
  handoff,
79
63
  current: handoff ? handoffCurrent(root) : null,
80
- open_questions: handoff ? openQuestions(root) : [],
64
+ open_questions: openQuestions(root),
81
65
  };
82
66
  }
83
67
  export function statusLines(r) {
@@ -1,4 +1,4 @@
1
- // REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 write commands.
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.
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";
@@ -91,7 +91,8 @@ export async function runWorkflow(c) {
91
91
  mkdirSync(join(c.root, ".ctx"), { recursive: true });
92
92
  const { cpLen } = await import("../util/pystr.js");
93
93
  // REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
94
- writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id }) + "\n");
94
+ // REQ-TASK-002 the Verify command is frozen here, so task done can refuse a changed one (019/D3)
95
+ writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id, verify: t.verify.command }) + "\n");
95
96
  // REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
96
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.`;
97
98
  if (c.json)
@@ -110,11 +111,13 @@ export async function runWorkflow(c) {
110
111
  if (!dry && manual !== undefined) {
111
112
  if (!manual.trim())
112
113
  throw new UsageExit("--manual needs a note");
114
+ // REQ-TASK-003 a task with a Verify command gets no manual note (019/D1, ADR-004)
115
+ if (t.verify.command !== null)
116
+ throw new UsageExit(`task ${t.id} has a Verify command. Run it with specctx task done ${t.id}.`);
113
117
  if (t.done && hasEvidence(c.root, t))
114
118
  throw new UsageExit(`task ${t.id} is done`);
115
- // a manual note records evidence without a run. On a task done by hand it backfills the evidence.
116
119
  const { writeEvidence } = await import("../tasks/evidence.js");
117
- const rel = writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", manual.trim());
120
+ const rel = writeEvidence(c.root, t, entries, { command: null, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", manual.trim());
118
121
  if (!t.done)
119
122
  setBox(c.root, t, true);
120
123
  clearCurrent(c.root, t.id);
@@ -124,11 +127,19 @@ export async function runWorkflow(c) {
124
127
  c.io.out(paint("ok", `done ${t.id} (manual). Evidence: ${rel}\n`, c.io.tty));
125
128
  return 0;
126
129
  }
127
- if (!dry && t.done)
130
+ // REQ-TASK-003 a ticked task with evidence is done. One without evidence gets the repair run (019/D2).
131
+ if (!dry && t.done && hasEvidence(c.root, t))
128
132
  throw new UsageExit(`task ${t.id} is done`);
129
133
  if (t.verify.command === null) {
130
134
  throw new UsageExit(`task ${t.id} has no Verify command. ${dry ? "Nothing to run" : 'A --manual "<note>" is required'}`);
131
135
  }
136
+ // REQ-TASK-003 the Verify command that task start froze must still be the one on the task line (019/D3)
137
+ if (!dry) {
138
+ const frozen = readCurrent(c.root);
139
+ 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.`);
141
+ }
142
+ }
132
143
  const { runVerify, tail } = await import("../verify/run.js");
133
144
  const { resolve: resolvePath, relative, isAbsolute } = await import("node:path");
134
145
  let dir = c.root;
@@ -153,8 +164,16 @@ export async function runWorkflow(c) {
153
164
  }
154
165
  if (r.timedOut || r.exit !== 0) {
155
166
  const msg = r.timedOut ? `verify timed out after ${c.cfg.verify.timeout_s}s` : `verify failed with exit ${r.exit}`;
167
+ // REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
168
+ const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
169
+ 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);
171
+ let question = openQuestionFor(c.root, t.id)?.n ?? null;
172
+ 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;
174
+ }
156
175
  if (c.json)
157
- print(c, { command: t.verify.command, error: msg, exit: r.exit, tail: outTail, task: t.id });
176
+ print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
158
177
  else
159
178
  c.io.out(outTail ? outTail + "\n" : "");
160
179
  // REQ-VER-003 a timeout keeps its own line on stderr
@@ -163,6 +182,9 @@ export async function runWorkflow(c) {
163
182
  // REQ-TASK-003 the refusal is the last line of the output. REQ-CLI-008 it is red on a TTY.
164
183
  else if (!c.json)
165
184
  c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
185
+ // REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
186
+ if (!c.json && question !== null)
187
+ c.io.out(blockedLine(question) + "\n");
166
188
  return 1;
167
189
  }
168
190
  const { sha256 } = await import("../util/hash.js");
@@ -181,38 +203,6 @@ export async function runWorkflow(c) {
181
203
  c.io.out(paint("ok", `done ${t.id}. Evidence: ${rel}\n`, c.io.tty)); // REQ-CLI-008
182
204
  return 0;
183
205
  }
184
- case "task backfill": {
185
- // REQ-TASK-006 manual evidence for every ticked task of an active change that has none, through the task done --manual path.
186
- // Archived changes stay untouched (017/D1).
187
- noMore(c, 0);
188
- const note = f.manual?.trim();
189
- if (!note)
190
- throw new UsageExit('task backfill needs --manual "<note>"');
191
- const { tasksOf, toTask, hasEvidence } = await import("../tasks/tasks.js");
192
- const { matchesChange } = await import("../index/scan.js");
193
- const { writeEvidence } = await import("../tasks/evidence.js");
194
- const want = f.change;
195
- const counts = new Map();
196
- for (const e of tasksOf(entries)) {
197
- if (e.status !== "done" || e.file.includes("/archive/"))
198
- continue;
199
- if (want && !matchesChange(e.change, want))
200
- continue;
201
- if (hasEvidence(c.root, e))
202
- continue;
203
- const t = toTask(c.root, e);
204
- writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", note);
205
- counts.set(e.change, (counts.get(e.change) ?? 0) + 1);
206
- }
207
- const { cmpStr } = await import("../util/pystr.js");
208
- const rows = [...counts.keys()].sort(cmpStr).map((k) => ({ change: k, count: counts.get(k) }));
209
- const total = rows.reduce((n, r) => n + r.count, 0);
210
- if (c.json)
211
- print(c, { changes: rows, total });
212
- else
213
- c.io.out(rows.map((r) => `${r.change}: ${r.count}\n`).join("") + `backfilled ${total} task(s)\n`);
214
- return 0;
215
- }
216
206
  case "task reopen": {
217
207
  const { findTask, setBox } = await import("../tasks/tasks.js");
218
208
  const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
@@ -240,8 +230,12 @@ export async function runWorkflow(c) {
240
230
  const h = writeHandoff(c.root, entries);
241
231
  if (c.json)
242
232
  print(c, h);
243
- else
233
+ else {
234
+ // REQ-HND-002 the one time move of old questions into questions.json (022/D8)
235
+ if (h.migrated)
236
+ c.io.out(`migrated ${h.migrated} question(s) to .ctx/questions.json\n`);
244
237
  c.io.out(`wrote ${HANDOFF_PATH}\n`);
238
+ }
245
239
  return 0;
246
240
  }
247
241
  case "handoff ask": {
@@ -250,7 +244,7 @@ export async function runWorkflow(c) {
250
244
  if (c.json)
251
245
  print(c, { open_questions: h.open_questions });
252
246
  else
253
- c.io.out(`open question ${h.open_questions.length} added\n`);
247
+ c.io.out(`open question ${h.asked ?? h.open_questions.length} added\n`);
254
248
  return 0;
255
249
  }
256
250
  case "handoff resolve": {
@@ -268,6 +262,18 @@ export async function runWorkflow(c) {
268
262
  }
269
263
  throw new UsageExit(`unknown command: ${c.p.command}`);
270
264
  }
265
+ /** The current task file, or null when it is absent or damaged. */
266
+ function readCurrent(root) {
267
+ const p = join(root, ".ctx/current.json");
268
+ if (!existsSync(p))
269
+ return null;
270
+ try {
271
+ return JSON.parse(readFileSync(p, "utf8"));
272
+ }
273
+ catch {
274
+ return null;
275
+ }
276
+ }
271
277
  function clearCurrent(root, task) {
272
278
  const p = join(root, ".ctx/current.json");
273
279
  if (!existsSync(p))
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "smol-toml";
5
+ /** REQ-DIST-002 the command that gate runs for openspec validate (018/D1). */
6
+ export const DEFAULT_OPENSPEC = "npx -y @fission-ai/openspec@latest";
5
7
  export const CONFIG_FILE = "spec-ctx.toml";
6
8
  export function defaults() {
7
9
  return {
@@ -13,10 +15,12 @@ export function defaults() {
13
15
  tests: ["tests/**"],
14
16
  ignore: [],
15
17
  },
16
- ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
18
+ ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {}, brackets: false },
17
19
  bundle: { budget: 8000, depth: 1 },
18
20
  verify: { timeout_s: 600 },
19
- gate: { markdown: ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"] },
21
+ // REQ-DIST-002 the gate lints nothing unless the project opts in (022/D1, ADR-006 item 9)
22
+ gate: { markdown: [], openspec: DEFAULT_OPENSPEC, base: "origin/main" },
23
+ loop: { max_attempts: 3, archive: "ask" },
20
24
  };
21
25
  }
22
26
  export class ConfigError extends Error {
@@ -65,6 +69,12 @@ export function loadConfig(root) {
65
69
  }
66
70
  cfg.ids.paths = { ...v };
67
71
  }
72
+ // REQ-ADOPT-010 the header form that adopt and id new write (027/D2)
73
+ if ("brackets" in ids) {
74
+ if (typeof ids.brackets !== "boolean")
75
+ throw new ConfigError(`${CONFIG_FILE}: ids.brackets must be true or false.`);
76
+ cfg.ids.brackets = ids.brackets;
77
+ }
68
78
  const bundle = sec("bundle");
69
79
  for (const k of ["budget", "depth"]) {
70
80
  if (k in bundle) {
@@ -86,6 +96,32 @@ export function loadConfig(root) {
86
96
  const gate = sec("gate");
87
97
  if ("markdown" in gate)
88
98
  cfg.gate.markdown = strList(gate.markdown, "gate.markdown");
99
+ // REQ-DIST-002 the OpenSpec command. An empty string would run " validate", so it is an error too (018/D1).
100
+ if ("openspec" in gate) {
101
+ if (typeof gate.openspec !== "string" || !gate.openspec.trim())
102
+ throw new ConfigError(`${CONFIG_FILE}: gate.openspec must be a string that is not empty.`);
103
+ cfg.gate.openspec = gate.openspec;
104
+ }
105
+ // REQ-ADOPT-008 the ref whose merge base with HEAD holds the baseline that gate compares with (020/D5)
106
+ if ("base" in gate) {
107
+ if (typeof gate.base !== "string" || !gate.base.trim())
108
+ throw new ConfigError(`${CONFIG_FILE}: gate.base must be a string that is not empty.`);
109
+ cfg.gate.base = gate.base;
110
+ }
111
+ // REQ-LOOP-003 the loop keys (024/D6)
112
+ const loop = sec("loop");
113
+ if ("max_attempts" in loop) {
114
+ const v = loop.max_attempts;
115
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1)
116
+ throw new ConfigError(`${CONFIG_FILE}: loop.max_attempts must be an integer of 1 or more.`);
117
+ cfg.loop.max_attempts = v;
118
+ }
119
+ if ("archive" in loop) {
120
+ const v = loop.archive;
121
+ if (v !== "ask" && v !== "auto")
122
+ throw new ConfigError(`${CONFIG_FILE}: loop.archive must be "ask" or "auto".`);
123
+ cfg.loop.archive = v;
124
+ }
89
125
  return cfg;
90
126
  }
91
127
  /** The file that `specctx init` writes. Each key has a comment above it. */
@@ -111,6 +147,9 @@ ignore = []
111
147
  caps = []
112
148
  # Pattern of the task number in a tasks.md checkbox.
113
149
  task_pattern = '\\d+\\.\\d+'
150
+ # true makes adopt and id new write the ID in square brackets, [REQ-UA-001], as OpenSpec PR #1979 reads it.
151
+ # false writes the bare form, REQ-UA-001. The scan reads both forms.
152
+ brackets = false
114
153
 
115
154
  [bundle]
116
155
  # Default character budget of a bundle.
@@ -123,6 +162,18 @@ depth = 1
123
162
  timeout_s = 600
124
163
 
125
164
  [gate]
126
- # Markdown globs that gate lints. The baseline holds the lint debt of these files.
127
- markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]
165
+ # Markdown globs that gate lints with the tells and STE linters. Empty means no lint.
166
+ # To turn it on, set markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]. The spec-ctx repository opts in.
167
+ markdown = []
168
+ # The command that gate runs for openspec validate. Set it to "openspec" for a global install or a fork,
169
+ # or pin a version. The env var SPECCTX_OPENSPEC overrides it for one run.
170
+ openspec = "npx -y @fission-ai/openspec@latest"
171
+ # The ref whose merge base with HEAD holds the baseline that gate compares with. New entries fail the gate.
172
+ base = "origin/main"
173
+
174
+ [loop]
175
+ # Verify failures on one task before task done writes a question and the loop stops.
176
+ max_attempts = 3
177
+ # "ask" stops before archive for a human. "auto" lets the loop archive when the gate passes.
178
+ archive = "ask"
128
179
  `;
@@ -6,7 +6,8 @@ import { changeKey } from "../index/scan.js";
6
6
  import { listEvidence } from "../tasks/evidence.js";
7
7
  import { activeChanges, nextTask } from "../tasks/tasks.js";
8
8
  import { cmpStr, splitLines } from "../util/pystr.js";
9
- import { isoNow, today } from "../util/time.js";
9
+ import { isoNow } from "../util/time.js";
10
+ import { addQuestion, fromHandoffItems, hasQuestions, readQuestions, renderQuestions, resolveQuestion, writeQuestions } from "./questions.js";
10
11
  export const HANDOFF_PATH = ".ctx/handoff.md";
11
12
  export const CURRENT_PATH = ".ctx/current.json";
12
13
  const SCALARS = ["change", "current", "base", "as_of"];
@@ -86,9 +87,24 @@ function evidenceRows(root, active) {
86
87
  return rows;
87
88
  }
88
89
  const AT = / \(at (\S+)\)$/;
90
+ /**
91
+ * REQ-HND-002 move the questions of an old handoff.md into questions.json once (022/D8).
92
+ * Returns the count, 0 when the file exists or the handoff has no question.
93
+ */
94
+ function migrateQuestions(root, prev) {
95
+ if (!prev || hasQuestions(root))
96
+ return 0;
97
+ if (!prev.open_questions.length && !prev.resolved.length)
98
+ return 0;
99
+ const qs = fromHandoffItems(prev.open_questions, prev.resolved);
100
+ writeQuestions(root, qs);
101
+ return qs.length;
102
+ }
89
103
  /** Generate the handoff. Two runs with no change in between write the same bytes. */
90
104
  export function writeHandoff(root, entries) {
91
105
  const prev = readHandoff(root);
106
+ const migrated = migrateQuestions(root, prev);
107
+ const lists = renderQuestions(readQuestions(root));
92
108
  const active = activeChanges(entries);
93
109
  const next = nextTask(root, entries);
94
110
  const rows = evidenceRows(root, active);
@@ -108,41 +124,40 @@ export function writeHandoff(root, entries) {
108
124
  const m = AT.exec(item.split("\n")[0]);
109
125
  return !m || after(m[1]);
110
126
  }),
111
- open_questions: prev?.open_questions ?? [],
112
- resolved: prev?.resolved ?? [],
127
+ // REQ-HND-001 both lists come from questions.json, never from the previous file (022/D7)
128
+ open_questions: lists.open_questions,
129
+ resolved: lists.resolved,
113
130
  next: next ? `${next.id} ${next.text}` : "no open task",
114
131
  evidence: `${rows.length} task(s), ${rows.filter((r) => r.kind === "command").length} command, ${rows.filter((r) => r.kind === "manual").length} manual`,
115
132
  };
116
133
  save(root, h);
134
+ if (migrated)
135
+ h.migrated = migrated;
117
136
  return h;
118
137
  }
119
- function ensure(root, entries) {
120
- return readHandoff(root) ?? writeHandoff(root, entries);
138
+ /** The handoff with its two question lists rendered again from questions.json. The other sections stay. */
139
+ function refresh(root, entries) {
140
+ const h = readHandoff(root) ?? writeHandoff(root, entries);
141
+ const lists = renderQuestions(readQuestions(root));
142
+ h.open_questions = lists.open_questions;
143
+ h.resolved = lists.resolved;
144
+ save(root, h);
145
+ return h;
121
146
  }
147
+ /** REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6). */
122
148
  export function ask(root, entries, text) {
123
- const q = text.replace(/\s*\n\s*/g, " ").trim();
124
- if (!q)
125
- throw new UsageExit("handoff ask needs a question");
126
- const h = ensure(root, entries);
127
- h.open_questions.push(`${q} (task ${currentTask(root)}, ${today()})`);
128
- save(root, h);
149
+ const q = addQuestion(root, currentTask(root), text);
150
+ const h = refresh(root, entries);
151
+ h.asked = q.n;
129
152
  return h;
130
153
  }
154
+ /** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
131
155
  export function resolve(root, entries, n, answer) {
132
- const h = ensure(root, entries);
133
- if (!Number.isInteger(n) || n < 1 || n > h.open_questions.length) {
134
- throw new UsageExit(`no open question ${n}. The handoff has ${h.open_questions.length}`);
135
- }
136
- const a = answer.replace(/\s*\n\s*/g, " ").trim();
137
- if (!a)
138
- throw new UsageExit("handoff resolve needs an answer");
139
- const [item] = h.open_questions.splice(n - 1, 1);
140
- h.resolved.push(`${item.split("\n")[0]} answer: ${a}`);
141
- save(root, h);
142
- return h;
156
+ resolveQuestion(root, n, answer);
157
+ return refresh(root, entries);
143
158
  }
144
159
  export function addReopened(root, entries, task, reason) {
145
- const h = ensure(root, entries);
160
+ const h = readHandoff(root) ?? writeHandoff(root, entries);
146
161
  h.reopened.push(`${task} ${reason.replace(/\s*\n\s*/g, " ").trim()} (at ${isoNow()})`);
147
162
  save(root, h);
148
163
  }