spec-ctx 0.1.1 → 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.
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,9 +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 },
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" },
19
24
  };
20
25
  }
21
26
  export class ConfigError extends Error {
@@ -64,6 +69,12 @@ export function loadConfig(root) {
64
69
  }
65
70
  cfg.ids.paths = { ...v };
66
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
+ }
67
78
  const bundle = sec("bundle");
68
79
  for (const k of ["budget", "depth"]) {
69
80
  if (k in bundle) {
@@ -81,6 +92,36 @@ export function loadConfig(root) {
81
92
  throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
82
93
  cfg.verify.timeout_s = v;
83
94
  }
95
+ // REQ-DIST-002 the globs that gate lints
96
+ const gate = sec("gate");
97
+ if ("markdown" in gate)
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
+ }
84
125
  return cfg;
85
126
  }
86
127
  /** The file that `specctx init` writes. Each key has a comment above it. */
@@ -106,6 +147,9 @@ ignore = []
106
147
  caps = []
107
148
  # Pattern of the task number in a tasks.md checkbox.
108
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
109
153
 
110
154
  [bundle]
111
155
  # Default character budget of a bundle.
@@ -116,4 +160,20 @@ depth = 1
116
160
  [verify]
117
161
  # Seconds before task done stops a Verify command.
118
162
  timeout_s = 600
163
+
164
+ [gate]
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"
119
179
  `;
@@ -1,12 +1,15 @@
1
1
  // REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
2
+ // With no term and a change filter, every entry of the change matches with score 0 (017/D6).
2
3
  import { poolOf } from "../index/canonical.js";
3
4
  import { matchesChange } from "../index/scan.js";
4
5
  import { cmpTuple, pySplit } from "../util/pystr.js";
5
6
  export function query(entries, args, o = {}) {
6
7
  const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
8
+ // REQ-QRY-001 the index stores kinds in lower case. The user may type the kind as the ID shows it.
9
+ const kind = o.kind?.toLowerCase();
7
10
  const hits = [];
8
11
  for (const e of poolOf(entries, !!o.all)) {
9
- if ((o.kind && e.kind !== o.kind) || (o.change && !matchesChange(e.change, o.change)))
12
+ if ((kind && e.kind !== kind) || (o.change && !matchesChange(e.change, o.change)))
10
13
  continue;
11
14
  // REQ-IDX-005 --change takes a key or a full folder name
12
15
  const idl = e.id.toLowerCase();
@@ -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
  }
@@ -0,0 +1,99 @@
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) {
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
+ qs.push(item);
44
+ writeQuestions(root, qs);
45
+ return item;
46
+ }
47
+ /** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
48
+ export function resolveQuestion(root, n, answer) {
49
+ const qs = readQuestions(root);
50
+ const item = qs.find((x) => x.n === n && x.status === "open");
51
+ if (!Number.isInteger(n) || !item) {
52
+ const open = qs.filter((x) => x.status === "open").length;
53
+ throw new UsageExit(`no open question ${n}. The file has ${open} open question(s)`);
54
+ }
55
+ const a = oneLine(answer);
56
+ if (!a)
57
+ throw new UsageExit("handoff resolve needs an answer");
58
+ item.status = "resolved";
59
+ item.answer = a;
60
+ item.resolved_at = isoNow();
61
+ writeQuestions(root, qs);
62
+ return item;
63
+ }
64
+ const ITEM = /^(.*) \(task (\S+), (\d{4}-\d{2}-\d{2})\)(?: answer: (.*))?$/s;
65
+ /**
66
+ * REQ-HND-002 the questions of an old handoff.md as objects (022/D8). Resolved items come first, then open ones,
67
+ * with n from 1 in that order. An item that does not match the old pattern keeps its whole text and task none.
68
+ */
69
+ export function fromHandoffItems(open, resolved) {
70
+ const out = [];
71
+ const push = (item, status) => {
72
+ const m = ITEM.exec(item);
73
+ const q = m
74
+ ? { n: out.length + 1, asked_at: m[3], task: m[2], text: oneLine(m[1]), status }
75
+ : { n: out.length + 1, asked_at: "none", task: "none", text: oneLine(item), status };
76
+ if (status === "resolved")
77
+ q.answer = m?.[4] !== undefined ? oneLine(m[4]) : "";
78
+ out.push(q);
79
+ };
80
+ for (const item of resolved)
81
+ push(item, "resolved");
82
+ for (const item of open)
83
+ push(item, "open");
84
+ return out;
85
+ }
86
+ /** The last 10 resolved entries render. The rest stay in the file. */
87
+ export const RESOLVED_SHOWN = 10;
88
+ /** REQ-HND-001 the two lists of handoff.md as rendered item text (022/D7). */
89
+ export function renderQuestions(qs) {
90
+ const date = (q) => q.asked_at.slice(0, 10);
91
+ const head = (q) => `Q${q.n} ${q.text} (task ${q.task}, ${date(q)})`;
92
+ const open = qs.filter((q) => q.status === "open").map(head);
93
+ const done = qs.filter((q) => q.status === "resolved");
94
+ const shown = done.slice(-RESOLVED_SHOWN).map((q) => `${head(q)} answer: ${q.answer ?? ""}`);
95
+ const more = done.length - Math.min(done.length, RESOLVED_SHOWN);
96
+ if (more > 0)
97
+ shown.push(`and ${more} more in ${QUESTIONS_PATH}`);
98
+ return { open_questions: open, resolved: shown };
99
+ }
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,10 +221,12 @@ 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
- problems.push([rpath, i, "missing-id", title]);
227
+ // REQ-CHK-001 an archived header without an ID is history. adopt never writes it.
228
+ if (status !== "archived")
229
+ problems.push([rpath, i, "missing-id", title]);
226
230
  curReq = null;
227
231
  curEntry = null;
228
232
  continue;
@@ -243,10 +247,11 @@ export function scanMarkdown(sc, rpath, entries, problems) {
243
247
  }
244
248
  m = SCN_HDR.exec(line);
245
249
  if (m) {
246
- const sid = m[1];
247
- const title = pyStrip(m[2]);
250
+ const sid = m[1] ?? m[2];
251
+ const title = pyStrip(m[3]);
248
252
  if (!sid) {
249
- problems.push([rpath, i, "missing-id", title]);
253
+ if (status !== "archived")
254
+ problems.push([rpath, i, "missing-id", title]);
250
255
  continue;
251
256
  }
252
257
  const st = curEntry ? curEntry.status : status;