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.
- package/README.md +114 -264
- package/dist/check/check.js +63 -23
- package/dist/cli/args.js +1 -0
- package/dist/cli/commands.js +32 -5
- package/dist/cli/help.js +7 -2
- package/dist/cli/workflow.js +8 -3
- package/dist/cli.js +45 -6
- package/dist/graph/bundle.js +20 -6
- package/dist/journal/journal.js +65 -0
- package/dist/journal/note.js +54 -0
- package/dist/journal/report.js +115 -0
- package/dist/journal/signals.js +26 -0
- package/dist/loop/fails.js +61 -10
- package/dist/main.js +16 -1
- package/dist/tasks/evidence.js +3 -3
- package/dist/util/redact.js +57 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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>", "
|
|
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>", "
|
|
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",
|
package/dist/cli/workflow.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
package/dist/graph/bundle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
195
|
-
const keep = blocks.map(() =>
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|