spec-ctx 0.4.1 → 0.6.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.
@@ -1,7 +1,9 @@
1
1
  // REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
2
- import { join } from "node:path";
2
+ import { statSync } from "node:fs";
3
+ import { isAbsolute, join, normalize } from "node:path";
3
4
  import { canon, poolOf } from "../index/canonical.js";
4
5
  import { blockRange, readText } from "../index/scan.js";
6
+ import { Trust, provenanceClass, taggedReq } from "./trust.js";
5
7
  import { cmpStr, cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
6
8
  import { KIND_RANK, mainVersion, walkFound } from "./related.js";
7
9
  export class Lines {
@@ -42,8 +44,13 @@ function rstrip(s) {
42
44
  const t = pyStrip("x" + s);
43
45
  return t.slice(1);
44
46
  }
45
- export function provenance(e, reason) {
46
- return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
47
+ export function provenance(e, reason, trust = "") {
48
+ return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}${trust}\n`;
49
+ }
50
+ /** REQ-JRN-002 the ID of a provenance line, or null. It anchors on the start of `provenance()` (057/D3). */
51
+ export function parseProvenance(line) {
52
+ const m = /^-- (\S+) \| /.exec(line);
53
+ return m ? m[1] : null;
47
54
  }
48
55
  /** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
49
56
  export function reqBlocks(root, reqs) {
@@ -132,12 +139,15 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
132
139
  if (m)
133
140
  blocks.push([m, `main version of ${i}`, 0]);
134
141
  }
142
+ // REQ-QRY-018 the count of blocks that the loop above pushed. They print first and always stay.
143
+ const pinned = blocks.length;
135
144
  const found = new Map();
136
145
  for (const i of wanted) {
137
146
  // decisions that reference a requested ID are included at every depth, as depth 1.
138
147
  // REQ-QRY-016 They replace a deeper hit, which the walk for an earlier requested ID can leave (048/D1).
139
148
  for (const e of entries) {
140
- if (e.kind !== "decision" || !e.refs.includes(i))
149
+ // REQ-QRY-014 a requested decision prints once, as requested (050/D10)
150
+ if (e.kind !== "decision" || !e.refs.includes(i) || wanted.includes(e.id))
141
151
  continue;
142
152
  const cur = found.get(e.id);
143
153
  if (!cur || cur.depth > 1)
@@ -161,7 +171,7 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
161
171
  }
162
172
  for (const c of r.cited) {
163
173
  for (const e of entries) {
164
- if (e.kind !== "decision" || !e.refs.includes(c))
174
+ if (e.kind !== "decision" || !e.refs.includes(c) || wanted.includes(e.id))
165
175
  continue;
166
176
  const cur = found.get(e.id);
167
177
  if (!cur || cur.depth > 1)
@@ -186,28 +196,145 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
186
196
  blocks.push([m, `main version of ${r2}`, cls]);
187
197
  }
188
198
  const files = new Lines(root);
189
- const texts = blocks.map(([e, reason]) => {
199
+ // REQ-QRY-020 one state per block: 0 none, 1 card, 2 full. The fill sets it and the output reads it in print order.
200
+ // REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust columns of each card (062/D1 to D5)
201
+ const trust = new Trust(root, entries);
202
+ const labels = (e, reason) => {
203
+ let prov = provenanceClass(reason);
204
+ if (prov === "tagged" && (e.kind === "code" || e.kind === "test")) {
205
+ const r = taggedReq(reason);
206
+ if (r && trust.lineVerified(e.file, r))
207
+ prov = "verified";
208
+ }
209
+ let ev = null;
210
+ if (e.kind === "task")
211
+ ev = trust.taskState(e.id);
212
+ else if (e.kind === "req")
213
+ ev = trust.reqState(e.id);
214
+ return { provenance: prov, evidence: ev };
215
+ };
216
+ const blk = blocks.map(([e, reason, cls]) => {
190
217
  const body = blockText(e, files);
191
- // a blank line closes a block with a body. Code and test paths stay one line each.
192
- return provenance(e, reason) + body + (body ? "\n" : "");
218
+ const l = labels(e, reason);
219
+ const suffix = ` | ${l.provenance}` + (l.evidence ? ` | ${l.evidence}` : "");
220
+ return { id: e.id, head: provenance(e, reason, suffix), body: body ? body + "\n" : "", cls, st: 0,
221
+ info: { id: e.id, file: e.file, line: e.line, status: e.status, reason, ...l } };
193
222
  });
194
- // fill the budget class by class, in print order inside a class, and skip a block that does not fit
195
- const keep = blocks.map(() => false);
223
+ // REQ-QRY-021 the files that a requested task names, as class 1 cards after the code and test lines (060/D4)
224
+ const named = [];
225
+ if (depth >= 1) {
226
+ const taken = new Set(blocks.filter(([e]) => e.kind === "code" || e.kind === "test").map(([e]) => e.file));
227
+ for (const t of wanted) {
228
+ const te = ids.get(t);
229
+ if (te?.kind !== "task")
230
+ continue;
231
+ for (const path of te.paths ?? []) {
232
+ if (taken.has(path) || !isFile(root, path))
233
+ continue;
234
+ taken.add(path);
235
+ named.push({ id: path, head: `-- ${path} | ${path} | active | named by ${t} | declared\n`, body: "", cls: 1, st: 0,
236
+ info: { id: path, file: path, line: 0, status: "active", reason: `named by ${t}`, provenance: "declared", evidence: null } });
237
+ }
238
+ }
239
+ }
240
+ // the pinned blocks can hold a change, whose rank is above test, so the search starts after them
241
+ let at = blocks.findIndex(([e], k) => k >= pinned && (KIND_RANK[e.kind] ?? 9) > KIND_RANK.test);
242
+ if (at < 0)
243
+ at = blk.length;
244
+ blk.splice(Math.max(at, pinned), 0, ...named);
245
+ // REQ-QRY-018 the pinned blocks always stay, with their bodies (050/D2, 050/D3)
246
+ for (let k = 0; k < pinned; k++)
247
+ blk[k].st = 2;
196
248
  let used = 0;
197
- for (let cls = 0; cls <= 3; cls++) {
198
- for (let k = 0; k < blocks.length; k++) {
199
- if (blocks[k][2] !== cls)
249
+ for (let k = 0; k < pinned; k++)
250
+ used += cpLen(render(blk[k]));
251
+ const cost = (b, st) => cpLen(renderAs(b, st));
252
+ // fill in five steps, in print order inside each: cards of class 0, cards of class 1, bodies of class 0, cards of class 2, bodies of class 2
253
+ const cards = (cls) => {
254
+ for (let k = pinned; k < blk.length; k++) {
255
+ const b = blk[k];
256
+ if (b.cls !== cls || b.st)
200
257
  continue;
201
- const n = cpLen(texts[k]);
258
+ const st = b.body ? 1 : 2;
259
+ const n = cost(b, st);
202
260
  if (used + n > budget)
203
261
  continue;
204
- keep[k] = true;
262
+ b.st = st;
205
263
  used += n;
206
264
  }
265
+ };
266
+ const bodies = (cls) => {
267
+ for (let k = pinned; k < blk.length; k++) {
268
+ const b = blk[k];
269
+ if (b.cls !== cls || b.st !== 1)
270
+ continue;
271
+ const d = cost(b, 2) - cost(b, 1);
272
+ if (used + d > budget)
273
+ continue;
274
+ b.st = 2;
275
+ used += d;
276
+ }
277
+ };
278
+ cards(0);
279
+ cards(1);
280
+ bodies(0);
281
+ cards(2);
282
+ bodies(2);
283
+ // class 3 fills as a whole block
284
+ for (let k = pinned; k < blk.length; k++) {
285
+ const b = blk[k];
286
+ if (b.cls !== 3)
287
+ continue;
288
+ const n = cost(b, 2);
289
+ if (used + n > budget)
290
+ continue;
291
+ b.st = 2;
292
+ used += n;
207
293
  }
208
- const omitted = blocks.filter((_, k) => !keep[k]).map(([e]) => e.id);
209
- let text = texts.filter((_, k) => keep[k]).join("");
294
+ const omitted = blk.filter((b) => !b.st).map((b) => b.id);
295
+ const bodyOmitted = blk.filter((b) => b.st === 1).map((b) => b.id);
296
+ // REQ-QRY-022 the note of the first requested task with no requirement that the index holds. It prints after the pinned blocks.
297
+ let note = "";
298
+ if (depth >= 1) {
299
+ const t = wanted.find((w) => {
300
+ const te = ids.get(w);
301
+ return te?.kind === "task" && !te.refs.some((x) => ids.get(x)?.kind === "req");
302
+ });
303
+ if (t)
304
+ note = `note: ${t} cites no requirement that the index holds. The blocks come from its change and the paths it names. Add a [REQ-...] citation.\n`;
305
+ }
306
+ const out = blk.map((b) => render(b));
307
+ let text = out.slice(0, pinned).join("") + note + out.slice(pinned).join("");
308
+ // the budget counts the blocks, so the figure of the over budget line is their length
309
+ used = out.reduce((n, x) => n + cpLen(x), 0);
210
310
  if (omitted.length)
211
311
  text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
212
- return { text, omitted };
312
+ // REQ-QRY-018 the over budget line goes last, so the 0.4.1 output stays a prefix (050/D4)
313
+ if (used > budget)
314
+ text += `over budget: ${used} of ${budget} characters\n`;
315
+ const printed = blk.filter((b) => b.st).map((b) => b.info);
316
+ return { text, omitted, body_omitted: bodyOmitted, chars: cpLen(text), budget, complete: omitted.length === 0 && bodyOmitted.length === 0, used, blocks: printed };
317
+ }
318
+ const MARKER = " | body omitted";
319
+ function renderAs(b, st) {
320
+ if (st === 0)
321
+ return "";
322
+ if (st === 1 && b.body)
323
+ return b.head.slice(0, -1) + MARKER + "\n";
324
+ return b.head + b.body;
325
+ }
326
+ function render(b) {
327
+ return renderAs(b, b.st);
328
+ }
329
+ /** REQ-QRY-021 true when the path is a file under the root. */
330
+ function isFile(root, path) {
331
+ const n = normalize(path);
332
+ if (isAbsolute(n) || n.startsWith(".."))
333
+ return false;
334
+ try {
335
+ return statSync(join(root, n)).isFile();
336
+ }
337
+ catch {
338
+ return false;
339
+ }
213
340
  }
@@ -24,7 +24,8 @@ export function fileEdges(entries, path) {
24
24
  for (const r of e.refs)
25
25
  push(g, r, `${verb}, tag at ${e.file}:${e.line}`);
26
26
  }
27
- const mentions = entries.filter((e) => e.kind === "task" && e.canonical && (e.paths ?? []).includes(path));
27
+ // REQ-QRY-021 an archived task names paths too, but it is no mention
28
+ const mentions = entries.filter((e) => e.kind === "task" && e.canonical && !e.file.includes("/archive/") && (e.paths ?? []).includes(path));
28
29
  for (const e of mentions)
29
30
  push(2, e.id, "mentions, in task line");
30
31
  return groups.flatMap((g) => g.sort((a, b) => cmpStr(a[0], b[0])));
@@ -8,7 +8,7 @@ import { structuralEdges, structure } from "./links.js";
8
8
  export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5, cap: 6, change: 7 };
9
9
  export const REASONS = [
10
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",
11
+ "delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of", "named by",
12
12
  ];
13
13
  /** The walk behind `related` and `bundle`. The map keeps the insertion order and the depth of each entry (046/D7). */
14
14
  export function walkFound(entries, start, depth, all = false) {
@@ -0,0 +1,114 @@
1
+ // REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust labels of a card: the provenance class and the evidence state.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { staleOf } from "../check/evidence.js";
5
+ import { evidenceRel } from "../tasks/tasks.js";
6
+ import { fileSha256 } from "../util/hash.js";
7
+ const TAGGED = ["implements", "covers", "tag at"];
8
+ const STRUCTURAL = ["delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of"];
9
+ /** REQ-QRY-023 the class of a reason, by its prefix (062/D1). An unknown prefix is declared. */
10
+ export function provenanceClass(reason) {
11
+ if (TAGGED.some((p) => reason.startsWith(p)))
12
+ return "tagged";
13
+ if (STRUCTURAL.some((p) => reason.startsWith(p)))
14
+ return "structural";
15
+ return "declared";
16
+ }
17
+ /**
18
+ * The trust state of one bundle run. It reads each evidence file at most once and hashes each file at most once
19
+ * (062/D3). It writes nothing.
20
+ */
21
+ export class Trust {
22
+ tasks = new Map();
23
+ citing = new Map();
24
+ states = new Map();
25
+ evidence = new Map();
26
+ hashes = new Map();
27
+ root;
28
+ constructor(root, entries) {
29
+ this.root = root;
30
+ for (const e of entries) {
31
+ if (e.kind !== "task")
32
+ continue;
33
+ // an active copy wins over an archived one, as check does
34
+ const cur = this.tasks.get(e.id);
35
+ if (!cur || (cur.file.includes("/archive/") && !e.file.includes("/archive/")))
36
+ this.tasks.set(e.id, e);
37
+ }
38
+ for (const t of this.tasks.values()) {
39
+ for (const r of t.refs) {
40
+ const l = this.citing.get(r) ?? [];
41
+ l.push(t.id);
42
+ this.citing.set(r, l);
43
+ }
44
+ }
45
+ }
46
+ readEvidence(task) {
47
+ if (this.evidence.has(task))
48
+ return this.evidence.get(task);
49
+ const p = join(this.root, evidenceRel({ id: task }));
50
+ let ev = null;
51
+ if (existsSync(p)) {
52
+ try {
53
+ const j = JSON.parse(readFileSync(p, "utf8"));
54
+ ev = { files: j.files ?? {} };
55
+ }
56
+ catch {
57
+ ev = null;
58
+ }
59
+ }
60
+ this.evidence.set(task, ev);
61
+ return ev;
62
+ }
63
+ hash(path) {
64
+ if (!this.hashes.has(path))
65
+ this.hashes.set(path, fileSha256(join(this.root, path)));
66
+ return this.hashes.get(path);
67
+ }
68
+ /** REQ-QRY-024 fresh, stale since the first stale path, or no evidence (062/D2). Never the word current. */
69
+ taskState(id) {
70
+ if (this.states.has(id))
71
+ return this.states.get(id);
72
+ const t = this.tasks.get(id);
73
+ let s = "no evidence";
74
+ if (t && t.status === "done" && existsSync(join(this.root, evidenceRel({ id })))) {
75
+ // a damaged file is stale as a whole, as check reports it, so the decision rests on the file and not on the parse
76
+ if (t.file.includes("/archive/"))
77
+ s = "fresh";
78
+ else {
79
+ const stale = staleOf(this.root, id);
80
+ s = stale ? `stale since ${stale}` : "fresh";
81
+ }
82
+ }
83
+ this.states.set(id, s);
84
+ return s;
85
+ }
86
+ /** REQ-QRY-025 verified when one citing task is fresh, with an early stop (062/D3) */
87
+ reqState(id) {
88
+ for (const t of this.citing.get(id) ?? [])
89
+ if (this.taskState(t) === "fresh")
90
+ return "verified";
91
+ return "unverified";
92
+ }
93
+ /**
94
+ * REQ-QRY-023 a tagged line is verified when a done task that cites its requirement lists the file in its
95
+ * evidence with the current hash (062/D1a). An archived task counts too, so a changed line never reads verified.
96
+ */
97
+ lineVerified(file, req) {
98
+ for (const t of this.citing.get(req) ?? []) {
99
+ const task = this.tasks.get(t);
100
+ if (!task || task.status !== "done")
101
+ continue;
102
+ const ev = this.readEvidence(t);
103
+ const want = ev?.files[file];
104
+ if (want && this.hash(file) === want)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ }
110
+ /** REQ-QRY-023 the requirement that a tagged reason names, or null */
111
+ export function taggedReq(reason) {
112
+ const m = /^(?:implements|covers) (\S+)/.exec(reason);
113
+ return m ? m[1] : null;
114
+ }
@@ -309,8 +309,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
309
309
  id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
310
310
  status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
311
311
  });
312
- // REQ-IDX-009 an active task records the paths that its line names in backticks
313
- const paths = status === "archived" ? [] : taskPaths(body);
312
+ // REQ-IDX-009 a task of any change records the paths that its line names in backticks
313
+ const paths = taskPaths(body);
314
314
  if (paths.length)
315
315
  t.paths = paths;
316
316
  entries.push(t);
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 { childEnv } from "../util/env.js";
9
10
  import { VERSION } from "../version.js";
10
11
  export const SCHEMA = "spec-driven-ids";
11
12
  /** 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). */
@@ -31,7 +32,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
31
32
  if (env !== undefined && env.trim())
32
33
  return { command: env };
33
34
  const [cmd, ...args] = DEFAULT_OPENSPEC.split(" ");
34
- const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] });
35
+ const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"], env: childEnv() /* REQ-DIST-016 */ });
35
36
  const m = r.status === 0 && !r.error ? /\d+\.\d+\.\d+/.exec(r.stdout ?? "") : null;
36
37
  if (!m)
37
38
  return { command: DEFAULT_OPENSPEC, note: OPENSPEC_NOTE };
@@ -402,17 +403,24 @@ export function writeGitignore(root) {
402
403
  writeFileSync(p, text);
403
404
  return add.length;
404
405
  }
405
- /** REQ-DIST-002 true when .gitignore has a line that ignores the whole .claude directory (017/D3). */
406
- export function ignoresClaude(root) {
406
+ /** REQ-DIST-018 the agent folders that .gitignore ignores as a whole, with or without a slash. */
407
+ export function ignoredAgentDirs(root) {
407
408
  const p = join(root, GITIGNORE);
408
409
  if (!existsSync(p))
409
- return false;
410
+ return [];
410
411
  const lines = ignoreLines(readFileSync(p, "utf8"));
411
- return lines.has(".claude") || lines.has(".claude/");
412
+ return [".claude", ".agents"].filter((d) => lines.has(d) || lines.has(`${d}/`));
413
+ }
414
+ /** REQ-DIST-018 one note for the ignored agent folders, or null when none is ignored. */
415
+ export function ignoredNote(root) {
416
+ const dirs = ignoredAgentDirs(root).map((d) => `${d}/`);
417
+ if (dirs.length === 0)
418
+ return null;
419
+ const names = dirs.join(" and ");
420
+ return `note: .gitignore ignores ${names}. The skills under ${names} stay out of git.`;
412
421
  }
413
422
  /** REQ-SCH-003 where the templates with the ID placeholders are. Printed after the schema step (018/D5, 025/D4). */
414
423
  export const TEMPLATES_NOTE = `templates: ${TEMPLATES_DIR}/ (spec.md, tasks.md hold the ID placeholders)`;
415
- export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
416
424
  export function runInit(root, o) {
417
425
  const res = { written: [], kept: [], changed: [], merged: [], notes: [] };
418
426
  // REQ-DIST-002 the detection runs only when init writes spec-ctx.toml. An existing file stays (022/D2).
@@ -440,8 +448,9 @@ export function runInit(root, o) {
440
448
  const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
441
449
  if (!m || (!m.added.length && !m.merged.length)) {
442
450
  res.nothing = true;
443
- if (o.skills && ignoresClaude(root))
444
- res.notes.push(CLAUDE_NOTE);
451
+ const ign = ignoredNote(root); // REQ-DIST-018
452
+ if (ign)
453
+ res.notes.push(ign);
445
454
  if (o.schema && hasLegacySchema(cur))
446
455
  res.notes.push(LEGACY_SCHEMA_NOTE);
447
456
  if (o.schema)
@@ -499,9 +508,10 @@ export function runInit(root, o) {
499
508
  else
500
509
  res.kept.push(GITIGNORE);
501
510
  }
502
- // REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
503
- if (o.skills && ignoresClaude(root))
504
- res.notes.push(CLAUDE_NOTE);
511
+ // REQ-DIST-018 skills under an ignored agent folder never reach git (017/D3)
512
+ const ign = ignoredNote(root);
513
+ if (ign)
514
+ res.notes.push(ign);
505
515
  // REQ-SCH-003 the templates line comes last, right before next: (018/D5)
506
516
  if (o.schema)
507
517
  res.notes.push(TEMPLATES_NOTE);
@@ -0,0 +1,50 @@
1
+ // REQ-DIST-017 what init says to do next, from the state of the specs.
2
+ import { UsageExit } from "../cli/errors.js";
3
+ import { loadConfigOrExit } from "../cli/config.js";
4
+ import { ConfigError } from "../config.js";
5
+ import { planAdopt } from "../adopt/plan.js";
6
+ import { loadIndex } from "../index/build.js";
7
+ import { nextTask } from "../tasks/tasks.js";
8
+ import { runCommand } from "../runcmd.js";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ /** true when .gitignore keeps .ctx/index.json out of git, or the index is already there */
12
+ function indexIsSafe(root) {
13
+ if (existsSync(join(root, ".ctx/index.json")))
14
+ return true;
15
+ const g = join(root, ".gitignore");
16
+ if (!existsSync(g))
17
+ return false;
18
+ return readFileSync(g, "utf8").split("\n").map((l) => l.trim()).includes(".ctx/index.json");
19
+ }
20
+ /**
21
+ * Read the specs with the adopt planner. The planner writes nothing. When no header needs an ID,
22
+ * loadIndex may write .ctx/index.json, only when .gitignore already lists it or it exists (061/D1). Only a usage or config error gives
23
+ * the status fallback. Any other throw propagates.
24
+ */
25
+ export function detectNext(root, run) {
26
+ const status = { needs_id: 0, summary: null, next: runCommand(run, "status") };
27
+ try {
28
+ const cfg = loadConfigOrExit(root);
29
+ const plan = planAdopt(root, cfg);
30
+ if (plan.edits.length > 0) {
31
+ const files = new Set(plan.edits.map((e) => e.file)).size;
32
+ const caps = plan.caps.filter((c) => c.derived).length;
33
+ return {
34
+ needs_id: plan.edits.length,
35
+ summary: `specs: ${plan.edits.length} headers in ${files} files need an ID (${caps} capability codes). adopt --dry-run shows each one.`,
36
+ next: runCommand(run, "adopt"),
37
+ };
38
+ }
39
+ // a skills refresh must not leave an untracked index behind (REQ-DIST-013)
40
+ if (!indexIsSafe(root))
41
+ return status;
42
+ const task = nextTask(root, loadIndex(root, cfg).data.entries);
43
+ return task ? { ...status, next: runCommand(run, `context ${task.id}`) } : status;
44
+ }
45
+ catch (e) {
46
+ if (e instanceof UsageExit || e instanceof ConfigError)
47
+ return status;
48
+ throw e;
49
+ }
50
+ }
@@ -0,0 +1,65 @@
1
+ // REQ-JRN-001 REQ-JRN-003 the journal write. One call, one line, no output, no throw (057/D2, 057/D3).
2
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { COMMANDS } from "../cli/args.js";
5
+ import { dumps } from "../util/json.js";
6
+ import { SIGNALS } from "./note.js";
7
+ export const JOURNAL_DIR = ".ctx/local";
8
+ export const JOURNAL_FILE = "runs.jsonl";
9
+ export const ROTATED_FILE = "runs.1.jsonl";
10
+ export const MAX_BYTES = 256 * 1024;
11
+ export const MAX_LINE = 4096;
12
+ /** REQ-JRN-001 the line, with sorted keys and no newline inside. It stays under 4 KB. */
13
+ export function journalLine(sink, run) {
14
+ const signals = new Set(sink.signals);
15
+ if (run.exit === 2)
16
+ signals.add("usage-error");
17
+ if (run.exit === 3)
18
+ signals.add("internal-error");
19
+ const rec = {
20
+ at: run.at ?? new Date().toISOString(),
21
+ cmd: sink.cmd !== undefined && COMMANDS.includes(sink.cmd) ? sink.cmd : "?",
22
+ exit: run.exit,
23
+ ms: Math.max(0, Math.round(run.ms)),
24
+ signals: SIGNALS.filter((s) => signals.has(s)),
25
+ v: 1,
26
+ version: run.version,
27
+ };
28
+ if (sink.err && run.exit === 3)
29
+ rec.err = { code: sink.err.code, frames: sink.err.frames, name: sink.err.name };
30
+ let line = JSON.stringify(JSON.parse(dumps(rec)));
31
+ if (line.length > MAX_LINE && sink.err) {
32
+ rec.err = { code: sink.err.code, frames: sink.err.frames.slice(0, 5), name: sink.err.name };
33
+ line = JSON.stringify(JSON.parse(dumps(rec)));
34
+ }
35
+ if (line.length > MAX_LINE) {
36
+ delete rec.err;
37
+ line = JSON.stringify(JSON.parse(dumps(rec)));
38
+ }
39
+ return line;
40
+ }
41
+ /** REQ-JRN-003 append one line. Any failure stays silent. */
42
+ export function appendRun(sink, run) {
43
+ try {
44
+ if (process.env.SPECCTX_JOURNAL === "0")
45
+ return;
46
+ const root = sink.root;
47
+ if (!root)
48
+ return;
49
+ const os = join(root, "openspec");
50
+ if (!existsSync(os) || !statSync(os).isDirectory())
51
+ return;
52
+ const dir = join(root, JOURNAL_DIR);
53
+ mkdirSync(dir, { recursive: true });
54
+ const ignore = join(dir, ".gitignore");
55
+ if (!existsSync(ignore))
56
+ writeFileSync(ignore, "*\n");
57
+ const file = join(dir, JOURNAL_FILE);
58
+ if (existsSync(file) && statSync(file).size > MAX_BYTES)
59
+ renameSync(file, join(dir, ROTATED_FILE));
60
+ appendFileSync(file, journalLine(sink, run) + "\n");
61
+ }
62
+ catch {
63
+ // the journal never changes the exit code or the output
64
+ }
65
+ }
@@ -0,0 +1,57 @@
1
+ // REQ-JRN-001 REQ-JRN-002 the sink that main() fills. It has no fs import, so a --version run loads no write code (057/D1).
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** REQ-JRN-002 the closed set of signals. The line version `v` versions it. */
5
+ export const SIGNALS = ["init-ok", "context-has-code-or-test", "empty-context", "truncated-context", "usage-error", "internal-error"];
6
+ /** REQ-JRN-001 fill the sink. It does nothing when the caller passed no sink. */
7
+ export function note(io, patch) {
8
+ const s = io.journal;
9
+ if (!s)
10
+ return;
11
+ if (patch.root !== undefined)
12
+ s.root = patch.root;
13
+ if (patch.cmd !== undefined)
14
+ s.cmd = patch.cmd;
15
+ if (patch.run !== undefined)
16
+ s.run = patch.run;
17
+ if (patch.signal !== undefined && SIGNALS.includes(patch.signal))
18
+ s.signals.add(patch.signal);
19
+ if (patch.err !== undefined)
20
+ s.err = patch.err;
21
+ }
22
+ /** The package root: two folders above src/journal or dist/journal. */
23
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
24
+ /** REQ-JRN-002 name, code, and package frames of an error. Never the message (057/D4). */
25
+ export function errInfo(e, pkgRoot = PACKAGE_ROOT) {
26
+ if (!(e instanceof Error))
27
+ return { name: "NonError", code: null, frames: [] };
28
+ const raw = e.code;
29
+ const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]*$/.test(raw) ? raw : null;
30
+ const frames = [];
31
+ // stack line 1 holds the message, so it never counts
32
+ for (const line of (e.stack ?? "").split("\n").slice(1)) {
33
+ if (!/^\s+at /.test(line))
34
+ continue;
35
+ const m = /\(?((?:file:\/\/)?[^()\s]+):(\d+):\d+\)?$/.exec(line);
36
+ if (!m)
37
+ continue;
38
+ let file = m[1];
39
+ try {
40
+ if (file.startsWith("file://"))
41
+ file = fileURLToPath(file);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ // REQ-JRN-007 a frame like node:internal/... or <anonymous> is no path in the package
47
+ if (!isAbsolute(file))
48
+ continue;
49
+ const rel = relative(pkgRoot, file);
50
+ if (rel === "" || rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules") || resolve(rel) === rel)
51
+ continue;
52
+ frames.push(`${rel.split("\\").join("/")}:${m[2]}`);
53
+ if (frames.length === 10)
54
+ break;
55
+ }
56
+ return { name: e.name, code, frames };
57
+ }