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/README.md +46 -30
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +6 -3
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +78 -2
- package/dist/check/evidence.js +26 -14
- package/dist/check/hints.js +11 -2
- package/dist/cli/args.js +2 -2
- package/dist/cli/commands.js +50 -9
- package/dist/cli/gate.js +33 -8
- package/dist/cli/help.js +10 -9
- package/dist/cli/status.js +11 -36
- package/dist/cli/workflow.js +47 -41
- package/dist/config.js +55 -4
- package/dist/handoff/handoff.js +57 -24
- package/dist/handoff/questions.js +101 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +8 -6
- package/dist/init/init.js +298 -36
- package/dist/loop/fails.js +102 -0
- package/dist/loop/next.js +193 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +9 -4
- package/dist/tasks/evidence.js +5 -2
- package/dist/verify/parse.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schemas/spec-driven-ids/README.md +12 -12
- package/schemas/spec-driven-ids/config-fragment.yaml +3 -2
- package/skills/specctx-bootstrap/SKILL.md +1 -22
- package/skills/specctx-done/SKILL.md +1 -20
- package/skills/specctx-next/SKILL.md +1 -18
- package/skills/specctx-run/SKILL.md +37 -0
- package/schemas/spec-driven-ids/schema.yaml +0 -301
package/dist/init/init.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file,
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|