spec-ctx 0.4.0 → 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 +121 -199
- package/dist/adopt/write.js +6 -2
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +63 -23
- package/dist/check/hints.js +10 -9
- package/dist/cli/args.js +3 -1
- package/dist/cli/commands.js +96 -23
- package/dist/cli/help.js +23 -6
- package/dist/cli/workflow.js +31 -12
- package/dist/cli.js +45 -6
- package/dist/config.js +8 -0
- package/dist/graph/bundle.js +155 -27
- package/dist/graph/files.js +31 -0
- package/dist/graph/input.js +37 -0
- package/dist/graph/links.js +96 -0
- package/dist/graph/path.js +54 -0
- package/dist/graph/query.js +3 -0
- package/dist/graph/related.js +58 -11
- package/dist/handoff/handoff.js +6 -4
- package/dist/handoff/questions.js +9 -1
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +36 -9
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +15 -4
- 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 +71 -12
- package/dist/loop/next.js +38 -17
- package/dist/main.js +16 -1
- package/dist/runcmd.js +5 -0
- 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/skills/specctx-run/SKILL.md +3 -3
package/dist/check/hints.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// REQ-CHK-004 the rules of check and one fix hint per rule. The check summary prints the hint of each rule it found.
|
|
2
2
|
/** `hard` rules set exit 1. `advisory` rules print a warning and keep exit 0. */
|
|
3
3
|
export const RULES = {
|
|
4
|
-
"missing-id": { level: "hard", hint: "Add an ID to the header. Run
|
|
4
|
+
"missing-id": { level: "hard", hint: "Add an ID to the header. Run {run} adopt --dry-run to see the plan." },
|
|
5
5
|
"scenario-id-mismatch": { level: "hard", hint: "Give the scenario the number of its requirement." },
|
|
6
6
|
"bad-ref": { level: "hard", hint: "Write only IDs on the Refs line, separated by commas." },
|
|
7
7
|
"task-without-req": { level: "hard", hint: "Cite a requirement in brackets on the task line, for example [REQ-ABC-001]." },
|
|
8
8
|
"task-without-verify": { level: "hard", hint: "Add Verify: and a command in backticks to the task line." },
|
|
9
|
-
"duplicate-id": { level: "hard", hint: "Give one of the two entries a new ID from
|
|
9
|
+
"duplicate-id": { level: "hard", hint: "Give one of the two entries a new ID from {run} id new." },
|
|
10
10
|
"duplicate-change-key": { level: "hard", hint: "Rename one change folder, so that each key names one change." },
|
|
11
|
-
"reused-id": { level: "hard", hint: "The ID is retired. Get a new ID from
|
|
11
|
+
"reused-id": { level: "hard", hint: "The ID is retired. Get a new ID from {run} id new." },
|
|
12
12
|
// REQ-CHK-007 the hint names the string literal cause and the fix (033/D3)
|
|
13
13
|
"dangling-ref": {
|
|
14
14
|
level: "hard",
|
|
@@ -20,13 +20,13 @@ export const RULES = {
|
|
|
20
20
|
"unknown-cap": { level: "hard", hint: "Add the code to caps under [ids] in spec-ctx.toml." },
|
|
21
21
|
uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
|
|
22
22
|
"scenario-untested": { level: "hard", hint: "Name the scenario ID in a test with a // Covers: <SCN> tag." },
|
|
23
|
-
"done-without-evidence": { level: "hard", hint: "Run
|
|
23
|
+
"done-without-evidence": { level: "hard", hint: "Run {run} task done. It runs the Verify command and writes the evidence." },
|
|
24
24
|
// REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
|
|
25
|
-
"stale-evidence": { level: "hard", hint: "Run
|
|
26
|
-
"evidence-without-done": { level: "hard", hint: "Run
|
|
25
|
+
"stale-evidence": { level: "hard", hint: "Run {run} task reopen <task> <why>, then {run} task done <task>." },
|
|
26
|
+
"evidence-without-done": { level: "hard", hint: "Run {run} task done, or remove the evidence file." },
|
|
27
27
|
// REQ-CHK-005 advisory in plain check. check --strict reports it as a hard finding (028/D5).
|
|
28
28
|
"weak-verify": { level: "advisory", hint: "Name a test file or a command whose exit code proves the task." },
|
|
29
|
-
"manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run
|
|
29
|
+
"manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run {run} task done again." },
|
|
30
30
|
// REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
|
|
31
31
|
// REQ-ADOPT-010 FROM copies the main header and TO copies the delta header, each in its own form (027/D4).
|
|
32
32
|
"retitle-needs-renamed": {
|
|
@@ -34,6 +34,7 @@ export const RULES = {
|
|
|
34
34
|
hint: "Add to the delta before the MODIFIED block. Write each ID as its header writes it, bare or in brackets:\n## RENAMED Requirements\n- FROM: `### Requirement: <ID> <old title>`\n- TO: `### Requirement: <ID> <new title>`",
|
|
35
35
|
},
|
|
36
36
|
};
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/** REQ-CLI-010 `{run}` in a hint stands for the run prefix of `[cli] run` (043/D1). */
|
|
38
|
+
export function hintOf(rule, run = "specctx") {
|
|
39
|
+
return (RULES[rule]?.hint ?? "Read the check rules in the check spec.").replaceAll("{run}", run);
|
|
39
40
|
}
|
package/dist/cli/args.js
CHANGED
|
@@ -8,7 +8,9 @@ export const COMMAND_FLAGS = {
|
|
|
8
8
|
query: { kind: "string", change: "string", all: "bool", limit: "int" },
|
|
9
9
|
related: { depth: "int", all: "bool" },
|
|
10
10
|
bundle: { budget: "int", depth: "int", all: "bool", block: "bool" },
|
|
11
|
+
context: { budget: "int", depth: "int" },
|
|
11
12
|
check: { strict: "bool" },
|
|
13
|
+
report: {},
|
|
12
14
|
stats: { change: "string", write: "bool" },
|
|
13
15
|
gate: { strict: "bool", markdown: "string", rerun: "bool", base: "string" },
|
|
14
16
|
"id new": { for: "string", count: "int" },
|
|
@@ -20,7 +22,7 @@ export const COMMAND_FLAGS = {
|
|
|
20
22
|
"task reopen": {},
|
|
21
23
|
"handoff write": {},
|
|
22
24
|
"handoff ask": { change: "string", plan: "bool" },
|
|
23
|
-
"handoff resolve": {},
|
|
25
|
+
"handoff resolve": { approve: "bool", reject: "bool" },
|
|
24
26
|
adopt: { "dry-run": "bool" },
|
|
25
27
|
"baseline write": {},
|
|
26
28
|
"baseline prune": {},
|
package/dist/cli/commands.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { UsageExit } from "./errors.js";
|
|
4
4
|
import { findRoot, requireOpenSpec } from "./root.js";
|
|
5
|
+
import { defaults } from "../config.js";
|
|
5
6
|
import { hintOf } from "../check/hints.js";
|
|
6
7
|
import { paint } from "./color.js";
|
|
8
|
+
import { runCommand } from "../runcmd.js";
|
|
9
|
+
import { note } from "../journal/note.js";
|
|
7
10
|
/** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
|
|
8
11
|
async function runCheck(root, cfg, data, strict) {
|
|
9
12
|
const { check } = await import("../check/check.js");
|
|
@@ -26,12 +29,13 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
26
29
|
* REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
|
|
27
30
|
* A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
|
|
28
31
|
*/
|
|
29
|
-
export function checkText(r, tty) {
|
|
32
|
+
export function checkText(r, tty, run = "specctx", deferred = []) {
|
|
30
33
|
// REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
|
|
31
34
|
// REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
|
|
32
35
|
const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
|
|
33
36
|
+ r.warnings.map((w) => `warning: ${w}\n`).join("");
|
|
34
|
-
|
|
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` : "");
|
|
35
39
|
const n = r.findings.length + r.warnings.length;
|
|
36
40
|
if (!n)
|
|
37
41
|
return paint("dim", "0 findings\n", tty) + base;
|
|
@@ -62,7 +66,7 @@ export function checkText(r, tty) {
|
|
|
62
66
|
const rules = [...count.keys()].sort();
|
|
63
67
|
return body
|
|
64
68
|
+ "\n" + paint("dim", `${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`, tty)
|
|
65
|
-
+ rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule)}\n`).join("")
|
|
69
|
+
+ rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule, run)}\n`).join("")
|
|
66
70
|
+ base;
|
|
67
71
|
}
|
|
68
72
|
export async function run(p, io) {
|
|
@@ -73,8 +77,21 @@ export async function run(p, io) {
|
|
|
73
77
|
return init(p, io);
|
|
74
78
|
const root = findRoot(io.cwd, f.root);
|
|
75
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
|
+
}
|
|
76
91
|
const { loadConfigOrExit } = await import("./config.js");
|
|
77
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 });
|
|
78
95
|
const { dumps } = await import("../util/json.js");
|
|
79
96
|
const print = (v) => io.out(dumps(v) + "\n");
|
|
80
97
|
if (cmd === "build") {
|
|
@@ -103,7 +120,7 @@ export async function run(p, io) {
|
|
|
103
120
|
io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
|
|
104
121
|
// REQ-CLI-007 the dry run ends with the next step. REQ-ADOPT-001 the exit code stays 1 and the last line says why (017/D5).
|
|
105
122
|
else
|
|
106
|
-
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next:
|
|
123
|
+
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: ${runCommand(cfg.cli.run, "adopt")}\n`);
|
|
107
124
|
return plan.edits.length ? 1 : 0;
|
|
108
125
|
}
|
|
109
126
|
if (cmd === "baseline write" || cmd === "baseline prune") {
|
|
@@ -163,7 +180,7 @@ export async function run(p, io) {
|
|
|
163
180
|
void check;
|
|
164
181
|
const data = buildIndex(root, cfg);
|
|
165
182
|
const r = await runCheck(root, cfg, data, strict);
|
|
166
|
-
return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty) };
|
|
183
|
+
return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty, cfg.cli.run) };
|
|
167
184
|
},
|
|
168
185
|
}, io);
|
|
169
186
|
}
|
|
@@ -176,12 +193,17 @@ export async function run(p, io) {
|
|
|
176
193
|
if (cmd === "query" && !f.change)
|
|
177
194
|
need(1, "at least one term");
|
|
178
195
|
if (cmd === "related") {
|
|
179
|
-
need(1, "one ID");
|
|
196
|
+
need(1, "one ID, path, or change");
|
|
180
197
|
if (p.positional.length > 1)
|
|
181
|
-
throw new UsageExit("related takes one ID");
|
|
198
|
+
throw new UsageExit("related takes one ID, path, or change");
|
|
182
199
|
}
|
|
183
200
|
if (cmd === "bundle")
|
|
184
|
-
need(1, "at least one ID");
|
|
201
|
+
need(1, "at least one ID, path, or change");
|
|
202
|
+
if (cmd === "context") {
|
|
203
|
+
need(1, "one task, ID, path, or change");
|
|
204
|
+
if (p.positional.length > 1)
|
|
205
|
+
throw new UsageExit("context takes one task, ID, path, or change");
|
|
206
|
+
}
|
|
185
207
|
if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
|
|
186
208
|
throw new UsageExit(`${cmd} takes no arguments`);
|
|
187
209
|
const { data, rebuilt } = loadIndex(root, cfg);
|
|
@@ -231,15 +253,35 @@ export async function run(p, io) {
|
|
|
231
253
|
}
|
|
232
254
|
case "related": {
|
|
233
255
|
const { canon } = await import("../index/canonical.js");
|
|
234
|
-
const { related } = await import("../graph/related.js");
|
|
235
|
-
const id = p.positional[0];
|
|
256
|
+
const { related, mainVersion } = await import("../graph/related.js");
|
|
236
257
|
const ids = canon(entries);
|
|
258
|
+
let id = p.positional[0];
|
|
259
|
+
let isPath = false;
|
|
237
260
|
if (!ids.has(id) && !(all && entries.some((e) => e.id === id))) {
|
|
238
|
-
|
|
239
|
-
|
|
261
|
+
// REQ-QRY-011 a change key or folder name is a change. REQ-QRY-005 any other input is a path in the repository.
|
|
262
|
+
const { resolveFileInput, changeInput } = await import("../graph/path.js");
|
|
263
|
+
const ch = changeInput(entries, id);
|
|
264
|
+
if (ch)
|
|
265
|
+
id = ch;
|
|
266
|
+
else {
|
|
267
|
+
const r = resolveFileInput(root, io.cwd, id);
|
|
268
|
+
if ("error" in r) {
|
|
269
|
+
io.err(`${r.error}\n`);
|
|
270
|
+
return 2;
|
|
271
|
+
}
|
|
272
|
+
id = r.rel;
|
|
273
|
+
isPath = true;
|
|
274
|
+
}
|
|
240
275
|
}
|
|
241
276
|
const rows = [];
|
|
242
277
|
for (const [i, reason] of related(entries, id, f.depth ?? 1, all)) {
|
|
278
|
+
// REQ-QRY-010 the pair of a main version stands for the non-canonical copy in the main spec
|
|
279
|
+
if (!all && reason === `main version of ${i}`) {
|
|
280
|
+
const m = mainVersion(entries, i);
|
|
281
|
+
if (m)
|
|
282
|
+
rows.push([m, reason]);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
243
285
|
const copies = all ? entries.filter((e) => e.id === i) : ids.has(i) ? [ids.get(i)] : [];
|
|
244
286
|
// canonical copy first, the rest in index order
|
|
245
287
|
copies.sort((a, b) => Number(!a.canonical) - Number(!b.canonical));
|
|
@@ -248,37 +290,56 @@ export async function run(p, io) {
|
|
|
248
290
|
}
|
|
249
291
|
if (json)
|
|
250
292
|
print(rows.map(([e, reason]) => ({ ...e, reason })));
|
|
293
|
+
else if (isPath && rows.length === 0)
|
|
294
|
+
io.out(`no links: ${id}\n`);
|
|
251
295
|
else
|
|
252
296
|
io.out(rows.map(([e, r]) => `${e.kind} ${e.id} ${e.status} ${e.file}:${e.line} ${r}\n`).join(""));
|
|
253
297
|
return 0;
|
|
254
298
|
}
|
|
299
|
+
case "context":
|
|
255
300
|
case "bundle": {
|
|
256
301
|
const { canon } = await import("../index/canonical.js");
|
|
257
302
|
const { bundle } = await import("../graph/bundle.js");
|
|
303
|
+
const { resolveInputs } = await import("../graph/input.js");
|
|
258
304
|
const ids = canon(entries);
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
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");
|
|
308
|
+
// REQ-QRY-005 an input that is no known ID is a path in the repository
|
|
309
|
+
const { wanted, paths, errors, taskId } = resolveInputs(root, io.cwd, entries, p.positional);
|
|
310
|
+
if (errors.length) {
|
|
311
|
+
io.err(errors.map((l) => l + "\n").join(""));
|
|
262
312
|
return 2;
|
|
263
313
|
}
|
|
264
314
|
// REQ-QRY-004 --block prints the requirement blocks verbatim and nothing else (034/D2)
|
|
265
315
|
if (f.block) {
|
|
266
|
-
const notReq = p.positional.filter((i) => ids.get(i)
|
|
316
|
+
const notReq = p.positional.filter((i) => ids.get(i)?.kind !== "req");
|
|
267
317
|
if (notReq.length) {
|
|
268
318
|
io.err(`not a requirement: ${notReq.join(", ")}\n`);
|
|
269
319
|
return 2;
|
|
270
320
|
}
|
|
271
321
|
const { reqBlocks } = await import("../graph/bundle.js");
|
|
272
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)
|
|
273
324
|
if (json)
|
|
274
|
-
print({ text, omitted: [] });
|
|
325
|
+
print({ text, omitted: [], chars: (await import("../util/pystr.js")).cpLen(text), budget: null, complete: true });
|
|
275
326
|
else
|
|
276
327
|
io.out(text);
|
|
277
328
|
return 0;
|
|
278
329
|
}
|
|
279
|
-
|
|
330
|
+
// 047/D5 a task input passes the entry that findTask picks, so an active task wins over an archived one
|
|
331
|
+
const task = taskId === undefined ? undefined : (await import("../tasks/tasks.js")).findTask(root, entries, taskId).entry;
|
|
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
|
+
}
|
|
280
339
|
if (json)
|
|
281
340
|
print(b);
|
|
341
|
+
else if (b.text === "" && wanted.every((w) => paths.has(w)))
|
|
342
|
+
io.out(wanted.map((w) => `no links: ${w}\n`).join(""));
|
|
282
343
|
else
|
|
283
344
|
io.out(b.text);
|
|
284
345
|
return 0;
|
|
@@ -307,10 +368,12 @@ export async function run(p, io) {
|
|
|
307
368
|
const r = await runCheck(root, cfg, data, !!f.strict);
|
|
308
369
|
// REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
|
|
309
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) : [];
|
|
310
373
|
if (json)
|
|
311
|
-
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 } : {}) });
|
|
312
375
|
else
|
|
313
|
-
io.out(checkText(r, io.tty));
|
|
376
|
+
io.out(checkText(r, io.tty, cfg.cli.run, deferred));
|
|
314
377
|
return r.findings.length ? 1 : 0;
|
|
315
378
|
}
|
|
316
379
|
}
|
|
@@ -323,18 +386,28 @@ async function init(p, io) {
|
|
|
323
386
|
const root = resolve(io.cwd, p.flags.root ?? ".");
|
|
324
387
|
const all = !!p.flags.all;
|
|
325
388
|
const r = runInit(root, { schema: all || !!p.flags.schema, skills: all || !!p.flags.skills, force: !!p.flags.force });
|
|
389
|
+
// REQ-CLI-010 init prints its next step with the run prefix of the config it wrote or kept (043/D1)
|
|
390
|
+
const { loadConfigOrExit } = await import("./config.js");
|
|
391
|
+
const run = (() => {
|
|
392
|
+
try {
|
|
393
|
+
return loadConfigOrExit(root).cli.run;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return defaults().cli.run;
|
|
397
|
+
}
|
|
398
|
+
})();
|
|
326
399
|
if (p.flags.json)
|
|
327
400
|
io.out(JSON.stringify(r, null, 2) + "\n");
|
|
328
401
|
else if (r.nothing)
|
|
329
|
-
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim",
|
|
402
|
+
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", `next: ${runCommand(run, "status")}\n`, io.tty));
|
|
330
403
|
else {
|
|
331
404
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
332
405
|
io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
|
|
333
406
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
334
407
|
io.out(r.notes.map((n) => `${n}\n`).join(""));
|
|
335
408
|
// REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
|
|
336
|
-
const again = ["
|
|
337
|
-
io.out(paint("dim", `next: ${r.changed.length ? again : "
|
|
409
|
+
const again = [runCommand(run, "init"), all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
|
|
410
|
+
io.out(paint("dim", `next: ${r.changed.length ? again : runCommand(run, "status")}\n`, io.tty));
|
|
338
411
|
}
|
|
339
412
|
return 0;
|
|
340
413
|
}
|
package/dist/cli/help.js
CHANGED
|
@@ -38,19 +38,27 @@ export const TABLE = [
|
|
|
38
38
|
exit: PLAIN, example: "specctx query lockfile --kind req",
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
|
-
name: "
|
|
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
|
+
flags: [
|
|
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
|
+
["--depth <n>", "Follow links n steps. The default is 1."],
|
|
45
|
+
],
|
|
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",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "related", group: "Read", args: "<ID|path|change>", summary: "Show the entries that link to an ID, a file, or a change.",
|
|
42
50
|
flags: [["--depth <n>", "Follow links n steps. The default is 1."], ["--all", "Show all copies of an ID."]],
|
|
43
|
-
exit: [OK, ["2", "A usage error
|
|
51
|
+
exit: [OK, ["2", "A usage error, an unknown ID, or a path that is not a file in the repository."], INTERNAL], example: "specctx related src/graph/bundle.ts",
|
|
44
52
|
},
|
|
45
53
|
{
|
|
46
|
-
name: "bundle", group: "Read", args: "<ID...>", summary: "Print the spec text for the IDs and their links.",
|
|
54
|
+
name: "bundle", group: "Read", args: "<ID|path|change...>", summary: "Print the spec text for the IDs, files, or changes and their links.",
|
|
47
55
|
flags: [
|
|
48
|
-
["--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."],
|
|
49
57
|
["--depth <n>", "Follow links n steps. The default is 1."],
|
|
50
58
|
["--all", "Show all copies of an ID."],
|
|
51
59
|
["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
|
|
52
60
|
],
|
|
53
|
-
exit: [OK, ["2", "A usage error, an unknown ID, or an ID that is not a requirement."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
|
|
61
|
+
exit: [OK, ["2", "A usage error, an unknown ID, a bad path, or an ID that is not a requirement."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
|
|
54
62
|
},
|
|
55
63
|
{
|
|
56
64
|
name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
|
|
@@ -82,6 +90,11 @@ export const TABLE = [
|
|
|
82
90
|
flags: [["--change <name>", "Show only this change."], ["--write", "Also write docs/metrics/<date>.md."]],
|
|
83
91
|
exit: PLAIN, example: "specctx stats --write",
|
|
84
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
|
+
},
|
|
85
98
|
{
|
|
86
99
|
name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
|
|
87
100
|
flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint tells --baseline 0 README.md",
|
|
@@ -125,7 +138,11 @@ export const TABLE = [
|
|
|
125
138
|
},
|
|
126
139
|
{
|
|
127
140
|
name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
|
|
128
|
-
flags: [
|
|
141
|
+
flags: [
|
|
142
|
+
["--approve", "Approve a plan or archive question. A plan or archive question needs this or --reject."],
|
|
143
|
+
["--reject", "Reject a plan or archive question. The stop stays."],
|
|
144
|
+
],
|
|
145
|
+
exit: PLAIN, example: "specctx handoff resolve 1 --approve \"yes, for one release\"",
|
|
129
146
|
},
|
|
130
147
|
// Setup
|
|
131
148
|
{
|
package/dist/cli/workflow.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
1
|
+
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-007 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 REQ-LOOP-002 write commands.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { dumps } from "../util/json.js";
|
|
5
5
|
import { paint } from "./color.js";
|
|
6
6
|
import { UsageExit } from "./errors.js";
|
|
7
|
+
import { runCommand } from "../runcmd.js";
|
|
8
|
+
/** REQ-CLI-010 a command to print, with the run prefix of the config (043/D1). */
|
|
9
|
+
const rc = (c, args) => runCommand(c.cfg.cli.run, args);
|
|
7
10
|
const arg = (c, n, what) => {
|
|
8
11
|
const v = c.p.positional[n];
|
|
9
12
|
if (v === undefined)
|
|
@@ -62,7 +65,8 @@ export async function runWorkflow(c) {
|
|
|
62
65
|
}
|
|
63
66
|
return 1;
|
|
64
67
|
}
|
|
65
|
-
|
|
68
|
+
// REQ-TASK-007 the command names the answer that task start prints
|
|
69
|
+
const bundleCommand = rc(c, `context ${t.id} --budget ${c.cfg.bundle.budget}`);
|
|
66
70
|
if (c.json) {
|
|
67
71
|
print(c, { bundle_command: bundleCommand, change: t.change, id: t.id, reqs: t.reqs, text: t.text, verify: t.verify.command });
|
|
68
72
|
}
|
|
@@ -82,10 +86,10 @@ export async function runWorkflow(c) {
|
|
|
82
86
|
const { canon } = await import("../index/canonical.js");
|
|
83
87
|
const { bundle } = await import("../graph/bundle.js");
|
|
84
88
|
const ids = canon(entries);
|
|
85
|
-
const known = t.reqs.filter((r) => ids.has(r));
|
|
86
89
|
for (const r of t.reqs.filter((x) => !ids.has(x)))
|
|
87
90
|
c.io.err(`warning: task ${t.id} cites ${r}, which the index does not hold\n`);
|
|
88
|
-
|
|
91
|
+
// 047/D5 the entry that findTask found goes in, so an active task wins over an archived one with its number
|
|
92
|
+
const b = bundle(c.root, entries, [t.id], c.cfg.bundle.budget, c.cfg.bundle.depth, { task: t.entry });
|
|
89
93
|
const { isoNow } = await import("../util/time.js");
|
|
90
94
|
const { CURRENT_PATH } = await import("../handoff/handoff.js");
|
|
91
95
|
mkdirSync(join(c.root, ".ctx"), { recursive: true });
|
|
@@ -94,7 +98,7 @@ export async function runWorkflow(c) {
|
|
|
94
98
|
// REQ-TASK-002 the Verify command is frozen here, so task done can refuse a changed one (019/D3)
|
|
95
99
|
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id, verify: t.verify.command }) + "\n");
|
|
96
100
|
// REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
|
|
97
|
-
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run
|
|
101
|
+
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run ${rc(c, `query --change ${t.change} --kind req`)} to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
98
102
|
if (c.json)
|
|
99
103
|
print(c, { bundle: b, hint, task: t.id });
|
|
100
104
|
else
|
|
@@ -113,7 +117,7 @@ export async function runWorkflow(c) {
|
|
|
113
117
|
throw new UsageExit("--manual needs a note");
|
|
114
118
|
// REQ-TASK-003 a task with a Verify command gets no manual note (019/D1, ADR-004)
|
|
115
119
|
if (t.verify.command !== null)
|
|
116
|
-
throw new UsageExit(`task ${t.id} has a Verify command. Run it with
|
|
120
|
+
throw new UsageExit(`task ${t.id} has a Verify command. Run it with ${rc(c, `task done ${t.id}`)}.`);
|
|
117
121
|
if (t.done && hasEvidence(c.root, t))
|
|
118
122
|
throw new UsageExit(`task ${t.id} is done`);
|
|
119
123
|
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
@@ -137,7 +141,7 @@ export async function runWorkflow(c) {
|
|
|
137
141
|
if (!dry) {
|
|
138
142
|
const frozen = readCurrent(c.root);
|
|
139
143
|
if (frozen && frozen.task === t.id && typeof frozen.verify === "string" && frozen.verify !== t.verify.command) {
|
|
140
|
-
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run
|
|
144
|
+
throw new UsageExit(`task ${t.id}: the Verify command changed since task start. Run ${rc(c, `task start ${t.id}`)} again.`);
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
const { runVerify, tail } = await import("../verify/run.js");
|
|
@@ -167,10 +171,13 @@ export async function runWorkflow(c) {
|
|
|
167
171
|
// REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
|
|
168
172
|
const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
|
|
169
173
|
const { addQuestion } = await import("../handoff/questions.js");
|
|
170
|
-
|
|
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);
|
|
171
178
|
let question = openQuestionFor(c.root, t.id)?.n ?? null;
|
|
172
179
|
if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
|
|
173
|
-
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;
|
|
174
181
|
}
|
|
175
182
|
if (c.json)
|
|
176
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 });
|
|
@@ -184,7 +191,7 @@ export async function runWorkflow(c) {
|
|
|
184
191
|
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
185
192
|
// REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
|
|
186
193
|
if (!c.json && question !== null)
|
|
187
|
-
c.io.out(blockedLine(question) + "\n");
|
|
194
|
+
c.io.out(blockedLine(question, c.cfg.cli.run) + "\n");
|
|
188
195
|
return 1;
|
|
189
196
|
}
|
|
190
197
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -192,11 +199,20 @@ export async function runWorkflow(c) {
|
|
|
192
199
|
// the Verify command can change tagged files, so the digests come from the tree after the run
|
|
193
200
|
const { loadIndex } = await import("../index/build.js");
|
|
194
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");
|
|
195
204
|
const rel = writeEvidence(c.root, t, after, {
|
|
196
|
-
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),
|
|
197
206
|
}, "command", undefined, t.verify.cwd);
|
|
198
207
|
setBox(c.root, t, true);
|
|
199
208
|
clearCurrent(c.root, t.id);
|
|
209
|
+
// REQ-LOOP-014 a pass closes the attempt-cap question of the task. Plan, design, and archive questions stay (044/D3).
|
|
210
|
+
const { isCapQuestion } = await import("../loop/fails.js");
|
|
211
|
+
const { readQuestions, resolveQuestion } = await import("../handoff/questions.js");
|
|
212
|
+
for (const q of readQuestions(c.root)) {
|
|
213
|
+
if (isCapQuestion(q, t.id))
|
|
214
|
+
resolveQuestion(c.root, q.n, "Verify passed. Resolved by task done.");
|
|
215
|
+
}
|
|
200
216
|
if (c.json)
|
|
201
217
|
print(c, { evidence: rel, kind: "command", task: t.id });
|
|
202
218
|
else
|
|
@@ -255,7 +271,10 @@ export async function runWorkflow(c) {
|
|
|
255
271
|
const n = arg(c, 0, "a question number");
|
|
256
272
|
if (!/^\d+$/.test(n))
|
|
257
273
|
throw new UsageExit("handoff resolve needs a question number");
|
|
258
|
-
const
|
|
274
|
+
const approve = c.p.flags.approve === true;
|
|
275
|
+
const reject = c.p.flags.reject === true;
|
|
276
|
+
const verdict = approve && reject ? "both" : approve ? "approved" : reject ? "rejected" : undefined;
|
|
277
|
+
const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "), verdict);
|
|
259
278
|
if (c.json)
|
|
260
279
|
print(c, { open_questions: h.open_questions, resolved: h.resolved });
|
|
261
280
|
else
|
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/config.js
CHANGED
|
@@ -21,6 +21,7 @@ export function defaults() {
|
|
|
21
21
|
// REQ-DIST-002 the gate lints nothing unless the project opts in (022/D1, ADR-006 item 9)
|
|
22
22
|
gate: { markdown: [], openspec: DEFAULT_OPENSPEC, base: "origin/main" },
|
|
23
23
|
loop: { max_attempts: 3, archive: "ask" },
|
|
24
|
+
cli: { run: "specctx" },
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export class ConfigError extends Error {
|
|
@@ -122,6 +123,13 @@ export function loadConfig(root) {
|
|
|
122
123
|
throw new ConfigError(`${CONFIG_FILE}: loop.archive must be "ask" or "auto".`);
|
|
123
124
|
cfg.loop.archive = v;
|
|
124
125
|
}
|
|
126
|
+
// REQ-CLI-010 the run prefix of printed commands (043/D1)
|
|
127
|
+
const cli = sec("cli");
|
|
128
|
+
if ("run" in cli) {
|
|
129
|
+
if (typeof cli.run !== "string" || !cli.run.trim())
|
|
130
|
+
throw new ConfigError(`${CONFIG_FILE}: cli.run must be a string that is not empty.`);
|
|
131
|
+
cfg.cli.run = cli.run.trim();
|
|
132
|
+
}
|
|
125
133
|
return cfg;
|
|
126
134
|
}
|
|
127
135
|
/** The file that `specctx init` writes. Each key has a comment above it. */
|