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.
@@ -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,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]);
package/dist/init/init.js CHANGED
@@ -1,10 +1,14 @@
1
- // REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file, schema bundle, skills.
1
+ // REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file, ID templates and config rules, skills.
2
+ import { spawnSync } from "node:child_process";
2
3
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
4
5
  import { ASSETS } from "../assets.generated.js";
5
6
  import { UsageExit } from "../cli/errors.js";
6
- import { CONFIG_FILE, INIT_TEXT } from "../config.js";
7
+ import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
7
8
  export const SCHEMA = "spec-driven-ids";
9
+ /** REQ-SCH-003 where the templates land in a project. Upstream reads no project override for the stock templates, so this is a reference path (025/D4). */
10
+ export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
11
+ const TEMPLATES_SRC = `schemas/${SCHEMA}/templates/`;
8
12
  export const SKILL_DIRS = [".claude/skills", ".agents/skills"];
9
13
  function asset(rel) {
10
14
  const b64 = ASSETS[rel];
@@ -12,25 +16,48 @@ function asset(rel) {
12
16
  throw new Error(`asset ${rel} is missing from the package`);
13
17
  return Buffer.from(b64, "base64");
14
18
  }
19
+ /** REQ-DIST-002 the note when the OpenSpec version is unknown (022/D2). Strict mode. */
20
+ export const OPENSPEC_NOTE = "note: could not read the OpenSpec version. [gate] openspec uses @latest.";
21
+ export const DETECT_TIMEOUT_MS = 60_000;
22
+ /**
23
+ * REQ-DIST-002 the OpenSpec command that init writes into [gate] openspec (022/D2, ADR-006 item 10).
24
+ * The env var SPECCTX_OPENSPEC wins. Else `npx -y @fission-ai/openspec@latest --version` gives the pin.
25
+ * A failure, a timeout, or output without a version keeps @latest and adds the note.
26
+ */
27
+ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
28
+ const env = process.env.SPECCTX_OPENSPEC;
29
+ if (env !== undefined && env.trim())
30
+ return { command: env };
31
+ const [cmd, ...args] = DEFAULT_OPENSPEC.split(" ");
32
+ const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] });
33
+ const m = r.status === 0 && !r.error ? /\d+\.\d+\.\d+/.exec(r.stdout ?? "") : null;
34
+ if (!m)
35
+ return { command: DEFAULT_OPENSPEC, note: OPENSPEC_NOTE };
36
+ return { command: `npx -y @fission-ai/openspec@${m[0]}` };
37
+ }
15
38
  /**
16
39
  * REQ-DIST-002 the config text for one project (017/D4). `tests/**` becomes `test/**` when only `test/`
17
40
  * exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
41
+ * `openspec` replaces the default command on the `openspec = ` line (022/D2).
18
42
  */
19
- export function configText(root) {
43
+ export function configText(root, openspec = DEFAULT_OPENSPEC) {
20
44
  const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
21
45
  let text = INIT_TEXT;
22
46
  if (!isDir("tests") && isDir("test"))
23
47
  text = text.replace('\ntests = ["tests/**"]\n', '\ntests = ["test/**"]\n');
24
48
  if (!isDir("src") && isDir("lib"))
25
49
  text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
50
+ if (openspec !== DEFAULT_OPENSPEC)
51
+ text = text.replace(`\nopenspec = "${DEFAULT_OPENSPEC}"\n`, `\nopenspec = ${JSON.stringify(openspec)}\n`);
26
52
  return text;
27
53
  }
28
54
  /** Target path relative to the project root for every file that init installs, with its bytes. */
29
- export function plan(o, root) {
30
- const out = [[CONFIG_FILE, Buffer.from(configText(root))]];
55
+ export function plan(o, root, openspec = DEFAULT_OPENSPEC) {
56
+ const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
31
57
  for (const rel of Object.keys(ASSETS).sort()) {
32
- if (o.schema && rel.startsWith(`schemas/${SCHEMA}/`) && !rel.endsWith("/config-fragment.yaml")) {
33
- out.push([`openspec/${rel}`, asset(rel)]);
58
+ // REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
59
+ if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
60
+ out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`, asset(rel)]);
34
61
  }
35
62
  if (o.skills && rel.startsWith("skills/")) {
36
63
  for (const d of SKILL_DIRS)
@@ -100,32 +127,166 @@ export function repairItems(text) {
100
127
  });
101
128
  return { text: out.join("\n"), fixed };
102
129
  }
130
+ const indentOf = (line) => /^[ \t]*/.exec(line)[0].length;
131
+ /** The value of a YAML list item without its single or double quotes, so two spellings of one rule compare equal. */
132
+ function itemValue(line) {
133
+ const v = line.replace(/^\s*- /, "").trim();
134
+ const m = /^'(.*)'$/s.exec(v);
135
+ if (m)
136
+ return m[1].replace(/''/g, "'");
137
+ const d = /^"(.*)"$/s.exec(v);
138
+ return d ? d[1] : v;
139
+ }
140
+ /**
141
+ * The line range of a nested key inside `lines[start, end)`, where every line has an indent above `parent`.
142
+ * Returns the key line, the end of its range, and its indent. Null when the key is absent.
143
+ */
144
+ function findChild(lines, start, end, parent, key) {
145
+ for (let i = start; i < end; i++) {
146
+ const line = lines[i];
147
+ if (!line.trim())
148
+ continue;
149
+ const ind = indentOf(line);
150
+ if (ind <= parent)
151
+ break;
152
+ const m = new RegExp(`^[ \\t]*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}:\\s*$`).exec(line);
153
+ if (m) {
154
+ let j = i + 1;
155
+ while (j < end && (!lines[j].trim() || indentOf(lines[j]) > ind))
156
+ j++;
157
+ // trailing blank lines belong to the next block
158
+ while (j > i + 1 && !lines[j - 1].trim())
159
+ j--;
160
+ return { at: i, end: j, indent: ind };
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ /** Lists of a fragment block: path segments under the top key and the item lines of each list, in order. */
166
+ function fragmentLists(block) {
167
+ const out = [];
168
+ const stack = [];
169
+ let cur = null;
170
+ for (const line of block.split("\n").slice(1)) {
171
+ if (!line.trim())
172
+ continue;
173
+ const ind = indentOf(line);
174
+ const key = /^[ \t]*([\w-]+):\s*$/.exec(line);
175
+ if (key) {
176
+ while (stack.length && stack[stack.length - 1].indent >= ind)
177
+ stack.pop();
178
+ stack.push({ indent: ind, key: key[1] });
179
+ cur = null;
180
+ }
181
+ else if (/^[ \t]*- /.test(line)) {
182
+ if (!cur) {
183
+ cur = { path: stack.map((s) => s.key), items: [] };
184
+ out.push(cur);
185
+ }
186
+ cur.items.push(line);
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ /**
192
+ * REQ-SCH-003 merge the lists of one fragment block into the same top key of the project text, item by item (022/D4).
193
+ * An item whose value exists is skipped. A missing nested key is added with its items. Returns the count of items added.
194
+ */
195
+ export function mergeItems(lines, key, block) {
196
+ const top = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
197
+ if (top < 0)
198
+ return 0;
199
+ let topEnd = top + 1;
200
+ while (topEnd < lines.length && (!lines[topEnd].trim() || /^[ \t]/.test(lines[topEnd]) || lines[topEnd].startsWith("#")))
201
+ topEnd++;
202
+ while (topEnd > top + 1 && !lines[topEnd - 1].trim())
203
+ topEnd--;
204
+ let added = 0;
205
+ for (const list of fragmentLists(block)) {
206
+ // walk the path. A step that is absent is appended under its parent with the items that follow it.
207
+ let start = top + 1;
208
+ let end = topEnd;
209
+ let parent = 0;
210
+ let missingFrom = -1;
211
+ for (let d = 0; d < list.path.length; d++) {
212
+ const c = findChild(lines, start, end, parent, list.path[d]);
213
+ if (!c) {
214
+ missingFrom = d;
215
+ break;
216
+ }
217
+ start = c.at + 1;
218
+ end = c.end;
219
+ parent = c.indent;
220
+ }
221
+ if (missingFrom >= 0) {
222
+ // the indent of the siblings at this level, or two more than the parent
223
+ let child = parent + 2;
224
+ for (let i = start; i < end; i++)
225
+ if (lines[i].trim() && !/^[ \t]*- /.test(lines[i])) {
226
+ child = indentOf(lines[i]);
227
+ break;
228
+ }
229
+ const ins = [];
230
+ let ind = child;
231
+ for (let d = missingFrom; d < list.path.length; d++) {
232
+ ins.push(" ".repeat(ind) + list.path[d] + ":");
233
+ ind += 2;
234
+ }
235
+ for (const item of list.items)
236
+ ins.push(" ".repeat(ind) + item.trim());
237
+ lines.splice(end, 0, ...ins);
238
+ topEnd += ins.length;
239
+ added += list.items.length;
240
+ continue;
241
+ }
242
+ const have = new Set();
243
+ let last = start - 1;
244
+ let itemIndent = parent + 2;
245
+ for (let i = start; i < end; i++) {
246
+ if (/^[ \t]*- /.test(lines[i])) {
247
+ have.add(itemValue(lines[i]));
248
+ last = i;
249
+ itemIndent = indentOf(lines[i]);
250
+ }
251
+ }
252
+ const ins = list.items.filter((it) => !have.has(itemValue(it))).map((it) => " ".repeat(itemIndent) + it.trim());
253
+ if (!ins.length)
254
+ continue;
255
+ lines.splice(last + 1, 0, ...ins);
256
+ topEnd += ins.length;
257
+ added += ins.length;
258
+ }
259
+ return added;
260
+ }
261
+ /** REQ-SCH-003 true when the project config still names the schema copy that 025 removed (025/D5). */
262
+ export function hasLegacySchema(text) {
263
+ return text !== null && new RegExp(`^schema:\\s*${SCHEMA}\\s*$`, "m").test(text);
264
+ }
265
+ /** REQ-SCH-003 the note for a project that still says schema: spec-driven-ids (025/D5). Strict mode. */
266
+ export const LEGACY_SCHEMA_NOTE = `note: schema ${SCHEMA} is no longer installed. Set schema: spec-driven in openspec/config.yaml. The rules now carry the ID instructions.`;
103
267
  /**
104
268
  * REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
105
- * Keys that exist stay as they are. The schema key names the installed bundle.
269
+ * A key that exists gets the fragment items it lacks (022/D4). The schema key stays as the project wrote it (025/D5).
106
270
  */
107
271
  export function mergeConfig(current, fragment) {
108
272
  let text = current ?? "";
109
273
  const added = [];
110
274
  const kept = [];
111
- const schemaLine = `schema: ${SCHEMA}`;
112
- if (/^schema:.*$/m.test(text)) {
113
- if (!new RegExp(`^${schemaLine}\\s*$`, "m").test(text)) {
114
- text = text.replace(/^schema:.*$/m, schemaLine);
115
- added.push("schema");
116
- }
117
- }
118
- else {
119
- text = schemaLine + "\n" + (text ? "\n" + text : "");
120
- added.push("schema");
121
- }
275
+ const merged = [];
122
276
  const have = topKeys(text);
123
277
  for (const [key, block] of topBlocks(fragment)) {
124
278
  if (have.has(key)) {
125
- kept.push(key);
279
+ const lines = text.split("\n");
280
+ const n = mergeItems(lines, key, block);
281
+ if (n) {
282
+ text = lines.join("\n");
283
+ merged.push({ key, added: n });
284
+ }
285
+ else
286
+ kept.push(key);
126
287
  continue;
127
288
  }
128
- text = text.replace(/\n*$/, "\n\n") + block;
289
+ text = text.trim() ? text.replace(/\n*$/, "\n\n") + block : block;
129
290
  added.push(key);
130
291
  }
131
292
  const r = repairItems(text);
@@ -133,10 +294,10 @@ export function mergeConfig(current, fragment) {
133
294
  text = r.text;
134
295
  added.push("repaired items");
135
296
  }
136
- return { text, added, kept };
297
+ return { text, added, kept, merged };
137
298
  }
138
- /** REQ-DIST-002 the two lines init owns in .gitignore (017/D2). */
139
- export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md"];
299
+ /** REQ-DIST-002 the four lines init owns in .gitignore (017/D2, 022/D5). current.json and gate-log.jsonl are per session. */
300
+ export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md", ".ctx/current.json", ".ctx/gate-log.jsonl"];
140
301
  const GITIGNORE = ".gitignore";
141
302
  /** The lines of a .gitignore, trimmed, with a leading slash dropped, so `/x` and `x` compare equal. */
142
303
  function ignoreLines(text) {
@@ -144,7 +305,7 @@ function ignoreLines(text) {
144
305
  }
145
306
  /**
146
307
  * REQ-DIST-002 add the absent lines under a `# spec-ctx` comment. Create the file when it is missing.
147
- * Returns the number of lines added, 0 when both are present.
308
+ * Returns the number of lines added, 0 when all four are present.
148
309
  */
149
310
  export function writeGitignore(root) {
150
311
  const p = join(root, GITIGNORE);
@@ -170,10 +331,20 @@ export function ignoresClaude(root) {
170
331
  const lines = ignoreLines(readFileSync(p, "utf8"));
171
332
  return lines.has(".claude") || lines.has(".claude/");
172
333
  }
334
+ /** REQ-SCH-003 where the templates with the ID placeholders are. Printed after the schema step (018/D5, 025/D4). */
335
+ export const TEMPLATES_NOTE = `templates: ${TEMPLATES_DIR}/ (spec.md, tasks.md hold the ID placeholders)`;
173
336
  export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
174
337
  export function runInit(root, o) {
175
- const res = { written: [], kept: [], changed: [], notes: [] };
176
- let files = plan(o, root);
338
+ const res = { written: [], kept: [], changed: [], merged: [], notes: [] };
339
+ // REQ-DIST-002 the detection runs only when init writes spec-ctx.toml. An existing file stays (022/D2).
340
+ let openspec = DEFAULT_OPENSPEC;
341
+ if (!existsSync(join(root, CONFIG_FILE))) {
342
+ const d = detectOpenspec();
343
+ openspec = d.command;
344
+ if (d.note)
345
+ res.notes.push(d.note);
346
+ }
347
+ let files = plan(o, root, openspec);
177
348
  // REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
178
349
  // an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
179
350
  const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
@@ -181,10 +352,15 @@ export function runInit(root, o) {
181
352
  if (same && ignoreDone && !o.force) {
182
353
  const cfgPath = join(root, "openspec/config.yaml");
183
354
  const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
184
- if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
355
+ const m = o.schema ? mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")) : null;
356
+ if (!m || (!m.added.length && !m.merged.length)) {
185
357
  res.nothing = true;
186
358
  if (o.skills && ignoresClaude(root))
187
359
  res.notes.push(CLAUDE_NOTE);
360
+ if (o.schema && hasLegacySchema(cur))
361
+ res.notes.push(LEGACY_SCHEMA_NOTE);
362
+ if (o.schema)
363
+ res.notes.push(TEMPLATES_NOTE);
188
364
  return res;
189
365
  }
190
366
  }
@@ -210,15 +386,22 @@ export function runInit(root, o) {
210
386
  const cfgPath = join(root, "openspec/config.yaml");
211
387
  const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
212
388
  const m = mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8"));
213
- if (m.added.length) {
389
+ if (m.added.length || m.merged.length) {
214
390
  mkdirSync(dirname(cfgPath), { recursive: true });
215
391
  writeFileSync(cfgPath, m.text);
216
- res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
392
+ if (m.added.length)
393
+ res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
217
394
  }
395
+ // REQ-SCH-003 one line per key that gained items (022/D4)
396
+ for (const x of m.merged)
397
+ res.merged.push(`openspec/config.yaml ${x.key} (${x.added} added)`);
218
398
  for (const k of m.kept)
219
399
  res.kept.push(`openspec/config.yaml ${k}`);
400
+ // REQ-SCH-003 the schema line is the user's. A legacy value gets a note and stays (025/D5)
401
+ if (hasLegacySchema(cur))
402
+ res.notes.push(LEGACY_SCHEMA_NOTE);
220
403
  }
221
- // REQ-DIST-002 the two .gitignore lines (017/D2)
404
+ // REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
222
405
  const added = writeGitignore(root);
223
406
  if (added)
224
407
  res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
@@ -227,5 +410,8 @@ export function runInit(root, o) {
227
410
  // REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
228
411
  if (o.skills && ignoresClaude(root))
229
412
  res.notes.push(CLAUDE_NOTE);
413
+ // REQ-SCH-003 the templates line comes last, right before next: (018/D5)
414
+ if (o.schema)
415
+ res.notes.push(TEMPLATES_NOTE);
230
416
  return res;
231
417
  }
@@ -0,0 +1,91 @@
1
+ // REQ-LOOP-002 fail files under .ctx/evidence/<change>/<task>.fail-<n>.json and the attempt window (024/D4, 024/D5).
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { readQuestions } from "../handoff/questions.js";
5
+ import { isFailFile } from "../tasks/evidence.js";
6
+ import { evidenceRel } from "../tasks/tasks.js";
7
+ import { dumps } from "../util/json.js";
8
+ import { cmpStr } from "../util/pystr.js";
9
+ import { isoNow } from "../util/time.js";
10
+ /** Every fail file of a task, parsed, in `at` order. A damaged file is skipped. */
11
+ export function readFails(root, task) {
12
+ const rel = evidenceRel({ id: task });
13
+ const dir = dirname(join(root, rel));
14
+ const base = rel.slice(rel.lastIndexOf("/") + 1, -".json".length);
15
+ if (!existsSync(dir))
16
+ return [];
17
+ const out = [];
18
+ for (const n of readdirSync(dir)) {
19
+ if (!n.startsWith(`${base}.fail-`) || !isFailFile(n))
20
+ continue;
21
+ try {
22
+ const file = JSON.parse(readFileSync(join(dir, n), "utf8"));
23
+ if (file.task === task && typeof file.at === "string")
24
+ out.push({ rel: `${dirname(rel)}/${n}`, file });
25
+ }
26
+ catch {
27
+ // a damaged fail file counts for nothing
28
+ }
29
+ }
30
+ return out.sort((a, b) => cmpStr(a.file.at, b.file.at) || cmpStr(a.rel, b.rel));
31
+ }
32
+ /**
33
+ * The start of the attempt window of a task: `current.json.started_at` when the file names the task, else the
34
+ * start of time. A resolved question for the task moves the start to its `resolved_at`, so a resolve resets the count.
35
+ */
36
+ export function windowStart(root, task, questions) {
37
+ let start = "";
38
+ try {
39
+ const cur = JSON.parse(readFileSync(join(root, ".ctx/current.json"), "utf8"));
40
+ if (cur.task === task && typeof cur.started_at === "string")
41
+ start = cur.started_at;
42
+ }
43
+ catch {
44
+ // no current task
45
+ }
46
+ for (const q of questions ?? safeQuestions(root)) {
47
+ if (q.task === task && q.status === "resolved" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, start) > 0)
48
+ start = q.resolved_at;
49
+ }
50
+ return start;
51
+ }
52
+ function safeQuestions(root) {
53
+ try {
54
+ return readQuestions(root);
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ /** The fail files of a task inside its window, in `at` order. */
61
+ export function failsInWindow(root, task, questions) {
62
+ const start = windowStart(root, task, questions);
63
+ return readFails(root, task).filter((f) => cmpStr(f.file.at, start) >= 0);
64
+ }
65
+ /** The open question that names the task, if any. */
66
+ export function openQuestionFor(root, task, questions) {
67
+ return (questions ?? safeQuestions(root)).find((q) => q.task === task && q.status === "open");
68
+ }
69
+ /** Write the next fail file of a task. The attempt counts the window, the file number counts every fail file. */
70
+ export function writeFail(root, task, verify, tail) {
71
+ const attempt = failsInWindow(root, task).length + 1;
72
+ const rel = evidenceRel({ id: task });
73
+ const base = rel.slice(0, -".json".length);
74
+ let n = 1;
75
+ while (existsSync(join(root, `${base}.fail-${n}.json`)))
76
+ n++;
77
+ const target = `${base}.fail-${n}.json`;
78
+ const file = { at: isoNow(), attempt, stderr_tail: tail, task, verify };
79
+ mkdirSync(dirname(join(root, target)), { recursive: true });
80
+ writeFileSync(join(root, target), dumps(file) + "\n");
81
+ return { rel: target, file };
82
+ }
83
+ /** REQ-LOOP-002 the question text at the cap. Strict mode. */
84
+ export function capQuestion(task, attempts, tail) {
85
+ const first = tail.split("\n")[0]?.trim() || "no output";
86
+ return `task ${task} failed Verify ${attempts} times. Last: ${first}. What should change?`;
87
+ }
88
+ /** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
89
+ export function blockedLine(n) {
90
+ return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
91
+ }