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/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, loadConfig } 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,69 @@ 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
  }
54
+ /**
55
+ * REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
56
+ * An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
57
+ */
58
+ export function bracketForm(text) {
59
+ return text.replace(/(### Requirement: |#### Scenario: )((?:REQ|SCN)-[^\s\]"'`]+)/g, "$1[$2]");
60
+ }
61
+ /** REQ-SCH-006 the bytes of one template in the form that `brackets` names (032/D1). */
62
+ export function templateBytes(rel, brackets) {
63
+ const b = asset(rel);
64
+ return brackets ? Buffer.from(bracketForm(b.toString("utf8"))) : b;
65
+ }
66
+ /** REQ-SCH-006 the config fragment in the form that `brackets` names (032/D1). */
67
+ export function fragmentText(brackets) {
68
+ const t = asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8");
69
+ return brackets ? bracketForm(t) : t;
70
+ }
71
+ /** REQ-SCH-006 the value of [ids] brackets, false when spec-ctx.toml is absent (032/D2). */
72
+ export function bracketsOf(root) {
73
+ return existsSync(join(root, CONFIG_FILE)) ? loadConfig(root).ids.brackets : false;
74
+ }
28
75
  /** 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))]];
76
+ export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
77
+ const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
31
78
  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)]);
79
+ // REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
80
+ if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
81
+ out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`, templateBytes(rel, brackets)]);
34
82
  }
35
83
  if (o.skills && rel.startsWith("skills/")) {
36
84
  for (const d of SKILL_DIRS)
@@ -100,32 +148,172 @@ export function repairItems(text) {
100
148
  });
101
149
  return { text: out.join("\n"), fixed };
102
150
  }
151
+ const indentOf = (line) => /^[ \t]*/.exec(line)[0].length;
152
+ /** The value of a YAML list item without its single or double quotes, so two spellings of one rule compare equal. */
153
+ function itemValue(line) {
154
+ const v = line.replace(/^\s*- /, "").trim();
155
+ const m = /^'(.*)'$/s.exec(v);
156
+ if (m)
157
+ return m[1].replace(/''/g, "'");
158
+ const d = /^"(.*)"$/s.exec(v);
159
+ return d ? d[1] : v;
160
+ }
161
+ /**
162
+ * The line range of a nested key inside `lines[start, end)`, where every line has an indent above `parent`.
163
+ * Returns the key line, the end of its range, and its indent. Null when the key is absent.
164
+ */
165
+ function findChild(lines, start, end, parent, key) {
166
+ for (let i = start; i < end; i++) {
167
+ const line = lines[i];
168
+ if (!line.trim())
169
+ continue;
170
+ const ind = indentOf(line);
171
+ if (ind <= parent)
172
+ break;
173
+ const m = new RegExp(`^[ \\t]*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}:\\s*$`).exec(line);
174
+ if (m) {
175
+ let j = i + 1;
176
+ while (j < end && (!lines[j].trim() || indentOf(lines[j]) > ind))
177
+ j++;
178
+ // trailing blank lines belong to the next block
179
+ while (j > i + 1 && !lines[j - 1].trim())
180
+ j--;
181
+ return { at: i, end: j, indent: ind };
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ /** Lists of a fragment block: path segments under the top key and the item lines of each list, in order. */
187
+ function fragmentLists(block) {
188
+ const out = [];
189
+ const stack = [];
190
+ let cur = null;
191
+ for (const line of block.split("\n").slice(1)) {
192
+ if (!line.trim())
193
+ continue;
194
+ const ind = indentOf(line);
195
+ const key = /^[ \t]*([\w-]+):\s*$/.exec(line);
196
+ if (key) {
197
+ while (stack.length && stack[stack.length - 1].indent >= ind)
198
+ stack.pop();
199
+ stack.push({ indent: ind, key: key[1] });
200
+ cur = null;
201
+ }
202
+ else if (/^[ \t]*- /.test(line)) {
203
+ if (!cur) {
204
+ cur = { path: stack.map((s) => s.key), items: [] };
205
+ out.push(cur);
206
+ }
207
+ cur.items.push(line);
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * REQ-SCH-003 merge the lists of one fragment block into the same top key of the project text, item by item (022/D4).
214
+ * An item whose value exists is skipped. A missing nested key is added with its items. Returns the count of items added.
215
+ */
216
+ export function mergeItems(lines, key, block) {
217
+ const top = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
218
+ if (top < 0)
219
+ return 0;
220
+ let topEnd = top + 1;
221
+ while (topEnd < lines.length && (!lines[topEnd].trim() || /^[ \t]/.test(lines[topEnd]) || lines[topEnd].startsWith("#")))
222
+ topEnd++;
223
+ while (topEnd > top + 1 && !lines[topEnd - 1].trim())
224
+ topEnd--;
225
+ let added = 0;
226
+ for (const list of fragmentLists(block)) {
227
+ // walk the path. A step that is absent is appended under its parent with the items that follow it.
228
+ let start = top + 1;
229
+ let end = topEnd;
230
+ let parent = 0;
231
+ let missingFrom = -1;
232
+ for (let d = 0; d < list.path.length; d++) {
233
+ const c = findChild(lines, start, end, parent, list.path[d]);
234
+ if (!c) {
235
+ missingFrom = d;
236
+ break;
237
+ }
238
+ start = c.at + 1;
239
+ end = c.end;
240
+ parent = c.indent;
241
+ }
242
+ if (missingFrom >= 0) {
243
+ // the indent of the siblings at this level, or two more than the parent
244
+ let child = parent + 2;
245
+ for (let i = start; i < end; i++)
246
+ if (lines[i].trim() && !/^[ \t]*- /.test(lines[i])) {
247
+ child = indentOf(lines[i]);
248
+ break;
249
+ }
250
+ const ins = [];
251
+ let ind = child;
252
+ for (let d = missingFrom; d < list.path.length; d++) {
253
+ ins.push(" ".repeat(ind) + list.path[d] + ":");
254
+ ind += 2;
255
+ }
256
+ for (const item of list.items)
257
+ ins.push(" ".repeat(ind) + item.trim());
258
+ lines.splice(end, 0, ...ins);
259
+ topEnd += ins.length;
260
+ added += list.items.length;
261
+ continue;
262
+ }
263
+ const have = new Set();
264
+ let last = start - 1;
265
+ let itemIndent = parent + 2;
266
+ for (let i = start; i < end; i++) {
267
+ if (/^[ \t]*- /.test(lines[i])) {
268
+ have.add(itemValue(lines[i]));
269
+ last = i;
270
+ itemIndent = indentOf(lines[i]);
271
+ }
272
+ }
273
+ const ins = list.items.filter((it) => !have.has(itemValue(it))).map((it) => " ".repeat(itemIndent) + it.trim());
274
+ if (!ins.length)
275
+ continue;
276
+ lines.splice(last + 1, 0, ...ins);
277
+ topEnd += ins.length;
278
+ added += ins.length;
279
+ }
280
+ return added;
281
+ }
282
+ /** REQ-SCH-003 true when the project config still names the schema copy that 025 removed (025/D5). */
283
+ export function hasLegacySchema(text) {
284
+ return text !== null && new RegExp(`^schema:\\s*${SCHEMA}\\s*$`, "m").test(text);
285
+ }
286
+ /** REQ-SCH-003 the note for a project that still says schema: spec-driven-ids (025/D5). Strict mode. */
287
+ 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
288
  /**
104
289
  * 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.
290
+ * A key that exists gets the fragment items it lacks (022/D4). The schema key stays as the project wrote it (025/D5).
106
291
  */
107
- export function mergeConfig(current, fragment) {
292
+ export function mergeConfig(current, fragment, other) {
108
293
  let text = current ?? "";
109
294
  const added = [];
110
295
  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
- }
296
+ const merged = [];
122
297
  const have = topKeys(text);
123
- for (const [key, block] of topBlocks(fragment)) {
298
+ const blocks = topBlocks(fragment);
299
+ // REQ-SCH-007 an item that holds the other bracket form gets the current form in place (032/D4)
300
+ const swapped = other === undefined ? { text, size: 0, get: (_k) => undefined } : swapItems(text, blocks, topBlocks(other));
301
+ if (swapped.size)
302
+ text = swapped.text;
303
+ for (const [key, block] of blocks) {
124
304
  if (have.has(key)) {
125
- kept.push(key);
305
+ const lines = text.split("\n");
306
+ const n = mergeItems(lines, key, block);
307
+ const r = swapped.get(key) ?? 0;
308
+ if (n)
309
+ text = lines.join("\n");
310
+ if (n || r)
311
+ merged.push(r ? { key, added: n, replaced: r } : { key, added: n });
312
+ else
313
+ kept.push(key);
126
314
  continue;
127
315
  }
128
- text = text.replace(/\n*$/, "\n\n") + block;
316
+ text = text.trim() ? text.replace(/\n*$/, "\n\n") + block : block;
129
317
  added.push(key);
130
318
  }
131
319
  const r = repairItems(text);
@@ -133,10 +321,50 @@ export function mergeConfig(current, fragment) {
133
321
  text = r.text;
134
322
  added.push("repaired items");
135
323
  }
136
- return { text, added, kept };
324
+ return { text, added, kept, merged };
325
+ }
326
+ /**
327
+ * REQ-SCH-007 replace each project item whose value equals an item of the other form with the line of the current form (032/D4).
328
+ * The two fragments have the same lists in the same order, so items pair by position. The indent of the project line stays.
329
+ * Returns the new text and the count per top key.
330
+ */
331
+ function swapItems(text, cur, other) {
332
+ const swap = new Map();
333
+ for (const [key, block] of cur) {
334
+ const a = fragmentLists(block);
335
+ const b = fragmentLists(other.get(key) ?? "");
336
+ a.forEach((list, i) => list.items.forEach((item, j) => {
337
+ const o = b[i]?.items[j];
338
+ if (o !== undefined && itemValue(o) !== itemValue(item))
339
+ swap.set(`${key}\0${itemValue(o)}`, item.trim());
340
+ }));
341
+ }
342
+ const counts = new Map();
343
+ if (!swap.size)
344
+ return { text, size: 0, get: (k) => counts.get(k) };
345
+ let top = null;
346
+ const lines = text.split("\n").map((line) => {
347
+ const m = TOP_KEY.exec(line);
348
+ if (m)
349
+ top = m[1];
350
+ if (!top || !/^[ \t]+- /.test(line))
351
+ return line;
352
+ const to = swap.get(`${top}\0${itemValue(line)}`);
353
+ if (to === undefined)
354
+ return line;
355
+ counts.set(top, (counts.get(top) ?? 0) + 1);
356
+ return " ".repeat(indentOf(line)) + to;
357
+ });
358
+ return { text: lines.join("\n"), size: counts.size, get: (k) => counts.get(k) };
359
+ }
360
+ /** REQ-SCH-007 the text of the `merged` count: added, replaced, or both (032/D4). */
361
+ export function mergedCount(x) {
362
+ if (!x.replaced)
363
+ return `${x.added} added`;
364
+ return x.added ? `${x.added} added, ${x.replaced} replaced` : `${x.replaced} replaced`;
137
365
  }
138
- /** REQ-DIST-002 the two lines init owns in .gitignore (017/D2). */
139
- export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md"];
366
+ /** REQ-DIST-002 the four lines init owns in .gitignore (017/D2, 022/D5). current.json and gate-log.jsonl are per session. */
367
+ export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md", ".ctx/current.json", ".ctx/gate-log.jsonl"];
140
368
  const GITIGNORE = ".gitignore";
141
369
  /** The lines of a .gitignore, trimmed, with a leading slash dropped, so `/x` and `x` compare equal. */
142
370
  function ignoreLines(text) {
@@ -144,7 +372,7 @@ function ignoreLines(text) {
144
372
  }
145
373
  /**
146
374
  * 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.
375
+ * Returns the number of lines added, 0 when all four are present.
148
376
  */
149
377
  export function writeGitignore(root) {
150
378
  const p = join(root, GITIGNORE);
@@ -170,10 +398,24 @@ export function ignoresClaude(root) {
170
398
  const lines = ignoreLines(readFileSync(p, "utf8"));
171
399
  return lines.has(".claude") || lines.has(".claude/");
172
400
  }
401
+ /** REQ-SCH-003 where the templates with the ID placeholders are. Printed after the schema step (018/D5, 025/D4). */
402
+ export const TEMPLATES_NOTE = `templates: ${TEMPLATES_DIR}/ (spec.md, tasks.md hold the ID placeholders)`;
173
403
  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
404
  export function runInit(root, o) {
175
- const res = { written: [], kept: [], changed: [], notes: [] };
176
- let files = plan(o, root);
405
+ const res = { written: [], kept: [], changed: [], merged: [], notes: [] };
406
+ // REQ-DIST-002 the detection runs only when init writes spec-ctx.toml. An existing file stays (022/D2).
407
+ let openspec = DEFAULT_OPENSPEC;
408
+ if (!existsSync(join(root, CONFIG_FILE))) {
409
+ const d = detectOpenspec();
410
+ openspec = d.command;
411
+ if (d.note)
412
+ res.notes.push(d.note);
413
+ }
414
+ // REQ-SCH-006 the form of the templates and the rules follows [ids] brackets (032/D2)
415
+ const brackets = bracketsOf(root);
416
+ const fragment = fragmentText(brackets);
417
+ const otherFragment = fragmentText(!brackets);
418
+ let files = plan(o, root, openspec, brackets);
177
419
  // REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
178
420
  // an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
179
421
  const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
@@ -181,10 +423,15 @@ export function runInit(root, o) {
181
423
  if (same && ignoreDone && !o.force) {
182
424
  const cfgPath = join(root, "openspec/config.yaml");
183
425
  const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
184
- if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
426
+ const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
427
+ if (!m || (!m.added.length && !m.merged.length)) {
185
428
  res.nothing = true;
186
429
  if (o.skills && ignoresClaude(root))
187
430
  res.notes.push(CLAUDE_NOTE);
431
+ if (o.schema && hasLegacySchema(cur))
432
+ res.notes.push(LEGACY_SCHEMA_NOTE);
433
+ if (o.schema)
434
+ res.notes.push(TEMPLATES_NOTE);
188
435
  return res;
189
436
  }
190
437
  }
@@ -194,11 +441,16 @@ export function runInit(root, o) {
194
441
  res.kept.push(CONFIG_FILE);
195
442
  }
196
443
  if (!o.force) {
197
- const differs = (rel, data) => !readFileSync(join(root, rel)).equals(data);
444
+ // REQ-SCH-007 a template that holds the other bracket form is unchanged, so it gets the current form (032/D3)
445
+ const other = new Map(plan(o, root, openspec, !brackets).filter(([rel]) => rel.startsWith(`${TEMPLATES_DIR}/`)));
446
+ const differs = (rel, data) => {
447
+ const have = readFileSync(join(root, rel));
448
+ return !have.equals(data) && !(other.get(rel)?.equals(have) ?? false);
449
+ };
198
450
  res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
199
451
  for (const rel of res.changed)
200
452
  res.kept.push(`${rel} (other content)`);
201
- files = files.filter(([rel]) => !existsSync(join(root, rel)));
453
+ files = files.filter(([rel, data]) => !existsSync(join(root, rel)) || (!readFileSync(join(root, rel)).equals(data) && !differs(rel, data)));
202
454
  }
203
455
  for (const [rel, data] of files) {
204
456
  const p = join(root, rel);
@@ -209,16 +461,23 @@ export function runInit(root, o) {
209
461
  if (o.schema) {
210
462
  const cfgPath = join(root, "openspec/config.yaml");
211
463
  const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
212
- const m = mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8"));
213
- if (m.added.length) {
464
+ const m = mergeConfig(cur, fragment, otherFragment);
465
+ if (m.added.length || m.merged.length) {
214
466
  mkdirSync(dirname(cfgPath), { recursive: true });
215
467
  writeFileSync(cfgPath, m.text);
216
- res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
468
+ if (m.added.length)
469
+ res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
217
470
  }
471
+ // REQ-SCH-003 one line per key that gained items (022/D4)
472
+ for (const x of m.merged)
473
+ res.merged.push(`openspec/config.yaml ${x.key} (${mergedCount(x)})`);
218
474
  for (const k of m.kept)
219
475
  res.kept.push(`openspec/config.yaml ${k}`);
476
+ // REQ-SCH-003 the schema line is the user's. A legacy value gets a note and stays (025/D5)
477
+ if (hasLegacySchema(cur))
478
+ res.notes.push(LEGACY_SCHEMA_NOTE);
220
479
  }
221
- // REQ-DIST-002 the two .gitignore lines (017/D2)
480
+ // REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
222
481
  const added = writeGitignore(root);
223
482
  if (added)
224
483
  res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
@@ -227,5 +486,8 @@ export function runInit(root, o) {
227
486
  // REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
228
487
  if (o.skills && ignoresClaude(root))
229
488
  res.notes.push(CLAUDE_NOTE);
489
+ // REQ-SCH-003 the templates line comes last, right before next: (018/D5)
490
+ if (o.schema)
491
+ res.notes.push(TEMPLATES_NOTE);
230
492
  return res;
231
493
  }
@@ -0,0 +1,102 @@
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
+ }
92
+ /**
93
+ * REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:` or `FAIL` after
94
+ * leading spaces, else the last non-empty line, else `no output`. The match is case sensitive.
95
+ */
96
+ export function failLine(tail) {
97
+ const lines = tail.split("\n").map((l) => l.trimEnd());
98
+ const hit = lines.filter((l) => /^\s*(?:fail:|FAIL)/.test(l)).at(-1);
99
+ if (hit !== undefined)
100
+ return hit.trim();
101
+ return lines.filter((l) => l.trim()).at(-1)?.trim() ?? "no output";
102
+ }