spec-ctx 0.4.1 → 0.5.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.
@@ -6,6 +6,7 @@ import { defaults } from "../config.js";
6
6
  import { hintOf } from "../check/hints.js";
7
7
  import { paint } from "./color.js";
8
8
  import { runCommand } from "../runcmd.js";
9
+ import { note } from "../journal/note.js";
9
10
  /** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
10
11
  async function runCheck(root, cfg, data, strict) {
11
12
  const { check } = await import("../check/check.js");
@@ -28,12 +29,13 @@ async function runCheck(root, cfg, data, strict) {
28
29
  * REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
29
30
  * A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
30
31
  */
31
- export function checkText(r, tty, run = "specctx") {
32
+ export function checkText(r, tty, run = "specctx", deferred = []) {
32
33
  // REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
33
34
  // REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
34
35
  const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
35
36
  + r.warnings.map((w) => `warning: ${w}\n`).join("");
36
- const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
37
+ // REQ-CHK-010 a deferred line is not a finding. It comes before the baselined line, which stays last (052/D3).
38
+ const base = deferred.map((d) => `deferred: ${d.change} ${d.untested} untested delta item${d.untested === 1 ? "" : "s"}, ${d.open} open task${d.open === 1 ? "" : "s"}\n`).join("") + (r.baselined ? `baselined: ${r.baselined}\n` : "");
37
39
  const n = r.findings.length + r.warnings.length;
38
40
  if (!n)
39
41
  return paint("dim", "0 findings\n", tty) + base;
@@ -75,8 +77,21 @@ export async function run(p, io) {
75
77
  return init(p, io);
76
78
  const root = findRoot(io.cwd, f.root);
77
79
  requireOpenSpec(root);
80
+ // REQ-JRN-001 the root is known now, so the bin entry may write the journal
81
+ note(io, { root });
82
+ // REQ-JRN-004 report runs before the config load, so a broken config still reports
83
+ if (cmd === "report") {
84
+ if (p.positional.length)
85
+ throw new UsageExit("report takes no arguments");
86
+ const { buildReport } = await import("../journal/report.js");
87
+ const { VERSION } = await import("../version.js");
88
+ io.out(buildReport(root, new Date(), { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }).text);
89
+ return 0;
90
+ }
78
91
  const { loadConfigOrExit } = await import("./config.js");
79
92
  const cfg = loadConfigOrExit(root);
93
+ // REQ-JRN-006 the exit 3 line names the report command with this prefix
94
+ note(io, { run: cfg.cli.run });
80
95
  const { dumps } = await import("../util/json.js");
81
96
  const print = (v) => io.out(dumps(v) + "\n");
82
97
  if (cmd === "build") {
@@ -287,6 +302,9 @@ export async function run(p, io) {
287
302
  const { bundle } = await import("../graph/bundle.js");
288
303
  const { resolveInputs } = await import("../graph/input.js");
289
304
  const ids = canon(entries);
305
+ // REQ-QRY-018 a negative budget is a usage error (050/D11)
306
+ if (f.budget !== undefined && f.budget < 0)
307
+ throw new UsageExit("--budget must be 0 or more");
290
308
  // REQ-QRY-005 an input that is no known ID is a path in the repository
291
309
  const { wanted, paths, errors, taskId } = resolveInputs(root, io.cwd, entries, p.positional);
292
310
  if (errors.length) {
@@ -302,8 +320,9 @@ export async function run(p, io) {
302
320
  }
303
321
  const { reqBlocks } = await import("../graph/bundle.js");
304
322
  const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
323
+ // REQ-QRY-019 block mode has no budget, so budget is null and the text is complete (050/D6)
305
324
  if (json)
306
- print({ text, omitted: [] });
325
+ print({ text, omitted: [], chars: (await import("../util/pystr.js")).cpLen(text), budget: null, complete: true });
307
326
  else
308
327
  io.out(text);
309
328
  return 0;
@@ -311,6 +330,12 @@ export async function run(p, io) {
311
330
  // 047/D5 a task input passes the entry that findTask picks, so an active task wins over an archived one
312
331
  const task = taskId === undefined ? undefined : (await import("../tasks/tasks.js")).findTask(root, entries, taskId).entry;
313
332
  const b = bundle(root, entries, wanted, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, { all, task });
333
+ // REQ-JRN-002 only context emits the block signals. The sink is set only for the bin entry.
334
+ if (cmd === "context" && io.journal) {
335
+ const { contextSignals } = await import("../journal/signals.js");
336
+ for (const signal of contextSignals(entries, b))
337
+ note(io, { signal });
338
+ }
314
339
  if (json)
315
340
  print(b);
316
341
  else if (b.text === "" && wanted.every((w) => paths.has(w)))
@@ -343,10 +368,12 @@ export async function run(p, io) {
343
368
  const r = await runCheck(root, cfg, data, !!f.strict);
344
369
  // REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
345
370
  const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
371
+ // REQ-CHK-010 only this case computes the deferred notice. runCheck and gate stay as they are (052/D5).
372
+ const deferred = f.strict ? (await import("../check/check.js")).deferredOf(entries, data) : [];
346
373
  if (json)
347
- print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
374
+ print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings, ...(f.strict ? { deferred } : {}) });
348
375
  else
349
- io.out(checkText(r, io.tty, cfg.cli.run));
376
+ io.out(checkText(r, io.tty, cfg.cli.run, deferred));
350
377
  return r.findings.length ? 1 : 0;
351
378
  }
352
379
  }
package/dist/cli/help.js CHANGED
@@ -40,7 +40,7 @@ export const TABLE = [
40
40
  {
41
41
  name: "context", group: "Read", args: "<task|ID|path|change>", summary: "Print what an agent needs before it edits a task, an ID, a file, or a change.",
42
42
  flags: [
43
- ["--budget <n>", "Stop at n characters. The default is 8000."],
43
+ ["--budget <n>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. The default is 8000."],
44
44
  ["--depth <n>", "Follow links n steps. The default is 1."],
45
45
  ],
46
46
  exit: [OK, ["2", "A usage error, an unknown ID, or a bad path. It takes one input. Use `bundle` for several inputs or for `--block`."], INTERNAL], example: "specctx context 045/2.1",
@@ -53,7 +53,7 @@ export const TABLE = [
53
53
  {
54
54
  name: "bundle", group: "Read", args: "<ID|path|change...>", summary: "Print the spec text for the IDs, files, or changes and their links.",
55
55
  flags: [
56
- ["--budget <n>", "Stop at n characters. The default is 8000."],
56
+ ["--budget <n>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. The default is 8000."],
57
57
  ["--depth <n>", "Follow links n steps. The default is 1."],
58
58
  ["--all", "Show all copies of an ID."],
59
59
  ["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
@@ -90,6 +90,11 @@ export const TABLE = [
90
90
  flags: [["--change <name>", "Show only this change."], ["--write", "Also write docs/metrics/<date>.md."]],
91
91
  exit: PLAIN, example: "specctx stats --write",
92
92
  },
93
+ {
94
+ name: "report", group: "Read", args: "", summary: "Print a bug report from the run journal in .ctx/local. Review it, then file it with `gh issue create`. Write nothing.",
95
+ flags: [],
96
+ exit: [OK, ["2", "A usage error."], INTERNAL], example: "specctx report > report.md",
97
+ },
93
98
  {
94
99
  name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
95
100
  flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint tells --baseline 0 README.md",
@@ -171,10 +171,13 @@ export async function runWorkflow(c) {
171
171
  // REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
172
172
  const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
173
173
  const { addQuestion } = await import("../handoff/questions.js");
174
- const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
174
+ // REQ-TASK-008 the fail file and the cap question hold no machine path. The printed tail stays as it is (056/D5).
175
+ const { redactPaths } = await import("../util/redact.js");
176
+ const kept = redactPaths(outTail, c.root);
177
+ const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, kept);
175
178
  let question = openQuestionFor(c.root, t.id)?.n ?? null;
176
179
  if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
177
- question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail), "cap").n;
180
+ question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, kept), "cap").n;
178
181
  }
179
182
  if (c.json)
180
183
  print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
@@ -196,8 +199,10 @@ export async function runWorkflow(c) {
196
199
  // the Verify command can change tagged files, so the digests come from the tree after the run
197
200
  const { loadIndex } = await import("../index/build.js");
198
201
  const after = loadIndex(c.root, c.cfg).data.entries;
202
+ // REQ-TASK-008 the stderr tail holds no machine path. verify.command stays as the task line has it (056/D5, 056/D6).
203
+ const { redactPaths } = await import("../util/redact.js");
199
204
  const rel = writeEvidence(c.root, t, after, {
200
- command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: tail(r.stderr.toString("utf8")),
205
+ command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: redactPaths(tail(r.stderr.toString("utf8")), c.root),
201
206
  }, "command", undefined, t.verify.cwd);
202
207
  setBox(c.root, t, true);
203
208
  clearCurrent(c.root, t.id);
package/dist/cli.js CHANGED
@@ -1,14 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  // REQ-CLI-001 bin entry. tests call main() from src/main.ts in process.
3
+ // REQ-JRN-001 this entry writes the run journal after main() returns. main() only fills the sink (057/D1).
3
4
  import { main } from "./main.js";
5
+ import { VERSION } from "./version.js";
4
6
  process.stdout.on("error", (e) => {
5
7
  if (e.code === "EPIPE")
6
8
  process.exit(0);
7
9
  });
8
- const code = await main(process.argv.slice(2), {
9
- cwd: process.cwd(),
10
- out: (s) => process.stdout.write(s),
11
- err: (s) => process.stderr.write(s),
12
- tty: process.stdout.isTTY === true,
13
- });
10
+ const argv = process.argv.slice(2);
11
+ const sink = { signals: new Set() };
12
+ const t0 = performance.now();
13
+ let code;
14
+ let thrown;
15
+ let failed = false;
16
+ try {
17
+ code = await main(argv, {
18
+ cwd: process.cwd(),
19
+ out: (s) => process.stdout.write(s),
20
+ err: (s) => process.stderr.write(s),
21
+ tty: process.stdout.isTTY === true,
22
+ journal: sink,
23
+ });
24
+ }
25
+ catch (e) {
26
+ failed = true;
27
+ thrown = e;
28
+ code = 3;
29
+ try {
30
+ const { errInfo } = await import("./journal/note.js");
31
+ sink.err = errInfo(e);
32
+ }
33
+ catch {
34
+ // no journal detail
35
+ }
36
+ }
37
+ try {
38
+ // REQ-JRN-001 a usage error before the root check keeps no root. Only the "?" command with no --root looks for one (057/D2).
39
+ if (sink.root === undefined && sink.cmd === "?" && !argv.some((a) => a === "--root" || a.startsWith("--root="))) {
40
+ const { findRoot } = await import("./cli/root.js");
41
+ sink.root = findRoot(process.cwd());
42
+ }
43
+ if (sink.root !== undefined && process.env.SPECCTX_JOURNAL !== "0") {
44
+ const { appendRun } = await import("./journal/journal.js");
45
+ appendRun(sink, { exit: code, ms: performance.now() - t0, version: VERSION });
46
+ }
47
+ }
48
+ catch {
49
+ // the journal never changes the exit code or the output
50
+ }
51
+ if (failed)
52
+ throw thrown;
14
53
  process.exitCode = code;
@@ -45,6 +45,11 @@ function rstrip(s) {
45
45
  export function provenance(e, reason) {
46
46
  return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
47
47
  }
48
+ /** REQ-JRN-002 the ID of a provenance line, or null. It anchors on the start of `provenance()` (057/D3). */
49
+ export function parseProvenance(line) {
50
+ const m = /^-- (\S+) \| /.exec(line);
51
+ return m ? m[1] : null;
52
+ }
48
53
  /** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
49
54
  export function reqBlocks(root, reqs) {
50
55
  const files = new Lines(root);
@@ -132,12 +137,15 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
132
137
  if (m)
133
138
  blocks.push([m, `main version of ${i}`, 0]);
134
139
  }
140
+ // REQ-QRY-018 the count of blocks that the loop above pushed. They print first and always stay.
141
+ const pinned = blocks.length;
135
142
  const found = new Map();
136
143
  for (const i of wanted) {
137
144
  // decisions that reference a requested ID are included at every depth, as depth 1.
138
145
  // REQ-QRY-016 They replace a deeper hit, which the walk for an earlier requested ID can leave (048/D1).
139
146
  for (const e of entries) {
140
- if (e.kind !== "decision" || !e.refs.includes(i))
147
+ // REQ-QRY-014 a requested decision prints once, as requested (050/D10)
148
+ if (e.kind !== "decision" || !e.refs.includes(i) || wanted.includes(e.id))
141
149
  continue;
142
150
  const cur = found.get(e.id);
143
151
  if (!cur || cur.depth > 1)
@@ -161,7 +169,7 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
161
169
  }
162
170
  for (const c of r.cited) {
163
171
  for (const e of entries) {
164
- if (e.kind !== "decision" || !e.refs.includes(c))
172
+ if (e.kind !== "decision" || !e.refs.includes(c) || wanted.includes(e.id))
165
173
  continue;
166
174
  const cur = found.get(e.id);
167
175
  if (!cur || cur.depth > 1)
@@ -191,11 +199,14 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
191
199
  // a blank line closes a block with a body. Code and test paths stay one line each.
192
200
  return provenance(e, reason) + body + (body ? "\n" : "");
193
201
  });
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);
202
+ // REQ-QRY-018 the blocks of the first loop always stay: each requested block and the main version of a requested ID (050/D2, 050/D3)
203
+ const keep = blocks.map((_, k) => k < pinned);
196
204
  let used = 0;
205
+ for (let k = 0; k < pinned; k++)
206
+ used += cpLen(texts[k]);
207
+ // fill the budget class by class, in print order inside a class, and skip a block that does not fit
197
208
  for (let cls = 0; cls <= 3; cls++) {
198
- for (let k = 0; k < blocks.length; k++) {
209
+ for (let k = pinned; k < blocks.length; k++) {
199
210
  if (blocks[k][2] !== cls)
200
211
  continue;
201
212
  const n = cpLen(texts[k]);
@@ -209,5 +220,8 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
209
220
  let text = texts.filter((_, k) => keep[k]).join("");
210
221
  if (omitted.length)
211
222
  text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
212
- return { text, omitted };
223
+ // REQ-QRY-018 the over budget line goes last, so the 0.4.1 output stays a prefix (050/D4)
224
+ if (used > budget)
225
+ text += `over budget: ${used} of ${budget} characters\n`;
226
+ return { text, omitted, chars: cpLen(text), budget, complete: omitted.length === 0 };
213
227
  }
@@ -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,54 @@
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, 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
+ const rel = relative(pkgRoot, file);
47
+ if (rel === "" || rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules") || resolve(rel) === rel)
48
+ continue;
49
+ frames.push(`${rel.split("\\").join("/")}:${m[2]}`);
50
+ if (frames.length === 10)
51
+ break;
52
+ }
53
+ return { name: e.name, code, frames };
54
+ }
@@ -0,0 +1,115 @@
1
+ // REQ-JRN-004 REQ-JRN-005 the bug report. The only reader of the journal. It never runs gh and never uses the network (057/D6).
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "smol-toml";
5
+ import { JOURNAL_DIR, JOURNAL_FILE, ROTATED_FILE } from "./journal.js";
6
+ import { SIGNALS } from "./note.js";
7
+ export const NO_JOURNAL = `no journal found under ${JOURNAL_DIR}\n`;
8
+ function readLines(path) {
9
+ if (!existsSync(path))
10
+ return [];
11
+ const out = [];
12
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
13
+ if (!raw.trim())
14
+ continue;
15
+ try {
16
+ const v = JSON.parse(raw);
17
+ if (v === null || typeof v !== "object" || v.v !== 1)
18
+ continue;
19
+ if (typeof v.at !== "string" || Number.isNaN(Date.parse(v.at)) || typeof v.cmd !== "string")
20
+ continue;
21
+ if (typeof v.exit !== "number" || typeof v.ms !== "number" || !Array.isArray(v.signals))
22
+ continue;
23
+ const line = { at: v.at, cmd: v.cmd, exit: v.exit, ms: v.ms, signals: v.signals.filter((s) => typeof s === "string") };
24
+ const e = v.err;
25
+ if (e && typeof e === "object") {
26
+ line.err = {
27
+ name: typeof e.name === "string" && /^\w+$/.test(e.name) ? e.name : "?",
28
+ code: typeof e.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(e.code) ? e.code : null,
29
+ frames: Array.isArray(e.frames) ? e.frames.filter((f) => typeof f === "string" && /^[\w./-]+:\d+$/.test(f)) : [],
30
+ };
31
+ }
32
+ out.push(line);
33
+ }
34
+ catch {
35
+ // a line that is not JSON is skipped
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ /** REQ-JRN-004 section and key names of spec-ctx.toml, with no value. */
41
+ export function configShape(root) {
42
+ const path = join(root, "spec-ctx.toml");
43
+ if (!existsSync(path))
44
+ return ["none (no spec-ctx.toml)"];
45
+ let doc;
46
+ try {
47
+ doc = parse(readFileSync(path, "utf8"));
48
+ }
49
+ catch {
50
+ return ["none (spec-ctx.toml does not parse)"];
51
+ }
52
+ const rows = [];
53
+ const top = Object.keys(doc).filter((k) => !isTable(doc[k]));
54
+ if (top.length)
55
+ rows.push(`(top level): ${top.join(", ")}`);
56
+ const walk = (name, t) => {
57
+ const keys = Object.keys(t).filter((k) => !isTable(t[k]));
58
+ rows.push(`[${name}]: ${keys.length ? keys.join(", ") : "no keys"}`);
59
+ for (const k of Object.keys(t))
60
+ if (isTable(t[k]))
61
+ walk(`${name}.${k}`, t[k]);
62
+ };
63
+ for (const k of Object.keys(doc))
64
+ if (isTable(doc[k]))
65
+ walk(k, doc[k]);
66
+ return rows.length ? rows : ["none (empty file)"];
67
+ }
68
+ function isTable(v) {
69
+ return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
70
+ }
71
+ /** Nearest rank. */
72
+ export function p95(values) {
73
+ const s = [...values].sort((a, b) => a - b);
74
+ return s[Math.max(0, Math.ceil(0.95 * s.length) - 1)];
75
+ }
76
+ /** REQ-JRN-004 the Markdown report. It is a pure function of the journal files and the config. */
77
+ export function buildReport(root, now, sys) {
78
+ const dir = join(root, JOURNAL_DIR);
79
+ if (!existsSync(join(dir, JOURNAL_FILE)) && !existsSync(join(dir, ROTATED_FILE)))
80
+ return { found: false, text: NO_JOURNAL };
81
+ const lines = [...readLines(join(dir, ROTATED_FILE)), ...readLines(join(dir, JOURNAL_FILE))];
82
+ const md = ["# spec-ctx report", "", `Generated: ${now.toISOString()}`, ""];
83
+ md.push("## Versions", "", `- spec-ctx: ${sys.version}`, `- Node: ${sys.node}`, `- OS: ${sys.platform} ${sys.arch}`, "");
84
+ md.push("## Config shape", "", ...configShape(root).map((r) => `- ${r}`), "");
85
+ const i = lines.findIndex((l) => l.signals.includes("init-ok"));
86
+ const j = i < 0 ? -1 : lines.findIndex((l, k) => k > i && l.signals.includes("context-has-code-or-test"));
87
+ md.push("## North star", "");
88
+ if (j < 0)
89
+ md.push("- Minutes from the first `init-ok` to the first `context-has-code-or-test`: n/a", "- Runs between them: n/a", "");
90
+ else {
91
+ const mins = (Date.parse(lines[j].at) - Date.parse(lines[i].at)) / 60000;
92
+ md.push(`- Minutes from the first \`init-ok\` to the first \`context-has-code-or-test\`: ${mins.toFixed(1)}`, `- Runs between them: ${j - i}`, "");
93
+ }
94
+ md.push(`## Runs`, "", `- Total: ${lines.length}`, "");
95
+ const byCmd = new Map();
96
+ for (const l of lines)
97
+ byCmd.set(l.cmd, [...(byCmd.get(l.cmd) ?? []), l.ms]);
98
+ md.push("## Time", "", "| cmd | runs | p95 ms |", "| --- | --- | --- |");
99
+ for (const c of [...byCmd.keys()].sort())
100
+ md.push(`| ${c.replace(/[^\w ?-]/g, "?")} | ${byCmd.get(c).length} | ${p95(byCmd.get(c))} |`);
101
+ md.push("", "## Signals", "");
102
+ for (const s of SIGNALS)
103
+ md.push(`- ${s}: ${lines.filter((l) => l.signals.includes(s)).length}`);
104
+ md.push("", "## Last internal error", "");
105
+ const last = [...lines].reverse().find((l) => l.err !== undefined);
106
+ if (!last?.err)
107
+ md.push("- none");
108
+ else {
109
+ md.push(`- name: ${last.err.name}`, `- code: ${last.err.code ?? "none"}`, "- frames:", ...last.err.frames.map((f) => ` - ${f}`));
110
+ if (!last.err.frames.length)
111
+ md.push(" - none");
112
+ }
113
+ md.push("", "Review this text, then file it. Run: `gh issue create -R sonqh/spec-ctx --body-file <file>`", "");
114
+ return { found: true, text: md.join("\n") };
115
+ }
@@ -0,0 +1,26 @@
1
+ // REQ-JRN-002 the signals of one context run. They read the provenance lines of the printed text (057/D3).
2
+ import { parseProvenance } from "../graph/bundle.js";
3
+ import { canon } from "../index/canonical.js";
4
+ /** Only `context` calls this. No provenance line gives empty-context. A kept code or test block gives context-has-code-or-test. */
5
+ export function contextSignals(entries, b) {
6
+ const ids = canon(entries);
7
+ const out = [];
8
+ let lines = 0;
9
+ let line = false;
10
+ for (const l of b.text.split("\n")) {
11
+ const id = parseProvenance(l);
12
+ if (id === null)
13
+ continue;
14
+ lines++;
15
+ const k = ids.get(id)?.kind;
16
+ if (k === "code" || k === "test")
17
+ line = true;
18
+ }
19
+ if (lines === 0)
20
+ out.push("empty-context");
21
+ if (line)
22
+ out.push("context-has-code-or-test");
23
+ if (!b.complete)
24
+ out.push("truncated-context");
25
+ return out;
26
+ }