spec-ctx 0.1.2 → 0.3.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/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
  `;
@@ -2,11 +2,13 @@
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";
9
- import { isoNow, today } from "../util/time.js";
10
+ import { isoNow } from "../util/time.js";
11
+ import { addQuestion, fromHandoffItems, hasQuestions, readQuestions, renderQuestions, resolveQuestion, writeQuestions } from "./questions.js";
10
12
  export const HANDOFF_PATH = ".ctx/handoff.md";
11
13
  export const CURRENT_PATH = ".ctx/current.json";
12
14
  const SCALARS = ["change", "current", "base", "as_of"];
@@ -86,9 +88,24 @@ function evidenceRows(root, active) {
86
88
  return rows;
87
89
  }
88
90
  const AT = / \(at (\S+)\)$/;
91
+ /**
92
+ * REQ-HND-002 move the questions of an old handoff.md into questions.json once (022/D8).
93
+ * Returns the count, 0 when the file exists or the handoff has no question.
94
+ */
95
+ function migrateQuestions(root, prev) {
96
+ if (!prev || hasQuestions(root))
97
+ return 0;
98
+ if (!prev.open_questions.length && !prev.resolved.length)
99
+ return 0;
100
+ const qs = fromHandoffItems(prev.open_questions, prev.resolved);
101
+ writeQuestions(root, qs);
102
+ return qs.length;
103
+ }
89
104
  /** Generate the handoff. Two runs with no change in between write the same bytes. */
90
105
  export function writeHandoff(root, entries) {
91
106
  const prev = readHandoff(root);
107
+ const migrated = migrateQuestions(root, prev);
108
+ const lists = renderQuestions(readQuestions(root));
92
109
  const active = activeChanges(entries);
93
110
  const next = nextTask(root, entries);
94
111
  const rows = evidenceRows(root, active);
@@ -108,41 +125,57 @@ export function writeHandoff(root, entries) {
108
125
  const m = AT.exec(item.split("\n")[0]);
109
126
  return !m || after(m[1]);
110
127
  }),
111
- open_questions: prev?.open_questions ?? [],
112
- resolved: prev?.resolved ?? [],
128
+ // REQ-HND-001 both lists come from questions.json, never from the previous file (022/D7)
129
+ open_questions: lists.open_questions,
130
+ resolved: lists.resolved,
113
131
  next: next ? `${next.id} ${next.text}` : "no open task",
114
132
  evidence: `${rows.length} task(s), ${rows.filter((r) => r.kind === "command").length} command, ${rows.filter((r) => r.kind === "manual").length} manual`,
115
133
  };
116
134
  save(root, h);
135
+ if (migrated)
136
+ h.migrated = migrated;
117
137
  return h;
118
138
  }
119
- function ensure(root, entries) {
120
- return readHandoff(root) ?? writeHandoff(root, entries);
121
- }
122
- 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()})`);
139
+ /** The handoff with its two question lists rendered again from questions.json. The other sections stay. */
140
+ function refresh(root, entries) {
141
+ const h = readHandoff(root) ?? writeHandoff(root, entries);
142
+ const lists = renderQuestions(readQuestions(root));
143
+ h.open_questions = lists.open_questions;
144
+ h.resolved = lists.resolved;
128
145
  save(root, h);
129
146
  return h;
130
147
  }
131
- 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}`);
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) {
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);
135
161
  }
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);
162
+ const q = addQuestion(root, task, text, stage);
163
+ const h = refresh(root, entries);
164
+ h.asked = q.n;
142
165
  return h;
143
166
  }
167
+ /** REQ-LOOP-007 archive when the change has a task and every task is [x], plan otherwise. The text plays no part (031/D1). */
168
+ export function stageOf(entries, folder) {
169
+ const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
170
+ return tasks.length > 0 && tasks.every((t) => t.status === "done") ? "archive" : "plan";
171
+ }
172
+ /** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
173
+ export function resolve(root, entries, n, answer) {
174
+ resolveQuestion(root, n, answer);
175
+ return refresh(root, entries);
176
+ }
144
177
  export function addReopened(root, entries, task, reason) {
145
- const h = ensure(root, entries);
178
+ const h = readHandoff(root) ?? writeHandoff(root, entries);
146
179
  h.reopened.push(`${task} ${reason.replace(/\s*\n\s*/g, " ").trim()} (at ${isoNow()})`);
147
180
  save(root, h);
148
181
  }
@@ -0,0 +1,101 @@
1
+ // REQ-HND-002 the open and resolved questions in .ctx/questions.json (022/D6, ADR-006 item 1).
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { UsageExit } from "../cli/errors.js";
5
+ import { isoNow } from "../util/time.js";
6
+ export const QUESTIONS_PATH = ".ctx/questions.json";
7
+ /** The questions sorted by n. An absent file is an empty list. A damaged file is a usage error, not a crash. */
8
+ export function readQuestions(root) {
9
+ const p = join(root, QUESTIONS_PATH);
10
+ if (!existsSync(p))
11
+ return [];
12
+ let raw;
13
+ try {
14
+ raw = JSON.parse(readFileSync(p, "utf8"));
15
+ }
16
+ catch {
17
+ throw new UsageExit(`${QUESTIONS_PATH} is not valid JSON`);
18
+ }
19
+ if (!Array.isArray(raw))
20
+ throw new UsageExit(`${QUESTIONS_PATH} must hold a JSON list`);
21
+ return raw.slice().sort((a, b) => a.n - b.n);
22
+ }
23
+ export function hasQuestions(root) {
24
+ return existsSync(join(root, QUESTIONS_PATH));
25
+ }
26
+ export function writeQuestions(root, qs) {
27
+ mkdirSync(join(root, ".ctx"), { recursive: true });
28
+ const sorted = qs.slice().sort((a, b) => a.n - b.n);
29
+ writeFileSync(join(root, QUESTIONS_PATH), JSON.stringify(sorted, null, 2) + "\n");
30
+ }
31
+ /** One line of text. A question or an answer never spans lines in the file. */
32
+ export function oneLine(text) {
33
+ return text.replace(/\s*\n\s*/g, " ").trim();
34
+ }
35
+ /** REQ-HND-002 append an open question with the next n. Returns the new question. */
36
+ export function addQuestion(root, task, text, stage) {
37
+ const q = oneLine(text);
38
+ if (!q)
39
+ throw new UsageExit("handoff ask needs a question");
40
+ const qs = readQuestions(root);
41
+ const n = qs.reduce((m, x) => Math.max(m, x.n), 0) + 1;
42
+ const item = { n, asked_at: isoNow(), task, text: q, status: "open" };
43
+ if (stage !== undefined)
44
+ item.stage = stage;
45
+ qs.push(item);
46
+ writeQuestions(root, qs);
47
+ return item;
48
+ }
49
+ /** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
50
+ export function resolveQuestion(root, n, answer) {
51
+ const qs = readQuestions(root);
52
+ const item = qs.find((x) => x.n === n && x.status === "open");
53
+ if (!Number.isInteger(n) || !item) {
54
+ const open = qs.filter((x) => x.status === "open").length;
55
+ throw new UsageExit(`no open question ${n}. The file has ${open} open question(s)`);
56
+ }
57
+ const a = oneLine(answer);
58
+ if (!a)
59
+ throw new UsageExit("handoff resolve needs an answer");
60
+ item.status = "resolved";
61
+ item.answer = a;
62
+ item.resolved_at = isoNow();
63
+ writeQuestions(root, qs);
64
+ return item;
65
+ }
66
+ const ITEM = /^(.*) \(task (\S+), (\d{4}-\d{2}-\d{2})\)(?: answer: (.*))?$/s;
67
+ /**
68
+ * REQ-HND-002 the questions of an old handoff.md as objects (022/D8). Resolved items come first, then open ones,
69
+ * with n from 1 in that order. An item that does not match the old pattern keeps its whole text and task none.
70
+ */
71
+ export function fromHandoffItems(open, resolved) {
72
+ const out = [];
73
+ const push = (item, status) => {
74
+ const m = ITEM.exec(item);
75
+ const q = m
76
+ ? { n: out.length + 1, asked_at: m[3], task: m[2], text: oneLine(m[1]), status }
77
+ : { n: out.length + 1, asked_at: "none", task: "none", text: oneLine(item), status };
78
+ if (status === "resolved")
79
+ q.answer = m?.[4] !== undefined ? oneLine(m[4]) : "";
80
+ out.push(q);
81
+ };
82
+ for (const item of resolved)
83
+ push(item, "resolved");
84
+ for (const item of open)
85
+ push(item, "open");
86
+ return out;
87
+ }
88
+ /** The last 10 resolved entries render. The rest stay in the file. */
89
+ export const RESOLVED_SHOWN = 10;
90
+ /** REQ-HND-001 the two lists of handoff.md as rendered item text (022/D7). */
91
+ export function renderQuestions(qs) {
92
+ const date = (q) => q.asked_at.slice(0, 10);
93
+ const head = (q) => `Q${q.n} ${q.text} (task ${q.task}, ${date(q)})`;
94
+ const open = qs.filter((q) => q.status === "open").map(head);
95
+ const done = qs.filter((q) => q.status === "resolved");
96
+ const shown = done.slice(-RESOLVED_SHOWN).map((q) => `${head(q)} answer: ${q.answer ?? ""}`);
97
+ const more = done.length - Math.min(done.length, RESOLVED_SHOWN);
98
+ if (more > 0)
99
+ shown.push(`and ${more} more in ${QUESTIONS_PATH}`);
100
+ return { open_questions: open, resolved: shown };
101
+ }
package/dist/ids/ids.js CHANGED
@@ -1,4 +1,4 @@
1
- // REQ-IDS-001 REQ-IDS-002 REQ-IDS-003 ID allocation and retirement.
1
+ // REQ-IDS-001 REQ-IDS-002 REQ-IDS-003 REQ-IDS-004 ID allocation and retirement.
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { UsageExit } from "../cli/errors.js";
@@ -48,23 +48,28 @@ export function newIds(root, cfg, entries, o) {
48
48
  return cap;
49
49
  };
50
50
  const seq = (start, fmt) => Array.from({ length: o.count }, (_, i) => fmt(start + i));
51
+ // REQ-IDS-004 a REQ or SCN ID goes into a header, so it takes the configured form (027/D5)
52
+ const form = (ids) => (cfg.ids.brackets ? ids.map((id) => `[${id}]`) : ids);
51
53
  switch (kind) {
52
54
  case "REQ": {
53
55
  const cap = checkCap(o.cap);
54
56
  const start = next(new RegExp(`^REQ-${cap}-(\\d{3})$`));
55
57
  if (start + o.count - 1 > 999)
56
58
  throw new UsageExit(`no free REQ number for ${cap}`);
57
- return seq(start, (n) => `REQ-${cap}-${pad(n)}`);
59
+ return form(seq(start, (n) => `REQ-${cap}-${pad(n)}`));
58
60
  }
59
61
  case "SCN": {
60
62
  const cap = checkCap(o.cap);
61
63
  if (!o.forReq)
62
64
  throw new UsageExit("id new SCN needs --for <REQ-ID>");
63
- const m = /^REQ-([A-Z]{2,6})-(\d{3})$/.exec(o.forReq);
64
- if (!m || m[1] !== cap)
65
+ // REQ-IDS-004 --for takes the bare or the bracketed form
66
+ const m = /^(?:REQ-([A-Z]{2,6})-(\d{3})|\[REQ-([A-Z]{2,6})-(\d{3})\])$/.exec(o.forReq);
67
+ const forCap = m?.[1] ?? m?.[3];
68
+ const forNum = m?.[2] ?? m?.[4];
69
+ if (!m || forCap !== cap)
65
70
  throw new UsageExit(`--for ${o.forReq} is not a REQ ID of ${cap}`);
66
- const start = next(new RegExp(`^SCN-${cap}-${m[2]}-(\\d+)$`));
67
- return seq(start, (n) => `SCN-${cap}-${m[2]}-${n}`);
71
+ const start = next(new RegExp(`^SCN-${cap}-${forNum}-(\\d+)$`));
72
+ return form(seq(start, (n) => `SCN-${cap}-${forNum}-${n}`));
68
73
  }
69
74
  case "ADR": {
70
75
  const start = next(/^ADR-(\d{3})$/);
@@ -8,8 +8,10 @@ const S = "[ \\t\\n\\r\\f\\v]";
8
8
  export const ID_RE = /\b(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})\b/g;
9
9
  const ID_FULL = /^(?:REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})$/;
10
10
  const SCN_RE = /\bSCN-[A-Z]{2,6}-\d{3}-\d+\b/g;
11
- const REQ_HDR = new RegExp(`^### Requirement:${S}*(REQ-[A-Z]{2,6}-\\d{3})?${S}*(.*)$`, "s");
12
- const SCN_HDR = new RegExp(`^#### Scenario:${S}*(SCN-[A-Z]{2,6}-\\d{3}-\\d+)?${S}*(.*)$`, "s");
11
+ // REQ-IDX-006 the ID is bare or in square brackets, the form OpenSpec reads since PR #1979 (027/D1).
12
+ // Group 1 is the bare ID, group 2 the bracketed ID, group 3 the title.
13
+ const REQ_HDR = new RegExp(`^### Requirement:${S}*(?:(REQ-[A-Z]{2,6}-\\d{3})|\\[(REQ-[A-Z]{2,6}-\\d{3})\\](?=${S}|$))?${S}*(.*)$`, "s");
14
+ const SCN_HDR = new RegExp(`^#### Scenario:${S}*(?:(SCN-[A-Z]{2,6}-\\d{3}-\\d+)|\\[(SCN-[A-Z]{2,6}-\\d{3}-\\d+)\\](?=${S}|$))?${S}*(.*)$`, "s");
13
15
  const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
14
16
  const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
15
17
  const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
@@ -219,8 +221,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
219
221
  }
220
222
  let m = REQ_HDR.exec(line);
221
223
  if (m) {
222
- const rid = m[1];
223
- const title = pyStrip(m[2]);
224
+ const rid = m[1] ?? m[2];
225
+ const title = pyStrip(m[3]);
224
226
  if (!rid) {
225
227
  // REQ-CHK-001 an archived header without an ID is history. adopt never writes it.
226
228
  if (status !== "archived")
@@ -245,8 +247,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
245
247
  }
246
248
  m = SCN_HDR.exec(line);
247
249
  if (m) {
248
- const sid = m[1];
249
- const title = pyStrip(m[2]);
250
+ const sid = m[1] ?? m[2];
251
+ const title = pyStrip(m[3]);
250
252
  if (!sid) {
251
253
  if (status !== "archived")
252
254
  problems.push([rpath, i, "missing-id", title]);