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/README.md +44 -29
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +35 -9
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +50 -2
- package/dist/check/hints.js +7 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands.js +36 -12
- package/dist/cli/gate.js +51 -33
- package/dist/cli/help.js +12 -12
- package/dist/cli/status.js +5 -21
- package/dist/cli/workflow.js +50 -41
- package/dist/config.js +61 -1
- package/dist/graph/query.js +4 -1
- package/dist/handoff/handoff.js +38 -23
- package/dist/handoff/questions.js +99 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +13 -8
- package/dist/init/init.js +283 -35
- package/dist/lint/counts.js +60 -0
- package/dist/loop/fails.js +91 -0
- package/dist/loop/next.js +173 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +3 -1
- package/dist/tasks/evidence.js +5 -2
- 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,
|
|
2
|
-
import {
|
|
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";
|
|
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,12 +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
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* REQ-DIST-002 the config text for one project (017/D4). `tests/**` becomes `test/**` when only `test/`
|
|
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).
|
|
42
|
+
*/
|
|
43
|
+
export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
44
|
+
const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
|
|
45
|
+
let text = INIT_TEXT;
|
|
46
|
+
if (!isDir("tests") && isDir("test"))
|
|
47
|
+
text = text.replace('\ntests = ["tests/**"]\n', '\ntests = ["test/**"]\n');
|
|
48
|
+
if (!isDir("src") && isDir("lib"))
|
|
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`);
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
15
54
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
16
|
-
export function plan(o) {
|
|
17
|
-
const out = [[CONFIG_FILE, Buffer.from(
|
|
55
|
+
export function plan(o, root, openspec = DEFAULT_OPENSPEC) {
|
|
56
|
+
const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
|
|
18
57
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
19
|
-
|
|
20
|
-
|
|
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)]);
|
|
21
61
|
}
|
|
22
62
|
if (o.skills && rel.startsWith("skills/")) {
|
|
23
63
|
for (const d of SKILL_DIRS)
|
|
@@ -87,32 +127,166 @@ export function repairItems(text) {
|
|
|
87
127
|
});
|
|
88
128
|
return { text: out.join("\n"), fixed };
|
|
89
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.`;
|
|
90
267
|
/**
|
|
91
268
|
* REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
|
|
92
|
-
*
|
|
269
|
+
* A key that exists gets the fragment items it lacks (022/D4). The schema key stays as the project wrote it (025/D5).
|
|
93
270
|
*/
|
|
94
271
|
export function mergeConfig(current, fragment) {
|
|
95
272
|
let text = current ?? "";
|
|
96
273
|
const added = [];
|
|
97
274
|
const kept = [];
|
|
98
|
-
const
|
|
99
|
-
if (/^schema:.*$/m.test(text)) {
|
|
100
|
-
if (!new RegExp(`^${schemaLine}\\s*$`, "m").test(text)) {
|
|
101
|
-
text = text.replace(/^schema:.*$/m, schemaLine);
|
|
102
|
-
added.push("schema");
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
text = schemaLine + "\n" + (text ? "\n" + text : "");
|
|
107
|
-
added.push("schema");
|
|
108
|
-
}
|
|
275
|
+
const merged = [];
|
|
109
276
|
const have = topKeys(text);
|
|
110
277
|
for (const [key, block] of topBlocks(fragment)) {
|
|
111
278
|
if (have.has(key)) {
|
|
112
|
-
|
|
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);
|
|
113
287
|
continue;
|
|
114
288
|
}
|
|
115
|
-
text = text.replace(/\n*$/, "\n\n") + block;
|
|
289
|
+
text = text.trim() ? text.replace(/\n*$/, "\n\n") + block : block;
|
|
116
290
|
added.push(key);
|
|
117
291
|
}
|
|
118
292
|
const r = repairItems(text);
|
|
@@ -120,32 +294,87 @@ export function mergeConfig(current, fragment) {
|
|
|
120
294
|
text = r.text;
|
|
121
295
|
added.push("repaired items");
|
|
122
296
|
}
|
|
123
|
-
return { text, added, kept };
|
|
297
|
+
return { text, added, kept, merged };
|
|
124
298
|
}
|
|
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"];
|
|
301
|
+
const GITIGNORE = ".gitignore";
|
|
302
|
+
/** The lines of a .gitignore, trimmed, with a leading slash dropped, so `/x` and `x` compare equal. */
|
|
303
|
+
function ignoreLines(text) {
|
|
304
|
+
return new Set(text.split(/\r?\n/).map((l) => l.trim().replace(/^\//, "")));
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* REQ-DIST-002 add the absent lines under a `# spec-ctx` comment. Create the file when it is missing.
|
|
308
|
+
* Returns the number of lines added, 0 when all four are present.
|
|
309
|
+
*/
|
|
310
|
+
export function writeGitignore(root) {
|
|
311
|
+
const p = join(root, GITIGNORE);
|
|
312
|
+
const cur = existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
313
|
+
const have = cur === null ? new Set() : ignoreLines(cur);
|
|
314
|
+
const add = GITIGNORE_LINES.filter((l) => !have.has(l));
|
|
315
|
+
if (!add.length)
|
|
316
|
+
return 0;
|
|
317
|
+
const block = `# spec-ctx\n${add.join("\n")}\n`;
|
|
318
|
+
let text;
|
|
319
|
+
if (cur === null || cur === "")
|
|
320
|
+
text = block;
|
|
321
|
+
else
|
|
322
|
+
text = cur.replace(/\n*$/, "\n") + "\n" + block;
|
|
323
|
+
writeFileSync(p, text);
|
|
324
|
+
return add.length;
|
|
325
|
+
}
|
|
326
|
+
/** REQ-DIST-002 true when .gitignore has a line that ignores the whole .claude directory (017/D3). */
|
|
327
|
+
export function ignoresClaude(root) {
|
|
328
|
+
const p = join(root, GITIGNORE);
|
|
329
|
+
if (!existsSync(p))
|
|
330
|
+
return false;
|
|
331
|
+
const lines = ignoreLines(readFileSync(p, "utf8"));
|
|
332
|
+
return lines.has(".claude") || lines.has(".claude/");
|
|
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)`;
|
|
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.";
|
|
125
337
|
export function runInit(root, o) {
|
|
126
|
-
const res = { written: [], kept: [], notes: [] };
|
|
127
|
-
|
|
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);
|
|
128
348
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
129
|
-
|
|
130
|
-
|
|
349
|
+
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
350
|
+
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
351
|
+
const ignoreDone = existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
|
|
352
|
+
if (same && ignoreDone && !o.force) {
|
|
131
353
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
132
354
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
133
|
-
|
|
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)) {
|
|
134
357
|
res.nothing = true;
|
|
358
|
+
if (o.skills && ignoresClaude(root))
|
|
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);
|
|
135
364
|
return res;
|
|
136
365
|
}
|
|
137
366
|
}
|
|
367
|
+
// REQ-DIST-002 init never replaces spec-ctx.toml, and never fails because a file exists (014/D1)
|
|
138
368
|
if (existsSync(join(root, CONFIG_FILE))) {
|
|
139
|
-
if (!o.force)
|
|
140
|
-
throw new UsageExit(`${CONFIG_FILE} exists. Use --force to install the other files over it`);
|
|
141
|
-
// --force never replaces a config that the user wrote
|
|
142
369
|
files = files.filter(([rel]) => rel !== CONFIG_FILE);
|
|
143
370
|
res.kept.push(CONFIG_FILE);
|
|
144
371
|
}
|
|
145
372
|
if (!o.force) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
373
|
+
const differs = (rel, data) => !readFileSync(join(root, rel)).equals(data);
|
|
374
|
+
res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
|
|
375
|
+
for (const rel of res.changed)
|
|
376
|
+
res.kept.push(`${rel} (other content)`);
|
|
377
|
+
files = files.filter(([rel]) => !existsSync(join(root, rel)));
|
|
149
378
|
}
|
|
150
379
|
for (const [rel, data] of files) {
|
|
151
380
|
const p = join(root, rel);
|
|
@@ -157,13 +386,32 @@ export function runInit(root, o) {
|
|
|
157
386
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
158
387
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
159
388
|
const m = mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8"));
|
|
160
|
-
if (m.added.length) {
|
|
389
|
+
if (m.added.length || m.merged.length) {
|
|
161
390
|
mkdirSync(dirname(cfgPath), { recursive: true });
|
|
162
391
|
writeFileSync(cfgPath, m.text);
|
|
163
|
-
|
|
392
|
+
if (m.added.length)
|
|
393
|
+
res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
|
|
164
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)`);
|
|
165
398
|
for (const k of m.kept)
|
|
166
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);
|
|
167
403
|
}
|
|
404
|
+
// REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
|
|
405
|
+
const added = writeGitignore(root);
|
|
406
|
+
if (added)
|
|
407
|
+
res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
|
|
408
|
+
else
|
|
409
|
+
res.kept.push(GITIGNORE);
|
|
410
|
+
// REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
|
|
411
|
+
if (o.skills && ignoresClaude(root))
|
|
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);
|
|
168
416
|
return res;
|
|
169
417
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// REQ-ADOPT-007 hard lint findings per file and rule on the gate globs. One count per key, so the baseline survives line shifts.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { markdownFiles } from "../cli/gate.js";
|
|
5
|
+
import { lintSte } from "./ste.js";
|
|
6
|
+
import { lintTells } from "./tells.js";
|
|
7
|
+
/** Rules that the gate never counts. The repo lints by hand with the same disable. */
|
|
8
|
+
const SKIP = new Set(["synonym-rotation"]);
|
|
9
|
+
export const LINT_PREFIX = "lint ";
|
|
10
|
+
/** Key of one lint count: `lint <file> <linter>:<rule>`. */
|
|
11
|
+
export const lintKey = (file, linter, rule) => `${LINT_PREFIX}${file} ${linter}:${rule}`;
|
|
12
|
+
/** Hard findings per `lint <file> <linter>:<rule>` for the markdown globs. Files with no hard finding give no key. */
|
|
13
|
+
export function lintCounts(root, globs) {
|
|
14
|
+
const counts = new Map();
|
|
15
|
+
const files = markdownFiles(root, globs);
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
const text = readFileSync(join(root, f), "utf8").replace(/\r\n?/g, "\n");
|
|
18
|
+
for (const x of lintTells(text)) {
|
|
19
|
+
if (x.level !== "hard")
|
|
20
|
+
continue;
|
|
21
|
+
const k = lintKey(f, "tells", x.rule);
|
|
22
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
for (const x of lintSte(text, f).findings) {
|
|
25
|
+
if (x.level !== "advisory-free" || SKIP.has(x.rule))
|
|
26
|
+
continue;
|
|
27
|
+
const k = lintKey(f, "ste", x.rule);
|
|
28
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { counts, files: files.length };
|
|
32
|
+
}
|
|
33
|
+
/** Parse a baseline line `lint <file> <rule> <count>`. Returns null for any other line. */
|
|
34
|
+
export function parseLintLine(line) {
|
|
35
|
+
if (!line.startsWith(LINT_PREFIX))
|
|
36
|
+
return null;
|
|
37
|
+
const i = line.lastIndexOf(" ");
|
|
38
|
+
const n = Number(line.slice(i + 1));
|
|
39
|
+
if (i < 0 || !Number.isInteger(n) || n < 0)
|
|
40
|
+
return null;
|
|
41
|
+
return { key: line.slice(0, i), count: n };
|
|
42
|
+
}
|
|
43
|
+
export const lintLine = (key, count) => `${key} ${count}`;
|
|
44
|
+
/** REQ-CLI-004 the keys whose count rose above the baseline, worst first. A key with no line has a baseline of 0. */
|
|
45
|
+
export function lintRises(counts, baseline) {
|
|
46
|
+
const base = new Map();
|
|
47
|
+
for (const line of baseline ?? []) {
|
|
48
|
+
const p = parseLintLine(line);
|
|
49
|
+
if (p)
|
|
50
|
+
base.set(p.key, p.count);
|
|
51
|
+
}
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const [key, current] of counts) {
|
|
54
|
+
const b = base.get(key) ?? 0;
|
|
55
|
+
if (current > b)
|
|
56
|
+
out.push({ key, current, baseline: b });
|
|
57
|
+
}
|
|
58
|
+
out.sort((a, b) => b.current - b.baseline - (a.current - a.baseline) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|