spec-ctx 0.4.0 → 0.4.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.
@@ -1,10 +1,17 @@
1
1
  // REQ-QRY-002 IDs reachable from a start ID, with the reason each one was included.
2
2
  import { canon, poolOf } from "../index/canonical.js";
3
3
  import { cmpStr, cmpTuple } from "../util/pystr.js";
4
+ import { fileEdges } from "./files.js";
5
+ import { structuralEdges, structure } from "./links.js";
4
6
  // REQ-QRY-003 fixed block order after the requested IDs
5
- export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5 };
6
- export const REASONS = ["scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers"];
7
- export function related(entries, start, depth, all = false) {
7
+ // REQ-QRY-012 capability and change lines print after the test lines (046/D7)
8
+ export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5, cap: 6, change: 7 };
9
+ export const REASONS = [
10
+ "scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers", "mentions",
11
+ "delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of",
12
+ ];
13
+ /** The walk behind `related` and `bundle`. The map keeps the insertion order and the depth of each entry (046/D7). */
14
+ export function walkFound(entries, start, depth, all = false) {
8
15
  const pool = poolOf(entries, all);
9
16
  const ids = canon(entries);
10
17
  for (const e of pool)
@@ -19,32 +26,48 @@ export function related(entries, start, depth, all = false) {
19
26
  s.add(e.id);
20
27
  }
21
28
  }
29
+ const S = structure(entries);
22
30
  const found = new Map();
23
- const add = (i, reason) => {
31
+ const add = (i, reason, d) => {
32
+ if (seeds && ids.get(i) && (ids.get(i).kind === "code" || ids.get(i).kind === "test"))
33
+ return false;
24
34
  if (i !== start && !found.has(i)) {
25
- found.set(i, reason);
35
+ found.set(i, { reason, depth: d });
26
36
  return true;
27
37
  }
28
38
  return false;
29
39
  };
40
+ const isId = ids.has(start) || entries.some((e) => e.id === start);
41
+ const seeds = isId ? null : fileEdges(entries, start);
42
+ const frontier0 = [start];
43
+ if (seeds) {
44
+ frontier0.length = 0;
45
+ for (const [i, reason] of seeds)
46
+ if (add(i, reason, 1))
47
+ frontier0.push(i);
48
+ }
30
49
  const s = ids.get(start);
31
50
  if (s && s.kind === "req")
32
51
  for (const sid of s.scenarios ?? [])
33
- add(sid, `scenario of ${start}`);
34
- let frontier = [start];
52
+ add(sid, `scenario of ${start}`, 1);
53
+ let frontier = frontier0;
35
54
  for (let d = 0; d < depth; d++) {
36
55
  const nxt = [];
56
+ const dd = d + (seeds ? 2 : 1);
37
57
  for (const x of frontier) {
38
58
  const ex = ids.get(x);
39
59
  if (ex) {
40
60
  for (const r of ex.refs)
41
- if (add(r, `ref from ${x}`))
61
+ if (add(r, `ref from ${x}`, dd))
42
62
  nxt.push(r);
43
63
  if (ex.kind === "req") {
44
64
  for (const sid of ex.scenarios ?? [])
45
- if (add(sid, `scenario of ${x}`))
65
+ if (add(sid, `scenario of ${x}`, dd))
46
66
  nxt.push(sid);
47
67
  }
68
+ for (const [t, reason] of structuralEdges(S, ex, x, x === start, (id) => ids.has(id)))
69
+ if (add(t, reason, dd))
70
+ nxt.push(t);
48
71
  }
49
72
  for (const y of [...(back.get(x) ?? [])].sort(cmpStr)) {
50
73
  const ey = ids.get(y);
@@ -62,15 +85,39 @@ export function related(entries, start, depth, all = false) {
62
85
  reason = `covers ${x}`;
63
86
  else
64
87
  reason = `ref to ${x}`;
65
- if (add(y, reason))
88
+ if (add(y, reason, dd))
66
89
  nxt.push(y);
67
90
  }
68
91
  }
69
92
  frontier = nxt;
70
93
  }
94
+ return found;
95
+ }
96
+ /** The main version of a modified requirement, or undefined. */
97
+ export function mainVersion(entries, id) {
98
+ return structure(entries).main.get(id);
99
+ }
100
+ /** REQ-QRY-005 a start that is no ID is a repository path. Its links seed the walk, and the walk adds no code or test row. */
101
+ export function related(entries, start, depth, all = false) {
102
+ const ids = canon(entries);
103
+ for (const e of poolOf(entries, all))
104
+ if (!ids.has(e.id))
105
+ ids.set(e.id, e);
106
+ const found = walkFound(entries, start, depth, all);
71
107
  const rank = (i) => {
72
108
  const e = ids.get(i);
73
109
  return [e ? (KIND_RANK[e.kind] ?? 9) : 9, i];
74
110
  };
75
- return [...found.keys()].sort((a, b) => cmpTuple(rank(a), rank(b))).map((i) => [i, found.get(i)]);
111
+ const out = [];
112
+ // REQ-QRY-010 the main version of a modified requirement follows the pair of its delta. --all lists both copies already.
113
+ const withMain = (i, reason) => {
114
+ out.push([i, reason]);
115
+ if (!all && mainVersion(entries, i))
116
+ out.push([i, `main version of ${i}`]);
117
+ };
118
+ if (!all && mainVersion(entries, start))
119
+ out.push([start, `main version of ${start}`]);
120
+ for (const i of [...found.keys()].sort((a, b) => cmpTuple(rank(a), rank(b))))
121
+ withMain(i, found.get(i).reason);
122
+ return out;
76
123
  }
@@ -164,16 +164,18 @@ export function ask(root, entries, text, change, plan = false) {
164
164
  h.asked = q.n;
165
165
  return h;
166
166
  }
167
- /** REQ-LOOP-007 archive when the change has a task and every task is [x]. Else plan with the flag and design without it. The text plays no part (041/D2). */
167
+ /** REQ-LOOP-007 plan with the flag, whatever the task state. Else archive when the change has a task and every task is [x]. Else design. The text plays no part (041/D2). */
168
168
  export function stageOf(entries, folder, plan = false) {
169
+ if (plan)
170
+ return "plan";
169
171
  const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
170
172
  if (tasks.length > 0 && tasks.every((t) => t.status === "done"))
171
173
  return "archive";
172
- return plan ? "plan" : "design";
174
+ return "design";
173
175
  }
174
176
  /** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
175
- export function resolve(root, entries, n, answer) {
176
- resolveQuestion(root, n, answer);
177
+ export function resolve(root, entries, n, answer, verdict) {
178
+ resolveQuestion(root, n, answer, verdict);
177
179
  return refresh(root, entries);
178
180
  }
179
181
  export function addReopened(root, entries, task, reason) {
@@ -47,7 +47,7 @@ export function addQuestion(root, task, text, stage) {
47
47
  return item;
48
48
  }
49
49
  /** REQ-HND-002 resolve the open question n with an answer. Exit 2 when n is not an open question. */
50
- export function resolveQuestion(root, n, answer) {
50
+ export function resolveQuestion(root, n, answer, verdict) {
51
51
  const qs = readQuestions(root);
52
52
  const item = qs.find((x) => x.n === n && x.status === "open");
53
53
  if (!Number.isInteger(n) || !item) {
@@ -57,6 +57,14 @@ export function resolveQuestion(root, n, answer) {
57
57
  const a = oneLine(answer);
58
58
  if (!a)
59
59
  throw new UsageExit("handoff resolve needs an answer");
60
+ // REQ-HND-004 a plan or archive question needs one verdict. Other stages take none (048/D3).
61
+ const needs = item.stage === "plan" || item.stage === "archive";
62
+ if (needs && (verdict === undefined || verdict === "both"))
63
+ throw new UsageExit("handoff resolve needs --approve or --reject for a plan or archive question");
64
+ if (!needs && verdict !== undefined)
65
+ throw new UsageExit("--approve and --reject apply to plan and archive questions");
66
+ if (needs)
67
+ item.verdict = verdict;
60
68
  item.status = "resolved";
61
69
  item.answer = a;
62
70
  item.resolved_at = isoNow();
@@ -1,14 +1,39 @@
1
- // REQ-IDX-001 REQ-IDX-002 REQ-IDX-004 build, write, and load .ctx/index.json.
1
+ // REQ-IDX-001 REQ-IDX-002 REQ-IDX-004 REQ-IDX-014 build, write, and load .ctx/index.json.
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { fsNowMs, mtimeMsOf, pause, setMtimeMs } from "./clock.js";
3
4
  import { join } from "node:path";
4
5
  import { dumps } from "../util/json.js";
5
6
  import { cmpStr, cmpTuple } from "../util/pystr.js";
6
7
  import { markCanonical } from "./canonical.js";
8
+ import { addStructure } from "./structure.js";
7
9
  import { scanMarkdown, scannedOf, scanner, scanSource, walk } from "./scan.js";
8
10
  export const INDEX_PATH = ".ctx/index.json";
9
- export function buildIndex(root, cfg, files, sc) {
11
+ /** REQ-IDX-014 the most that a file mtime may pass the clock reading of a build and still count as a write in its tick. */
12
+ const TICK_MS = 50;
13
+ /** REQ-IDX-014 the most waits of 2 ms for the file clock to pass the newest mtime: 100 ms in all. */
14
+ const MAX_PAUSES = 50;
15
+ /**
16
+ * REQ-IDX-004 build and write the index. `t0` is the clock reading taken before the walk of `files`. The index file gets
17
+ * `t0` as its mtime, so a write in the same tick as the build, or during it, leaves a file at or after that mtime (049/D2).
18
+ * A newest file 0 to 50 ms after `t0` makes the build wait until the file clock passes it, then take `t0` again (049/D4).
19
+ * A file far after `t0`, such as one dated in the future, gets no wait. Only file clock readings enter this logic.
20
+ */
21
+ export function buildIndex(root, cfg, files, sc, t0) {
10
22
  const s = sc ?? scanner(root, cfg);
23
+ let at = t0 ?? fsNowMs(root);
11
24
  const list = files ?? walk(s);
25
+ const newest = scannedOf(root, list).newest_mtime_ms;
26
+ if (newest >= at && newest - at <= TICK_MS) {
27
+ for (let i = 0; i < MAX_PAUSES; i++) {
28
+ at = fsNowMs(root);
29
+ if (at > newest)
30
+ break;
31
+ pause(2);
32
+ }
33
+ }
34
+ return buildOnce(root, cfg, list, s, at);
35
+ }
36
+ function buildOnce(root, cfg, list, s, t0) {
12
37
  const entries = [];
13
38
  const problems = [];
14
39
  const scnNamed = new Set();
@@ -18,6 +43,7 @@ export function buildIndex(root, cfg, files, sc) {
18
43
  else
19
44
  scanSource(s, f.path, entries, scnNamed);
20
45
  }
46
+ addStructure(entries, list);
21
47
  markCanonical(entries);
22
48
  entries.sort((a, b) => cmpTuple([a.id, a.file, a.line], [b.id, b.file, b.line]));
23
49
  const uniq = new Map();
@@ -32,10 +58,12 @@ export function buildIndex(root, cfg, files, sc) {
32
58
  };
33
59
  mkdirSync(join(root, ".ctx"), { recursive: true });
34
60
  writeFileSync(join(root, INDEX_PATH), dumps(data) + "\n");
61
+ setMtimeMs(join(root, INDEX_PATH), t0);
35
62
  return data;
36
63
  }
37
64
  /** REQ-IDX-004 compare the stored scan record with a fresh stat walk. Rebuild on a mismatch. */
38
65
  export function loadIndex(root, cfg) {
66
+ const t0 = fsNowMs(root);
39
67
  const sc = scanner(root, cfg);
40
68
  const files = walk(sc);
41
69
  const p = join(root, INDEX_PATH);
@@ -50,8 +78,9 @@ export function loadIndex(root, cfg) {
50
78
  }
51
79
  const fresh = scannedOf(root, files);
52
80
  if (data === null || !data.scanned || data.scanned.count !== fresh.count
53
- || data.scanned.newest_mtime_ms !== fresh.newest_mtime_ms || Object.keys(data.scanned).length !== 2) {
54
- return { data: buildIndex(root, cfg, files, sc), rebuilt: true };
81
+ || data.scanned.newest_mtime_ms !== fresh.newest_mtime_ms || Object.keys(data.scanned).length !== 2
82
+ || fresh.newest_mtime_ms >= (mtimeMsOf(p) ?? 0)) {
83
+ return { data: buildIndex(root, cfg, files, sc, t0), rebuilt: true };
55
84
  }
56
85
  return { data, rebuilt: false };
57
86
  }
@@ -0,0 +1,55 @@
1
+ // REQ-IDX-014 the file system clock. A write gets its mtime from a coarse kernel clock, so two writes in one tick
2
+ // share an mtime. The index keeps the clock reading of the start of its build as its own mtime (049/D2).
3
+ import { closeSync, mkdirSync, openSync, statSync, unlinkSync, utimesSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ /** Whole milliseconds of an mtime in nanoseconds. */
6
+ export function msOf(ns) {
7
+ return Number(ns / 1000000n);
8
+ }
9
+ /**
10
+ * The time of the file system clock in whole milliseconds: create one file in `.ctx/`, stat it, delete it. When the
11
+ * clock cannot be read, the value is one second before `Date.now()`. An earlier value can only cause an extra rebuild.
12
+ */
13
+ export function fsNowMs(root) {
14
+ const dir = join(root, ".ctx");
15
+ const f = join(dir, `.clock-${process.pid}`);
16
+ try {
17
+ mkdirSync(dir, { recursive: true });
18
+ try {
19
+ unlinkSync(f);
20
+ }
21
+ catch {
22
+ // no old file
23
+ }
24
+ closeSync(openSync(f, "wx"));
25
+ const ms = msOf(statSync(f, { bigint: true }).mtimeNs);
26
+ try {
27
+ unlinkSync(f);
28
+ }
29
+ catch {
30
+ // the reading stays
31
+ }
32
+ return ms;
33
+ }
34
+ catch {
35
+ return Date.now() - 1000;
36
+ }
37
+ }
38
+ /** Set the mtime of a file to a whole number of milliseconds. Half a millisecond is added so that rounding keeps the value. */
39
+ export function setMtimeMs(path, ms) {
40
+ const t = (ms + 0.5) / 1000;
41
+ utimesSync(path, t, t);
42
+ }
43
+ /** The mtime of a file in whole milliseconds, or null when it is absent. */
44
+ export function mtimeMsOf(path) {
45
+ try {
46
+ return msOf(statSync(path, { bigint: true }).mtimeNs);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Sleep for a few milliseconds. The engine has no timer, so this blocks on a wait cell. */
53
+ export function pause(ms) {
54
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
55
+ }
@@ -15,13 +15,15 @@ const SCN_HDR = new RegExp(`^#### Scenario:${S}*(?:(SCN-[A-Z]{2,6}-\\d{3}-\\d+)|
15
15
  const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
16
16
  const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
17
17
  const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
18
- const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})`);
18
+ // REQ-IDX-008 the first marker and ID start the tag. Every REQ ID to the end of the line counts, with word boundaries (045/D5).
19
+ const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})\\b`);
20
+ const CODE_SPAN = /`([^`]+)`/g;
19
21
  // REQ-IDX-007 the rest of the line after Covers:, and each REQ ID in it with word boundaries (042/D4)
20
22
  const COVER_TAG = /Covers:(.*)$/s;
21
23
  const REQ_ID = /\bREQ-[A-Z]{2,6}-\d{3}\b/g;
22
24
  const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
23
25
  const COMMA = new RegExp(`${S}*,${S}*`);
24
- const SKIP_DIRS = new Set(["node_modules", ".git"]);
26
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".ctx"]);
25
27
  export function scanner(root, cfg) {
26
28
  const s = cfg.scan;
27
29
  const all = [...s.specs, ...s.changes, ...s.decisions, ...s.code, ...s.tests];
@@ -295,15 +297,23 @@ export function scanMarkdown(sc, rpath, entries, problems) {
295
297
  const reqs = [];
296
298
  for (const g of body.matchAll(TASK_REQ))
297
299
  reqs.push(...g[1].split(COMMA));
298
- if (reqs.length === 0)
299
- problems.push([rpath, i, "task-without-req", tid]);
300
- if (!body.includes("Verify:"))
301
- problems.push([rpath, i, "task-without-verify", tid]);
300
+ // REQ-CHK-001 an archived task is history: no citation finding
301
+ if (status !== "archived") {
302
+ if (reqs.length === 0)
303
+ problems.push([rpath, i, "task-without-req", tid]);
304
+ if (!body.includes("Verify:"))
305
+ problems.push([rpath, i, "task-without-verify", tid]);
306
+ }
302
307
  // the full task body is the title, so query output shows the Verify clause
303
- entries.push(entry({
308
+ const t = entry({
304
309
  id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
305
310
  status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
306
- }));
311
+ });
312
+ // REQ-IDX-009 an active task records the paths that its line names in backticks
313
+ const paths = status === "archived" ? [] : taskPaths(body);
314
+ if (paths.length)
315
+ t.paths = paths;
316
+ entries.push(t);
307
317
  }
308
318
  }
309
319
  // REQ-IDX-002 text holds the lower-cased body of each block
@@ -325,6 +335,20 @@ export function scanMarkdown(sc, rpath, entries, problems) {
325
335
  }
326
336
  }
327
337
  }
338
+ /** REQ-IDX-009 code spans with no space and no `*` that hold a `/` or a `.`. A leading `./` is dropped. */
339
+ export function taskPaths(body) {
340
+ const out = new Set();
341
+ for (const m of body.matchAll(CODE_SPAN)) {
342
+ let t = m[1];
343
+ if (/[ \t\n\r\f\v*]/.test(t) || !(t.includes("/") || t.includes(".")))
344
+ continue;
345
+ while (t.startsWith("./"))
346
+ t = t.slice(2);
347
+ if (t)
348
+ out.add(t);
349
+ }
350
+ return [...out].sort(cmpStr);
351
+ }
328
352
  export function isTestPath(sc, rpath) {
329
353
  const name = rpath.slice(rpath.lastIndexOf("/") + 1);
330
354
  return anyMatch(sc.tests, rpath) || rpath.includes("/tests/") || rpath.startsWith("tests/")
@@ -360,7 +384,10 @@ export function scanSource(sc, rpath, entries, scnNamed) {
360
384
  }
361
385
  const m = CODE_TAG.exec(line);
362
386
  if (m && !line.includes("Covers:")) {
363
- entries.push(entry({ id: `${rpath}:${i}`, kind: "code", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [m[1]] }));
387
+ const ids = [...new Set(line.slice(m.index).match(REQ_ID) ?? [])];
388
+ for (const rid of ids) {
389
+ entries.push(entry({ id: `${rpath}:${i}`, kind: "code", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
390
+ }
364
391
  }
365
392
  }
366
393
  }
@@ -0,0 +1,97 @@
1
+ // REQ-IDX-010 REQ-IDX-012 REQ-IDX-013 change entries, capability entries, and the design links of tasks.
2
+ import { cmpStr } from "../util/pystr.js";
3
+ import { changeKey, changeOf, resolveChange, taskPaths } from "./scan.js";
4
+ const CHANGE_DIR = /^(openspec\/changes\/(?:archive\/)?[^/]+)\//;
5
+ const ARCHIVE_DATE = /^openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2})-/;
6
+ const CAP_SPEC = /(?:^|\/)specs\/([^/]+)\/spec\.md$/;
7
+ // REQ-IDX-012 a full name `<key>/D<n>` or a bare `D<n>`. A bare D follows no letter, digit, slash, or hyphen.
8
+ const FULL_DESIGN = /(?<![\w/-])([\w-]+)\/D(\d+)(?!\w)/g;
9
+ const BARE_DESIGN = /(?<![\w/-])D(\d+)(?!\w)/g;
10
+ function entryOf(e) {
11
+ return { ...e, line: 1, text: "", canonical: true, refs: [] };
12
+ }
13
+ /** REQ-IDX-013 the winner among folders with one key: active first, then the smaller name. Archived: newest date, then the smaller name. */
14
+ function better(a, b) {
15
+ if (a.archived !== b.archived)
16
+ return !a.archived;
17
+ if (a.archived && a.date !== b.date)
18
+ return a.date > b.date;
19
+ return cmpStr(a.change, b.change) < 0;
20
+ }
21
+ /** REQ-IDX-010 one change entry per key and one cap entry per capability name. */
22
+ function structureEntries(files) {
23
+ const folders = new Map();
24
+ const caps = new Map();
25
+ for (const f of files) {
26
+ const m = CHANGE_DIR.exec(f.path);
27
+ if (m) {
28
+ let fo = folders.get(m[1]);
29
+ if (!fo) {
30
+ const date = ARCHIVE_DATE.exec(f.path)?.[1] ?? "";
31
+ folders.set(m[1], (fo = { dir: m[1], change: changeOf(f.path), archived: f.path.includes("/archive/"), date, files: [] }));
32
+ }
33
+ fo.files.push(f.path);
34
+ }
35
+ const c = CAP_SPEC.exec(f.path);
36
+ if (c) {
37
+ let l = caps.get(c[1]);
38
+ if (!l)
39
+ caps.set(c[1], (l = []));
40
+ l.push(f.path);
41
+ }
42
+ }
43
+ const win = new Map();
44
+ for (const fo of folders.values()) {
45
+ const key = changeKey(fo.change);
46
+ const cur = win.get(key);
47
+ if (!cur || better(fo, cur))
48
+ win.set(key, fo);
49
+ }
50
+ const out = [];
51
+ for (const [key, fo] of win) {
52
+ const proposal = `${fo.dir}/proposal.md`;
53
+ const file = fo.files.includes(proposal) ? proposal : [...fo.files].sort(cmpStr)[0];
54
+ out.push(entryOf({ id: `change/${key}`, kind: "change", file, title: fo.change, change: fo.change, status: fo.archived ? "archived" : "active" }));
55
+ }
56
+ for (const [name, list] of caps) {
57
+ // the main spec, else the first active delta, else the newest archived delta
58
+ const sorted = [...list].sort(cmpStr);
59
+ const file = sorted.find((p) => p.startsWith("openspec/specs/"))
60
+ ?? sorted.find((p) => !p.includes("/archive/"))
61
+ ?? sorted[sorted.length - 1];
62
+ out.push(entryOf({ id: `cap/${name}`, kind: "cap", file, title: name, change: "main", status: "active" }));
63
+ }
64
+ return out;
65
+ }
66
+ /** REQ-IDX-012 the `design:` links of a task. The scan reads the text before `Verify:` with the path spans removed. */
67
+ function designLinks(entries) {
68
+ const decisions = new Set(entries.filter((e) => e.kind === "decision").map((e) => e.id));
69
+ for (const t of entries) {
70
+ if (t.kind !== "task")
71
+ continue;
72
+ let body = t.title;
73
+ const v = body.indexOf("Verify:");
74
+ if (v >= 0)
75
+ body = body.slice(0, v);
76
+ for (const p of taskPaths(body))
77
+ body = body.split("`" + p + "`").join(" ");
78
+ const found = new Set();
79
+ for (const m of body.matchAll(FULL_DESIGN)) {
80
+ const folder = resolveChange(entries, m[1]);
81
+ const id = `${folder === undefined ? m[1] : changeKey(folder)}/D${m[2]}`;
82
+ if (decisions.has(id))
83
+ found.add(id);
84
+ }
85
+ const own = `${changeKey(t.change)}/D`;
86
+ for (const m of body.matchAll(BARE_DESIGN))
87
+ if (decisions.has(own + m[1]))
88
+ found.add(own + m[1]);
89
+ if (found.size)
90
+ t.links = [...found].sort(cmpStr).map((id) => `design:${id}`);
91
+ }
92
+ }
93
+ /** Add the structural entries and the design links. It runs after the scan and before the canonical mark. */
94
+ export function addStructure(entries, files) {
95
+ designLinks(entries);
96
+ entries.push(...structureEntries(files));
97
+ }
package/dist/init/init.js CHANGED
@@ -6,6 +6,7 @@ import { ASSETS } from "../assets.generated.js";
6
6
  import { UsageExit } from "../cli/errors.js";
7
7
  import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
8
8
  import { loadConfigOrExit } from "../cli/config.js";
9
+ import { VERSION } from "../version.js";
9
10
  export const SCHEMA = "spec-driven-ids";
10
11
  /** 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). */
11
12
  export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
@@ -41,7 +42,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
41
42
  * exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
42
43
  * `openspec` replaces the default command on the `openspec = ` line (022/D2).
43
44
  */
44
- export function configText(root, openspec = DEFAULT_OPENSPEC) {
45
+ export function configText(root, openspec = DEFAULT_OPENSPEC, run = null) {
45
46
  const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
46
47
  let text = INIT_TEXT;
47
48
  if (!isDir("tests") && isDir("test"))
@@ -50,8 +51,18 @@ export function configText(root, openspec = DEFAULT_OPENSPEC) {
50
51
  text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
51
52
  if (openspec !== DEFAULT_OPENSPEC)
52
53
  text = text.replace(`\nopenspec = "${DEFAULT_OPENSPEC}"\n`, `\nopenspec = ${JSON.stringify(openspec)}\n`);
54
+ // REQ-DIST-015 a run prefix adds the [cli] section at the end (043/D3)
55
+ if (run !== null)
56
+ text += `\n[cli]\n# The prefix of every command that spec-ctx prints for you to run. init wrote it because it ran under npx.\nrun = ${JSON.stringify(run)}\n`;
53
57
  return text;
54
58
  }
59
+ /**
60
+ * REQ-DIST-015 the run prefix that init writes. npm sets npm_command to exec for npm exec and npx.
61
+ * Any other value, or none, gives null and the default applies (043/D3).
62
+ */
63
+ export function runPrefixFor(env) {
64
+ return env.npm_command === "exec" ? `npx -y spec-ctx@${VERSION}` : null;
65
+ }
55
66
  /**
56
67
  * REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
57
68
  * An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
@@ -74,8 +85,8 @@ export function bracketsOf(root) {
74
85
  return existsSync(join(root, CONFIG_FILE)) ? loadConfigOrExit(root).ids.brackets : false;
75
86
  }
76
87
  /** Target path relative to the project root for every file that init installs, with its bytes. */
77
- export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
78
- const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
88
+ export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false, run = null) {
89
+ const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec, run))]];
79
90
  for (const rel of Object.keys(ASSETS).sort()) {
80
91
  // REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
81
92
  if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
@@ -416,7 +427,7 @@ export function runInit(root, o) {
416
427
  const brackets = bracketsOf(root);
417
428
  const fragment = fragmentText(brackets);
418
429
  const otherFragment = fragmentText(!brackets);
419
- let files = plan(o, root, openspec, brackets);
430
+ let files = plan(o, root, openspec, brackets, runPrefixFor(process.env));
420
431
  // REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
421
432
  // an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
422
433
  const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { readQuestions } from "../handoff/questions.js";
5
5
  import { isFailFile } from "../tasks/evidence.js";
6
6
  import { evidenceRel } from "../tasks/tasks.js";
7
+ import { runCommand } from "../runcmd.js";
7
8
  import { dumps } from "../util/json.js";
8
9
  import { cmpStr } from "../util/pystr.js";
9
10
  import { isoNow } from "../util/time.js";
@@ -66,6 +67,13 @@ export function failsInWindow(root, task, questions) {
66
67
  export function openQuestionFor(root, task, questions) {
67
68
  return (questions ?? safeQuestions(root)).find((q) => q.task === task && q.status === "open");
68
69
  }
70
+ /**
71
+ * REQ-LOOP-014 an open attempt-cap question of the task: stage cap only. A question with no stage is a person's (044/D3).
72
+ * The text plays no part (044/D3).
73
+ */
74
+ export function isCapQuestion(q, task) {
75
+ return q.status === "open" && q.task === task && q.stage === "cap";
76
+ }
69
77
  /** Write the next fail file of a task. The attempt counts the window, the file number counts every fail file. */
70
78
  export function writeFail(root, task, verify, tail) {
71
79
  const attempt = failsInWindow(root, task).length + 1;
@@ -85,8 +93,8 @@ export function capQuestion(task, attempts, tail) {
85
93
  return `task ${task} failed Verify ${attempts} times. Last: ${failLine(tail)}. What should change?`;
86
94
  }
87
95
  /** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
88
- export function blockedLine(n) {
89
- return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
96
+ export function blockedLine(n, run = "specctx") {
97
+ return `blocked: question ${n} written. Run ${runCommand(run, `handoff resolve ${n} "<answer>"`)} to continue.`;
90
98
  }
91
99
  /**
92
100
  * REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:`, `FAIL`, or `(fail)`