spec-ctx 0.0.0-stage → 0.1.1

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -2
  3. package/action.yml +33 -0
  4. package/dist/adopt/config-write.js +59 -0
  5. package/dist/adopt/plan.js +276 -0
  6. package/dist/adopt/write.js +126 -0
  7. package/dist/assets.generated.js +14 -0
  8. package/dist/baseline/baseline.js +65 -0
  9. package/dist/check/check.js +126 -0
  10. package/dist/check/evidence.js +70 -0
  11. package/dist/check/hints.js +26 -0
  12. package/dist/cli/args.js +90 -0
  13. package/dist/cli/color.js +23 -0
  14. package/dist/cli/commands.js +278 -0
  15. package/dist/cli/errors.js +3 -0
  16. package/dist/cli/gate.js +187 -0
  17. package/dist/cli/help.js +230 -0
  18. package/dist/cli/root.js +32 -0
  19. package/dist/cli/status.js +116 -0
  20. package/dist/cli/workflow.js +285 -0
  21. package/dist/cli.js +14 -0
  22. package/dist/config.js +119 -0
  23. package/dist/graph/bundle.js +94 -0
  24. package/dist/graph/query.js +31 -0
  25. package/dist/graph/related.js +76 -0
  26. package/dist/handoff/handoff.js +148 -0
  27. package/dist/ids/ids.js +115 -0
  28. package/dist/index/build.js +57 -0
  29. package/dist/index/canonical.js +44 -0
  30. package/dist/index/scan.js +357 -0
  31. package/dist/index/types.js +2 -0
  32. package/dist/init/init.js +169 -0
  33. package/dist/lint/cli.js +253 -0
  34. package/dist/lint/py.js +152 -0
  35. package/dist/lint/selftest.js +246 -0
  36. package/dist/lint/ste.js +395 -0
  37. package/dist/lint/tells.js +116 -0
  38. package/dist/main.js +74 -0
  39. package/dist/stats/stats.js +147 -0
  40. package/dist/stats/write.js +26 -0
  41. package/dist/tasks/evidence.js +102 -0
  42. package/dist/tasks/tasks.js +105 -0
  43. package/dist/util/glob.js +43 -0
  44. package/dist/util/hash.js +14 -0
  45. package/dist/util/json.js +16 -0
  46. package/dist/util/pystr.js +67 -0
  47. package/dist/util/time.js +11 -0
  48. package/dist/verify/parse.js +58 -0
  49. package/dist/verify/run.js +42 -0
  50. package/dist/version.js +2 -0
  51. package/package.json +50 -4
  52. package/schemas/spec-driven-ids/README.md +31 -0
  53. package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
  54. package/schemas/spec-driven-ids/schema.yaml +301 -0
  55. package/schemas/spec-driven-ids/templates/design.md +22 -0
  56. package/schemas/spec-driven-ids/templates/proposal.md +34 -0
  57. package/schemas/spec-driven-ids/templates/spec.md +15 -0
  58. package/schemas/spec-driven-ids/templates/tasks.md +10 -0
  59. package/skills/specctx-bootstrap/SKILL.md +27 -0
  60. package/skills/specctx-done/SKILL.md +25 -0
  61. package/skills/specctx-next/SKILL.md +23 -0
@@ -0,0 +1,357 @@
1
+ // REQ-IDX-001 scan of specs, changes, decisions, code, and tests.
2
+ import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { anyMatch, globBase, globRe } from "../util/glob.js";
5
+ import { cmpStr, pyStrip, splitLines, squash } from "../util/pystr.js";
6
+ // Every pattern is ASCII only. `[ \t\n\r\f\v]` is the ASCII whitespace class of the Python oracle.
7
+ const S = "[ \\t\\n\\r\\f\\v]";
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
+ const ID_FULL = /^(?:REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})$/;
10
+ const SCN_RE = /\bSCN-[A-Z]{2,6}-\d{3}-\d+\b/g;
11
+ const REQ_HDR = new RegExp(`^### Requirement:${S}*(REQ-[A-Z]{2,6}-\\d{3})?${S}*(.*)$`, "s");
12
+ const SCN_HDR = new RegExp(`^#### Scenario:${S}*(SCN-[A-Z]{2,6}-\\d{3}-\\d+)?${S}*(.*)$`, "s");
13
+ const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
14
+ const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
15
+ const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
16
+ const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})`);
17
+ const COVER_TAG = new RegExp(`Covers:${S}*(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)`);
18
+ const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
19
+ const COMMA = new RegExp(`${S}*,${S}*`);
20
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
21
+ export function scanner(root, cfg) {
22
+ const s = cfg.scan;
23
+ const all = [...s.specs, ...s.changes, ...s.decisions, ...s.code, ...s.tests];
24
+ return {
25
+ root,
26
+ md: [...s.specs, ...s.changes, ...s.decisions].map(globRe),
27
+ src: [...s.code, ...s.tests].map(globRe),
28
+ tests: s.tests.map(globRe),
29
+ ignore: s.ignore.map(globRe),
30
+ bases: [...new Set(all.map(globBase))].sort(cmpStr),
31
+ taskRe: new RegExp(`^${S}*- \\[([ xX]?)\\]${S}*(${cfg.ids.task_pattern})${S}+(.*)$`, "s"),
32
+ };
33
+ }
34
+ function isDir(p) {
35
+ try {
36
+ return statSync(p).isDirectory();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** Files that match the scan globs, sorted by path. The walk reads no file contents. */
43
+ export function walk(sc) {
44
+ const seen = new Map();
45
+ const visit = (abs, rel) => {
46
+ let names;
47
+ try {
48
+ names = readdirSync(abs).sort(cmpStr);
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ const pre = rel === "" ? "" : rel + "/";
54
+ const dirs = [];
55
+ for (const name of names) {
56
+ const p = join(abs, name);
57
+ let dir;
58
+ let link = false;
59
+ try {
60
+ const st = lstatSync(p);
61
+ link = st.isSymbolicLink();
62
+ dir = link ? isDir(p) : st.isDirectory();
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (dir) {
68
+ // a link to a directory is listed but not followed, as os.walk does
69
+ if (!link && !SKIP_DIRS.has(name) && !anyMatch(sc.ignore, `${pre}${name}/`))
70
+ dirs.push(name);
71
+ continue;
72
+ }
73
+ const path = pre + name;
74
+ if (seen.has(path) || anyMatch(sc.ignore, path))
75
+ continue;
76
+ if (anyMatch(sc.md, path))
77
+ seen.set(path, "md");
78
+ else if (anyMatch(sc.src, path))
79
+ seen.set(path, "src");
80
+ }
81
+ for (const d of dirs)
82
+ visit(join(abs, d), pre + d);
83
+ };
84
+ for (const base of sc.bases) {
85
+ const top = base ? join(sc.root, base) : sc.root;
86
+ if (isDir(top))
87
+ visit(top, base);
88
+ }
89
+ return [...seen.keys()].sort(cmpStr).map((path) => ({ path, kind: seen.get(path) }));
90
+ }
91
+ /** REQ-IDX-004 count and newest mtime in integer milliseconds, from stat calls only. */
92
+ export function scannedOf(root, files) {
93
+ let newest = 0;
94
+ for (const f of files) {
95
+ try {
96
+ const ms = Number(statSync(join(root, f.path), { bigint: true }).mtimeNs / 1000000n);
97
+ if (ms > newest)
98
+ newest = ms;
99
+ }
100
+ catch {
101
+ // a file removed during the walk does not count
102
+ }
103
+ }
104
+ return { count: files.length, newest_mtime_ms: newest };
105
+ }
106
+ export function changeOf(path) {
107
+ const m = /^openspec\/changes\/(archive\/)?([^/]+)\//.exec(path);
108
+ if (!m)
109
+ return "main";
110
+ let name = m[2];
111
+ // openspec archive prefixes the folder with a date: 2026-10-23-001-foundation
112
+ if (m[1])
113
+ name = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
114
+ return name;
115
+ }
116
+ /**
117
+ * REQ-IDX-005 the change key: the leading digits of a folder like `003-distribution`,
118
+ * else the full folder name. Task and decision IDs use the key (ADR-002, 008/D1).
119
+ */
120
+ export function changeKey(change) {
121
+ const m = /^(\d+)-/.exec(change);
122
+ return m ? m[1] : change;
123
+ }
124
+ /** REQ-IDX-005 a key or a full folder name names the same change. */
125
+ export function matchesChange(folder, input) {
126
+ return folder === input || changeKey(folder) === input;
127
+ }
128
+ /**
129
+ * REQ-IDX-005 resolve a key or a full folder name to the change folder.
130
+ * An exact folder name wins. Unknown input returns undefined.
131
+ */
132
+ export function resolveChange(entries, input) {
133
+ const folders = [...new Set(entries.map((e) => e.change).filter((c) => c !== "main"))];
134
+ if (folders.includes(input))
135
+ return input;
136
+ return folders.find((f) => changeKey(f) === input);
137
+ }
138
+ /** REQ-IDX-005 a task or decision ID may name its change by the full folder name: `003-distribution/3.3` is `003/3.3`. */
139
+ export function resolveTaskId(entries, id) {
140
+ const i = id.indexOf("/");
141
+ if (i < 0)
142
+ return id;
143
+ const folder = resolveChange(entries, id.slice(0, i));
144
+ return folder === undefined ? id : `${changeKey(folder)}${id.slice(i)}`;
145
+ }
146
+ export function readText(abs) {
147
+ return readFileSync(abs).toString("utf8");
148
+ }
149
+ function fenceToggle(line) {
150
+ const t = pyStrip(line);
151
+ return t.startsWith("```") || t.startsWith("~~~");
152
+ }
153
+ /** Line range of a markdown block from its header to the next header of the same or a higher level. */
154
+ export function blockRange(lines, start) {
155
+ const hdr = lines[start];
156
+ const level = hdr.startsWith("#") ? hdr.length - hdr.replace(/^#+/, "").length : 6;
157
+ let end = start + 1;
158
+ let inFence = false;
159
+ while (end < lines.length) {
160
+ const line = lines[end];
161
+ if (fenceToggle(line))
162
+ inFence = !inFence;
163
+ else if (!inFence && line.startsWith("#")) {
164
+ const lvl = line.length - line.replace(/^#+/, "").length;
165
+ if (lvl <= level)
166
+ break;
167
+ }
168
+ end++;
169
+ }
170
+ return [start, end];
171
+ }
172
+ function entry(e) {
173
+ return { text: "", canonical: false, ...e };
174
+ }
175
+ export function scanMarkdown(sc, rpath, entries, problems) {
176
+ const lines = splitLines(readText(join(sc.root, rpath)));
177
+ const change = changeOf(rpath);
178
+ const status = rpath.includes("/archive/") ? "archived" : "active";
179
+ const isDesign = rpath.endsWith("design.md");
180
+ const isTasks = rpath.endsWith("tasks.md");
181
+ let curReq = null;
182
+ let curEntry = null;
183
+ let inFence = false;
184
+ let section = "";
185
+ let decision = null;
186
+ const decisions = [];
187
+ const blocks = [];
188
+ for (let n = 0; n < lines.length; n++) {
189
+ const line = lines[n];
190
+ const i = n + 1;
191
+ if (fenceToggle(line)) {
192
+ inFence = !inFence;
193
+ continue;
194
+ }
195
+ if (inFence)
196
+ continue;
197
+ if (decision !== null) {
198
+ // a decision bullet runs until a blank line, the next bullet, or a header
199
+ if (pyStrip(line) && !line.startsWith("- ") && !line.startsWith("#")) {
200
+ decision._lines.push(pyStrip(line));
201
+ continue;
202
+ }
203
+ decision = null;
204
+ }
205
+ if (line.startsWith("## ")) {
206
+ // delta section of an active change: ADDED, MODIFIED, REMOVED, RENAMED. Also Decisions in design.md.
207
+ section = pyStrip(line.slice(3)).split(" ")[0].toUpperCase();
208
+ continue;
209
+ }
210
+ if (isDesign && section === "DECISIONS" && line.startsWith("- ")) {
211
+ decision = entry({
212
+ id: `${changeKey(change)}/D${decisions.length + 1}`, kind: "decision", file: rpath, line: i,
213
+ title: "", change, status, refs: [],
214
+ });
215
+ decision._lines = [pyStrip(line.slice(2))];
216
+ entries.push(decision);
217
+ decisions.push(decision);
218
+ continue;
219
+ }
220
+ let m = REQ_HDR.exec(line);
221
+ if (m) {
222
+ const rid = m[1];
223
+ const title = pyStrip(m[2]);
224
+ if (!rid) {
225
+ problems.push([rpath, i, "missing-id", title]);
226
+ curReq = null;
227
+ curEntry = null;
228
+ continue;
229
+ }
230
+ curReq = rid;
231
+ let st = status;
232
+ if (status === "active" && change !== "main") {
233
+ // REQ-IDX-003 a MODIFIED block in an active change is the pending version of a main-spec ID
234
+ if (section === "MODIFIED")
235
+ st = "modified";
236
+ else if (section === "REMOVED")
237
+ st = "removed";
238
+ }
239
+ curEntry = entry({ id: rid, kind: "req", file: rpath, line: i, title, change, status: st, refs: [], scenarios: [] });
240
+ entries.push(curEntry);
241
+ blocks.push(curEntry);
242
+ continue;
243
+ }
244
+ m = SCN_HDR.exec(line);
245
+ if (m) {
246
+ const sid = m[1];
247
+ const title = pyStrip(m[2]);
248
+ if (!sid) {
249
+ problems.push([rpath, i, "missing-id", title]);
250
+ continue;
251
+ }
252
+ const st = curEntry ? curEntry.status : status;
253
+ const e = entry({ id: sid, kind: "scn", file: rpath, line: i, title, change, status: st, refs: [] });
254
+ if (curReq && sid.startsWith("SCN-" + curReq.slice(4))) {
255
+ curEntry.scenarios.push(sid);
256
+ e.refs.push(curReq);
257
+ }
258
+ else if (curReq) {
259
+ problems.push([rpath, i, "scenario-id-mismatch", `${sid} under ${curReq}`]);
260
+ }
261
+ entries.push(e);
262
+ blocks.push(e);
263
+ continue;
264
+ }
265
+ m = ADR_HDR.exec(line);
266
+ if (m && i <= 3) {
267
+ curEntry = entry({ id: m[1], kind: "adr", file: rpath, line: i, title: pyStrip(m[2]), change: "main", status: "active", refs: [] });
268
+ entries.push(curEntry);
269
+ blocks.push(curEntry);
270
+ continue;
271
+ }
272
+ m = REFS_RE.exec(pyStrip(line));
273
+ if (m && curEntry !== null) {
274
+ for (const ref of pyStrip(m[1]).split(COMMA)) {
275
+ if (ID_FULL.test(ref))
276
+ curEntry.refs.push(ref);
277
+ else
278
+ problems.push([rpath, i, "bad-ref", ref]);
279
+ }
280
+ continue;
281
+ }
282
+ m = sc.taskRe.exec(line);
283
+ if (m && isTasks) {
284
+ const box = m[1];
285
+ const num = m[2];
286
+ const body = m[m.length - 1];
287
+ const tid = `${changeKey(change)}/${num}`;
288
+ const reqs = [];
289
+ for (const g of body.matchAll(TASK_REQ))
290
+ reqs.push(...g[1].split(COMMA));
291
+ if (reqs.length === 0)
292
+ problems.push([rpath, i, "task-without-req", tid]);
293
+ if (!body.includes("Verify:"))
294
+ problems.push([rpath, i, "task-without-verify", tid]);
295
+ // the full task body is the title, so query output shows the Verify clause
296
+ entries.push(entry({
297
+ id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
298
+ status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
299
+ }));
300
+ }
301
+ }
302
+ // REQ-IDX-002 text holds the lower-cased body of each block
303
+ for (const e of blocks) {
304
+ // text is empty for adr and task entries
305
+ if (e.kind === "adr")
306
+ continue;
307
+ const [s, end] = blockRange(lines, e.line - 1);
308
+ e.text = squash(lines.slice(s + 1, end));
309
+ }
310
+ for (const e of decisions) {
311
+ const body = e._lines.join(" ");
312
+ delete e._lines;
313
+ e.title = body.split(SENTENCE_END)[0];
314
+ e.text = squash([body]);
315
+ if (body.includes("Refs:")) {
316
+ const after = body.slice(body.indexOf("Refs:") + 5);
317
+ e.refs = [...after.matchAll(ID_RE)].map((x) => x[1]);
318
+ }
319
+ }
320
+ }
321
+ export function isTestPath(sc, rpath) {
322
+ const name = rpath.slice(rpath.lastIndexOf("/") + 1);
323
+ return anyMatch(sc.tests, rpath) || rpath.includes("/tests/") || rpath.startsWith("tests/")
324
+ || rpath.includes(".test.") || name.includes("test_");
325
+ }
326
+ export function scanSource(sc, rpath, entries, scnNamed) {
327
+ const isTest = isTestPath(sc, rpath);
328
+ let content;
329
+ try {
330
+ content = readText(join(sc.root, rpath));
331
+ }
332
+ catch {
333
+ return;
334
+ }
335
+ // REQ-CHK-002 a scenario is tested when a test file names it
336
+ if (isTest)
337
+ for (const m of content.matchAll(SCN_RE))
338
+ scnNamed.add(m[0]);
339
+ const lines = splitLines(content);
340
+ for (let n = 0; n < lines.length; n++) {
341
+ const line = lines[n];
342
+ const i = n + 1;
343
+ if (isTest) {
344
+ const m = COVER_TAG.exec(line);
345
+ if (m) {
346
+ for (const rid of m[1].split(COMMA)) {
347
+ entries.push(entry({ id: `${rpath}:${i}`, kind: "test", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
348
+ }
349
+ continue;
350
+ }
351
+ }
352
+ const m = CODE_TAG.exec(line);
353
+ if (m && !line.includes("Covers:")) {
354
+ entries.push(entry({ id: `${rpath}:${i}`, kind: "code", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [m[1]] }));
355
+ }
356
+ }
357
+ }
@@ -0,0 +1,2 @@
1
+ // REQ-IDX-002 record shape of .ctx/index.json.
2
+ export {};
@@ -0,0 +1,169 @@
1
+ // REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file, schema bundle, skills.
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { ASSETS } from "../assets.generated.js";
5
+ import { UsageExit } from "../cli/errors.js";
6
+ import { CONFIG_FILE, INIT_TEXT } from "../config.js";
7
+ export const SCHEMA = "spec-driven-ids";
8
+ export const SKILL_DIRS = [".claude/skills", ".agents/skills"];
9
+ function asset(rel) {
10
+ const b64 = ASSETS[rel];
11
+ if (b64 === undefined)
12
+ throw new Error(`asset ${rel} is missing from the package`);
13
+ return Buffer.from(b64, "base64");
14
+ }
15
+ /** 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(INIT_TEXT)]];
18
+ for (const rel of Object.keys(ASSETS).sort()) {
19
+ if (o.schema && rel.startsWith(`schemas/${SCHEMA}/`) && !rel.endsWith("/config-fragment.yaml")) {
20
+ out.push([`openspec/${rel}`, asset(rel)]);
21
+ }
22
+ if (o.skills && rel.startsWith("skills/")) {
23
+ for (const d of SKILL_DIRS)
24
+ out.push([`${d}/${rel.slice("skills/".length)}`, asset(rel)]);
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ const TOP_KEY = /^([A-Za-z_][\w-]*):/;
30
+ function topKeys(text) {
31
+ const keys = new Set();
32
+ for (const line of text.split("\n")) {
33
+ const m = TOP_KEY.exec(line);
34
+ if (m)
35
+ keys.add(m[1]);
36
+ }
37
+ return keys;
38
+ }
39
+ /** Top-level blocks of a YAML file, keyed by name, as raw text. Comment lines before a block stay out. */
40
+ function topBlocks(text) {
41
+ const blocks = new Map();
42
+ let key = null;
43
+ let buf = [];
44
+ const flush = () => {
45
+ if (key)
46
+ blocks.set(key, buf.join("\n").replace(/\n+$/, "") + "\n");
47
+ };
48
+ for (const line of text.split("\n")) {
49
+ const m = TOP_KEY.exec(line);
50
+ if (m) {
51
+ flush();
52
+ key = m[1];
53
+ buf = [line];
54
+ }
55
+ else if (key && (line.startsWith(" ") || line.startsWith("\t") || line.trim() === ""))
56
+ buf.push(line);
57
+ else if (key && line.startsWith("#"))
58
+ continue;
59
+ else if (key) {
60
+ flush();
61
+ key = null;
62
+ }
63
+ }
64
+ flush();
65
+ return blocks;
66
+ }
67
+ /**
68
+ * REQ-SCH-003 quote list items under `rules` and `operations` that hold `: ` and are not quoted.
69
+ * YAML reads such an item as a one key mapping. Every other line stays byte for byte.
70
+ */
71
+ export function repairItems(text) {
72
+ let inBlock = false;
73
+ let fixed = 0;
74
+ const out = text.split("\n").map((line) => {
75
+ const top = TOP_KEY.exec(line);
76
+ if (top) {
77
+ inBlock = top[1] === "rules" || top[1] === "operations";
78
+ return line;
79
+ }
80
+ if (!inBlock)
81
+ return line;
82
+ const m = /^(\s+- )(.*?)(\s*)$/.exec(line);
83
+ if (!m || /^['"]/.test(m[2]) || !/:(\s|$)/.test(m[2]))
84
+ return line;
85
+ fixed++;
86
+ return `${m[1]}'${m[2].replace(/'/g, "''")}'${m[3]}`;
87
+ });
88
+ return { text: out.join("\n"), fixed };
89
+ }
90
+ /**
91
+ * REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
92
+ * Keys that exist stay as they are. The schema key names the installed bundle.
93
+ */
94
+ export function mergeConfig(current, fragment) {
95
+ let text = current ?? "";
96
+ const added = [];
97
+ const kept = [];
98
+ const schemaLine = `schema: ${SCHEMA}`;
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
+ }
109
+ const have = topKeys(text);
110
+ for (const [key, block] of topBlocks(fragment)) {
111
+ if (have.has(key)) {
112
+ kept.push(key);
113
+ continue;
114
+ }
115
+ text = text.replace(/\n*$/, "\n\n") + block;
116
+ added.push(key);
117
+ }
118
+ const r = repairItems(text);
119
+ if (r.fixed) {
120
+ text = r.text;
121
+ added.push("repaired items");
122
+ }
123
+ return { text, added, kept };
124
+ }
125
+ export function runInit(root, o) {
126
+ const res = { written: [], kept: [], notes: [] };
127
+ let files = plan(o);
128
+ // REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
129
+ const same = files.every(([rel, data]) => existsSync(join(root, rel)) && readFileSync(join(root, rel)).equals(data));
130
+ if (same && !o.force) {
131
+ const cfgPath = join(root, "openspec/config.yaml");
132
+ const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
133
+ if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
134
+ res.nothing = true;
135
+ return res;
136
+ }
137
+ }
138
+ 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
+ files = files.filter(([rel]) => rel !== CONFIG_FILE);
143
+ res.kept.push(CONFIG_FILE);
144
+ }
145
+ if (!o.force) {
146
+ const clash = files.filter(([rel, data]) => existsSync(join(root, rel)) && !readFileSync(join(root, rel)).equals(data));
147
+ if (clash.length)
148
+ throw new UsageExit(`these files exist with other content: ${clash.map(([r]) => r).join(", ")}. Use --force to replace them`);
149
+ }
150
+ for (const [rel, data] of files) {
151
+ const p = join(root, rel);
152
+ mkdirSync(dirname(p), { recursive: true });
153
+ writeFileSync(p, data);
154
+ res.written.push(rel);
155
+ }
156
+ if (o.schema) {
157
+ const cfgPath = join(root, "openspec/config.yaml");
158
+ const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
159
+ const m = mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8"));
160
+ if (m.added.length) {
161
+ mkdirSync(dirname(cfgPath), { recursive: true });
162
+ writeFileSync(cfgPath, m.text);
163
+ res.written.push(`openspec/config.yaml (${m.added.join(", ")})`);
164
+ }
165
+ for (const k of m.kept)
166
+ res.kept.push(`openspec/config.yaml ${k}`);
167
+ }
168
+ return res;
169
+ }