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.
- package/README.md +44 -30
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- 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 +19 -2
- package/dist/cli/gate.js +33 -8
- package/dist/cli/help.js +8 -8
- package/dist/cli/status.js +5 -21
- package/dist/cli/workflow.js +46 -40
- package/dist/config.js +55 -4
- 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 +8 -6
- package/dist/init/init.js +217 -31
- 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
|
@@ -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
|
-
|
|
64
|
-
|
|
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}-${
|
|
67
|
-
return seq(start, (n) => `SCN-${cap}-${
|
|
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})$/);
|
package/dist/index/scan.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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[
|
|
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[
|
|
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,
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|