spec-ctx 0.1.2 → 0.3.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 +46 -30
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +6 -3
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +78 -2
- package/dist/check/evidence.js +26 -14
- package/dist/check/hints.js +11 -2
- package/dist/cli/args.js +2 -2
- package/dist/cli/commands.js +50 -9
- package/dist/cli/gate.js +33 -8
- package/dist/cli/help.js +10 -9
- package/dist/cli/status.js +11 -36
- package/dist/cli/workflow.js +47 -41
- package/dist/config.js +55 -4
- package/dist/handoff/handoff.js +57 -24
- package/dist/handoff/questions.js +101 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +8 -6
- package/dist/init/init.js +298 -36
- package/dist/loop/fails.js +102 -0
- package/dist/loop/next.js +193 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +9 -4
- package/dist/tasks/evidence.js +5 -2
- package/dist/verify/parse.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schemas/spec-driven-ids/README.md +12 -12
- package/schemas/spec-driven-ids/config-fragment.yaml +3 -2
- package/skills/specctx-bootstrap/SKILL.md +1 -22
- package/skills/specctx-done/SKILL.md +1 -20
- package/skills/specctx-next/SKILL.md +1 -18
- package/skills/specctx-run/SKILL.md +37 -0
- package/schemas/spec-driven-ids/schema.yaml +0 -301
package/dist/check/evidence.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
// REQ-CHK-003 evidence rules of check --strict.
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileSha256 } from "../util/hash.js";
|
|
5
|
+
import { evidenceRel } from "../tasks/tasks.js";
|
|
6
|
+
/**
|
|
7
|
+
* REQ-CHK-003 REQ-LOOP-008 the stale paths of one evidence file, sorted. A damaged file is stale as a whole and
|
|
8
|
+
* returns its own path. check and next both read this, so the two never differ (031/D5).
|
|
9
|
+
*/
|
|
10
|
+
export function staleFiles(root, rel) {
|
|
11
|
+
let files = {};
|
|
12
|
+
try {
|
|
13
|
+
const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
|
|
14
|
+
files = ev.files ?? {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [rel];
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(files).filter((p) => fileSha256(join(root, p)) !== files[p]).sort();
|
|
20
|
+
}
|
|
21
|
+
/** REQ-LOOP-008 the first stale path of the evidence of a task, or null when it is fresh or absent (031/D5). */
|
|
22
|
+
export function staleOf(root, task) {
|
|
23
|
+
const rel = evidenceRel({ id: task });
|
|
24
|
+
if (!existsSync(join(root, rel)))
|
|
25
|
+
return null;
|
|
26
|
+
return staleFiles(root, rel)[0] ?? null;
|
|
27
|
+
}
|
|
5
28
|
export function evidenceFindings(root, entries, evidence) {
|
|
6
29
|
const out = [];
|
|
7
30
|
const tasks = new Map();
|
|
@@ -27,19 +50,8 @@ export function evidenceFindings(root, entries, evidence) {
|
|
|
27
50
|
// archived changes are exempt from stale-evidence
|
|
28
51
|
if (!active)
|
|
29
52
|
continue;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
|
|
33
|
-
files = ev.files ?? {};
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
out.push([rel, 1, "stale-evidence", `${id} ${rel}`]);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
for (const [path, digest] of Object.entries(files)) {
|
|
40
|
-
if (fileSha256(join(root, path)) !== digest)
|
|
41
|
-
out.push([rel, 1, "stale-evidence", `${id} ${path}`]);
|
|
42
|
-
}
|
|
53
|
+
for (const path of staleFiles(root, rel))
|
|
54
|
+
out.push([rel, 1, "stale-evidence", `${id} ${path}`]);
|
|
43
55
|
}
|
|
44
56
|
for (const e of entries) {
|
|
45
57
|
if (e.kind === "task" && e.status === "done" && !e.file.includes("/archive/") && !have.has(e.id)) {
|
package/dist/check/hints.js
CHANGED
|
@@ -16,10 +16,19 @@ export const RULES = {
|
|
|
16
16
|
"unknown-cap": { level: "hard", hint: "Add the code to caps under [ids] in spec-ctx.toml." },
|
|
17
17
|
uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
|
|
18
18
|
"scenario-untested": { level: "hard", hint: "Name the scenario ID in a test." },
|
|
19
|
-
"done-without-evidence": { level: "hard", hint: "Run specctx task done
|
|
20
|
-
|
|
19
|
+
"done-without-evidence": { level: "hard", hint: "Run specctx task done. It runs the Verify command and writes the evidence." },
|
|
20
|
+
// REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
|
|
21
|
+
"stale-evidence": { level: "hard", hint: "Run specctx task reopen <task> <why>, then specctx task done <task>." },
|
|
21
22
|
"evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
|
|
23
|
+
// REQ-CHK-005 advisory in plain check. check --strict reports it as a hard finding (028/D5).
|
|
24
|
+
"weak-verify": { level: "advisory", hint: "Name a test file or a command whose exit code proves the task." },
|
|
22
25
|
"manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
|
|
26
|
+
// REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
|
|
27
|
+
// REQ-ADOPT-010 FROM copies the main header and TO copies the delta header, each in its own form (027/D4).
|
|
28
|
+
"retitle-needs-renamed": {
|
|
29
|
+
level: "hard",
|
|
30
|
+
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>`",
|
|
31
|
+
},
|
|
23
32
|
};
|
|
24
33
|
export function hintOf(rule) {
|
|
25
34
|
return RULES[rule]?.hint ?? "Read the check rules in the check spec.";
|
package/dist/cli/args.js
CHANGED
|
@@ -4,6 +4,7 @@ export const GLOBAL_FLAGS = { root: "string", json: "bool", help: "bool", versio
|
|
|
4
4
|
export const COMMAND_FLAGS = {
|
|
5
5
|
build: {},
|
|
6
6
|
status: {},
|
|
7
|
+
next: { change: "string" },
|
|
7
8
|
query: { kind: "string", change: "string", all: "bool", limit: "int" },
|
|
8
9
|
related: { depth: "int", all: "bool" },
|
|
9
10
|
bundle: { budget: "int", depth: "int", all: "bool" },
|
|
@@ -17,9 +18,8 @@ export const COMMAND_FLAGS = {
|
|
|
17
18
|
"task verify": {},
|
|
18
19
|
"task done": { manual: "string" },
|
|
19
20
|
"task reopen": {},
|
|
20
|
-
"task backfill": { manual: "string", change: "string" },
|
|
21
21
|
"handoff write": {},
|
|
22
|
-
"handoff ask": {},
|
|
22
|
+
"handoff ask": { change: "string" },
|
|
23
23
|
"handoff resolve": {},
|
|
24
24
|
adopt: { "dry-run": "bool" },
|
|
25
25
|
"baseline write": {},
|
package/dist/cli/commands.js
CHANGED
|
@@ -10,7 +10,12 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
10
10
|
const { applyBaseline } = await import("../baseline/baseline.js");
|
|
11
11
|
const findings = check(root, cfg, data, strict);
|
|
12
12
|
let warnings = [];
|
|
13
|
-
if (strict) {
|
|
13
|
+
if (!strict) {
|
|
14
|
+
// REQ-CHK-005 plain check prints weak-verify as an advisory warning (028/D3)
|
|
15
|
+
const { weakVerifyWarnings } = await import("../check/check.js");
|
|
16
|
+
warnings = weakVerifyWarnings(data.entries);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
14
19
|
const { manualWarnings } = await import("../check/evidence.js");
|
|
15
20
|
const { listEvidence } = await import("../tasks/evidence.js");
|
|
16
21
|
warnings = manualWarnings(root, data.entries, listEvidence(root));
|
|
@@ -23,7 +28,8 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
23
28
|
*/
|
|
24
29
|
export function checkText(r, tty) {
|
|
25
30
|
// REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
|
|
26
|
-
|
|
31
|
+
// REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
|
|
32
|
+
const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
|
|
27
33
|
+ r.warnings.map((w) => `warning: ${w}\n`).join("");
|
|
28
34
|
const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
|
|
29
35
|
const n = r.findings.length + r.warnings.length;
|
|
@@ -34,16 +40,29 @@ export function checkText(r, tty) {
|
|
|
34
40
|
for (const x of r.findings)
|
|
35
41
|
count.set(x.rule, (count.get(x.rule) ?? 0) + 1);
|
|
36
42
|
for (const w of r.warnings) {
|
|
37
|
-
const [rule, task] = w.split(" ");
|
|
43
|
+
const [rule, task, at] = w.split(" ");
|
|
38
44
|
count.set(rule, (count.get(rule) ?? 0) + 1);
|
|
45
|
+
// REQ-CHK-005 a weak-verify warning ends with <file>:<line> of the task line (028/D4)
|
|
46
|
+
if (rule === "weak-verify" && at)
|
|
47
|
+
files.add(at.replace(/:\d+$/, ""));
|
|
39
48
|
// a manual-evidence warning names a task. Its file is the evidence file of the task.
|
|
40
|
-
if (task?.includes("/"))
|
|
49
|
+
else if (task?.includes("/"))
|
|
41
50
|
files.add(`.ctx/evidence/${task}.json`);
|
|
42
51
|
}
|
|
52
|
+
// REQ-CHK-006 the distinct hints that findings carry, in finding order. A rule with none prints the table hint (031/D6).
|
|
53
|
+
const own = new Map();
|
|
54
|
+
for (const x of r.findings) {
|
|
55
|
+
if (x.hint === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
const l = own.get(x.rule) ?? [];
|
|
58
|
+
if (!l.includes(x.hint))
|
|
59
|
+
l.push(x.hint);
|
|
60
|
+
own.set(x.rule, l);
|
|
61
|
+
}
|
|
43
62
|
const rules = [...count.keys()].sort();
|
|
44
63
|
return body
|
|
45
64
|
+ "\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)
|
|
46
|
-
+ rules.map((rule) => `${rule} ${count.get(rule)} ${hintOf(rule)}\n`).join("")
|
|
65
|
+
+ rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule)}\n`).join("")
|
|
47
66
|
+ base;
|
|
48
67
|
}
|
|
49
68
|
export async function run(p, io) {
|
|
@@ -133,6 +152,11 @@ export async function run(p, io) {
|
|
|
133
152
|
return runGate(root, {
|
|
134
153
|
strict,
|
|
135
154
|
markdown: f.markdown ?? cfg.gate.markdown.join(" "),
|
|
155
|
+
// REQ-DIST-002 the env var wins over [gate] openspec, for tests and one-off runs (018/D2)
|
|
156
|
+
openspec: process.env.SPECCTX_OPENSPEC ?? cfg.gate.openspec,
|
|
157
|
+
// REQ-ADOPT-008 the ref for the merge base, and the subject override for tests (020/D4)
|
|
158
|
+
base: cfg.gate.base,
|
|
159
|
+
baselineSubject: process.env.SPECCTX_BASELINE_SUBJECT,
|
|
136
160
|
json,
|
|
137
161
|
check: async () => {
|
|
138
162
|
const { buildIndex } = await import("../index/build.js");
|
|
@@ -159,7 +183,7 @@ export async function run(p, io) {
|
|
|
159
183
|
}
|
|
160
184
|
if (cmd === "bundle")
|
|
161
185
|
need(1, "at least one ID");
|
|
162
|
-
if ((cmd === "status" || cmd === "check") && p.positional.length)
|
|
186
|
+
if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
|
|
163
187
|
throw new UsageExit(`${cmd} takes no arguments`);
|
|
164
188
|
const { data, rebuilt } = loadIndex(root, cfg);
|
|
165
189
|
if (rebuilt)
|
|
@@ -176,9 +200,23 @@ export async function run(p, io) {
|
|
|
176
200
|
const r = statusReport(root, entries);
|
|
177
201
|
if (json)
|
|
178
202
|
print(r);
|
|
179
|
-
|
|
203
|
+
else {
|
|
204
|
+
// REQ-CLI-007 the next: line is the command of the decision of specctx next, so the two never disagree (030/D1)
|
|
205
|
+
const { decideNext } = await import("../loop/next.js");
|
|
206
|
+
const d = decideNext(root, cfg, data);
|
|
207
|
+
// REQ-CLI-008 the next: line is dim
|
|
208
|
+
io.out(statusLines(r, d.command).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
|
|
209
|
+
}
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
case "next": {
|
|
213
|
+
// REQ-LOOP-001 the decision is a pure function of the files. --json prints the object, else the lines with next: last (024/D1).
|
|
214
|
+
const { decideNext, renderNext } = await import("../loop/next.js");
|
|
215
|
+
const d = decideNext(root, cfg, data, f.change);
|
|
216
|
+
if (json)
|
|
217
|
+
print(d);
|
|
180
218
|
else
|
|
181
|
-
io.out(
|
|
219
|
+
io.out(renderNext(d).split("\n").map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l)).join("\n"));
|
|
182
220
|
return 0;
|
|
183
221
|
}
|
|
184
222
|
case "query": {
|
|
@@ -253,8 +291,10 @@ export async function run(p, io) {
|
|
|
253
291
|
}
|
|
254
292
|
case "check": {
|
|
255
293
|
const r = await runCheck(root, cfg, data, !!f.strict);
|
|
294
|
+
// REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
|
|
295
|
+
const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
|
|
256
296
|
if (json)
|
|
257
|
-
print({ baselined: r.baselined, count:
|
|
297
|
+
print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
|
|
258
298
|
else
|
|
259
299
|
io.out(checkText(r, io.tty));
|
|
260
300
|
return r.findings.length ? 1 : 0;
|
|
@@ -275,6 +315,7 @@ async function init(p, io) {
|
|
|
275
315
|
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
|
|
276
316
|
else {
|
|
277
317
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
318
|
+
io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
|
|
278
319
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
279
320
|
io.out(r.notes.map((n) => `${n}\n`).join(""));
|
|
280
321
|
// REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
|
package/dist/cli/gate.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// REQ-CLI-004 specctx gate: check, both linters, openspec validate, spec length. Stops at the first failure.
|
|
1
|
+
// REQ-CLI-004 specctx gate: check, the baseline against the merge base, both linters, openspec validate, spec length. Stops at the first failure.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { anyMatch, globRe } from "../util/glob.js";
|
|
6
6
|
import { cmpStr } from "../util/pystr.js";
|
|
7
|
-
export const DEFAULT_MARKDOWN = "openspec/**/*.md decisions/*.md docs/**/*.md *.md";
|
|
8
7
|
const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
|
|
9
8
|
export function markdownFiles(root, globs) {
|
|
10
9
|
const res = globs.split(/\s+/).filter(Boolean).map(globRe);
|
|
@@ -42,7 +41,7 @@ export const GATE_LOG = ".ctx/gate-log.jsonl";
|
|
|
42
41
|
/** REQ-STAT-005 the gate steps in order. The log names the step that failed. */
|
|
43
42
|
/** REQ-CLI-004 the lint detail stops here unless --json asks for every line. */
|
|
44
43
|
export const LINT_DETAIL_CAP = 50;
|
|
45
|
-
export const GATE_STEPS = ["check", "lint tells", "lint ste", "validate", "spec-length"];
|
|
44
|
+
export const GATE_STEPS = ["check", "baseline", "lint tells", "lint ste", "validate", "spec-length"];
|
|
46
45
|
/** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
|
|
47
46
|
export function appendGateLog(root, line) {
|
|
48
47
|
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
@@ -67,7 +66,13 @@ async function gateSteps(root, o, io, ran) {
|
|
|
67
66
|
return "check";
|
|
68
67
|
}
|
|
69
68
|
io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
|
|
70
|
-
// gate 2: REQ-
|
|
69
|
+
// gate 2: REQ-ADOPT-008 the baseline may not gain entries against the merge base (020/D1 to D4)
|
|
70
|
+
ran.push("baseline");
|
|
71
|
+
const b = await baselineStep(root, o);
|
|
72
|
+
io.out(b.text);
|
|
73
|
+
if (!b.ok)
|
|
74
|
+
return "baseline";
|
|
75
|
+
// gate 3: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
|
|
71
76
|
// REQ-ADOPT-007 the step fails only on a count above its baseline line.
|
|
72
77
|
const { lintCounts, lintRises } = await import("../lint/counts.js");
|
|
73
78
|
const { readBaseline } = await import("../baseline/baseline.js");
|
|
@@ -87,16 +92,15 @@ async function gateSteps(root, o, io, ran) {
|
|
|
87
92
|
}
|
|
88
93
|
io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
|
|
89
94
|
ran.push("validate");
|
|
90
|
-
// gate
|
|
91
|
-
const
|
|
92
|
-
const v = spawnSync("/bin/sh", ["-c", `${cmd} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
|
|
95
|
+
// gate 4: openspec validate with the command the caller resolved (018/D2)
|
|
96
|
+
const v = spawnSync("/bin/sh", ["-c", `${o.openspec} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
|
|
93
97
|
if (v.status !== 0) {
|
|
94
98
|
io.out("validate: fail\n" + (v.stdout ?? "") + (v.stderr ?? ""));
|
|
95
99
|
return "validate";
|
|
96
100
|
}
|
|
97
101
|
io.out(`validate: ok${o.strict ? " (strict)" : ""}\n`);
|
|
98
102
|
ran.push("spec-length");
|
|
99
|
-
// gate
|
|
103
|
+
// gate 5: the length of every requirement in the deltas of active changes
|
|
100
104
|
const long = longRequirements(root);
|
|
101
105
|
if (long.length) {
|
|
102
106
|
io.out(long.map((l) => `spec-length: fail ${l.id} ${l.chars} chars\n`).join(""));
|
|
@@ -105,6 +109,27 @@ async function gateSteps(root, o, io, ran) {
|
|
|
105
109
|
io.out("spec-length: ok\n");
|
|
106
110
|
return null;
|
|
107
111
|
}
|
|
112
|
+
/** REQ-ADOPT-008 the baseline step: skipped, ok, or fail with the new entries (020/D3). */
|
|
113
|
+
async function baselineStep(root, o) {
|
|
114
|
+
const { baseAtMergeBase, headSubjects, isBaselineSubject, newEntries } = await import("../baseline/ratchet.js");
|
|
115
|
+
const { readBaseline } = await import("../baseline/baseline.js");
|
|
116
|
+
const subjects = o.baselineSubject !== undefined ? [o.baselineSubject] : headSubjects(root);
|
|
117
|
+
if (subjects.some(isBaselineSubject))
|
|
118
|
+
return { ok: true, text: "baseline: ok (baseline: commit)\n" };
|
|
119
|
+
const base = baseAtMergeBase(root, o.base);
|
|
120
|
+
if ("skip" in base)
|
|
121
|
+
return { ok: true, text: `baseline: skipped (${base.skip})\n` };
|
|
122
|
+
const current = [...readBaseline(root) ?? []];
|
|
123
|
+
const fresh = newEntries(current, base.lines);
|
|
124
|
+
if (!fresh.length)
|
|
125
|
+
return { ok: true, text: "baseline: ok\n" };
|
|
126
|
+
const shown = o.json ? fresh : fresh.slice(0, LINT_DETAIL_CAP);
|
|
127
|
+
const more = fresh.length - shown.length;
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
text: `baseline: fail, ${fresh.length} new entr${fresh.length === 1 ? "y" : "ies"}\n` + shown.map((l) => l + "\n").join("") + (more ? `... ${more} more line(s)\n` : ""),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
108
133
|
// REQ-CLI-005 the limit strict validate applies to a requirement after archive
|
|
109
134
|
export const MAX_REQUIREMENT_CHARS = 500;
|
|
110
135
|
/** Lines inside a fenced code block, fence lines included. Same rule as OpenSpec. */
|
package/dist/cli/help.js
CHANGED
|
@@ -22,6 +22,11 @@ const LINT_EXIT = [["0", "The hard findings are at the baseline or below."], ["1
|
|
|
22
22
|
export const TABLE = [
|
|
23
23
|
// Read
|
|
24
24
|
{ name: "status", group: "Read", args: "", summary: "Show the active changes, the current task, and the open questions.", flags: [], exit: PLAIN, example: "specctx status" },
|
|
25
|
+
{
|
|
26
|
+
name: "next", group: "Read", args: "", summary: "Name the next action of the loop from the files on disk. Write nothing.",
|
|
27
|
+
flags: [["--change <name>", "Decide for this change. The key or the full folder name works."]],
|
|
28
|
+
exit: PLAIN, example: "specctx next --json",
|
|
29
|
+
},
|
|
25
30
|
{
|
|
26
31
|
name: "query", group: "Read", args: "[term...]", summary: "Find the entries that contain all terms. With --change and no term, list every entry of that change.",
|
|
27
32
|
flags: [
|
|
@@ -53,7 +58,7 @@ export const TABLE = [
|
|
|
53
58
|
},
|
|
54
59
|
{
|
|
55
60
|
name: "gate", group: "Read", args: "",
|
|
56
|
-
summary: "Run `check`, the markdown linters, `openspec validate
|
|
61
|
+
summary: "Run `check`, the baseline against the merge base of [gate] base, the markdown linters, `openspec validate` with the command in [gate] openspec, and the length check. Stop at the first failure.",
|
|
57
62
|
flags: [
|
|
58
63
|
["--strict", "Run `check` and `openspec validate` in strict mode."],
|
|
59
64
|
["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
|
|
@@ -109,7 +114,8 @@ export const TABLE = [
|
|
|
109
114
|
{ name: "handoff write", group: "Write", args: "", summary: "Write .ctx/handoff.md from the index and the evidence.", flags: [], exit: PLAIN, example: "specctx handoff write" },
|
|
110
115
|
{
|
|
111
116
|
name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
|
|
112
|
-
flags: [
|
|
117
|
+
flags: [["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."]],
|
|
118
|
+
exit: PLAIN, example: "specctx handoff ask --change 008 \"plan ready for 008-tasks. Approve to start the tasks?\"",
|
|
113
119
|
},
|
|
114
120
|
{
|
|
115
121
|
name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
|
|
@@ -119,8 +125,8 @@ export const TABLE = [
|
|
|
119
125
|
{
|
|
120
126
|
name: "init", group: "Setup", args: "", summary: "Write a default spec-ctx.toml file.",
|
|
121
127
|
flags: [
|
|
122
|
-
["--schema", "Also install the
|
|
123
|
-
["--skills", "Also install the
|
|
128
|
+
["--schema", "Also install the ID templates and add the spec-ctx rules to openspec/config.yaml."],
|
|
129
|
+
["--skills", "Also install the specctx-run skill and its aliases in .claude/skills and .agents/skills."],
|
|
124
130
|
["--all", "Do all three."],
|
|
125
131
|
["--force", "Replace the files that have other content. spec-ctx.toml stays."],
|
|
126
132
|
],
|
|
@@ -132,11 +138,6 @@ export const TABLE = [
|
|
|
132
138
|
exit: [["0", "The IDs are written, or nothing is left to adopt."], ["1", "The dry run found headers to adopt, or a new finding undid the write."], USAGE, INTERNAL],
|
|
133
139
|
example: "specctx adopt --dry-run",
|
|
134
140
|
},
|
|
135
|
-
{
|
|
136
|
-
name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task of an active change that has none.",
|
|
137
|
-
flags: [["--manual <note>", "The note for the evidence. The flag is required."], ["--change <name>", "Look only in this change."]],
|
|
138
|
-
exit: PLAIN, example: "specctx task backfill --manual \"done before spec-ctx\"",
|
|
139
|
-
},
|
|
140
141
|
{
|
|
141
142
|
name: "baseline write", group: "Setup", args: "", summary: "Record the current debt in .ctx/baseline.json. `check` then hides it.",
|
|
142
143
|
flags: [], exit: PLAIN, example: "specctx baseline write",
|
package/dist/cli/status.js
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
|
-
// REQ-CLI-001 status reads the index, the handoff file, and the .openspec.yaml of each archive folder.
|
|
1
|
+
// REQ-CLI-001 status reads the index, the handoff file, questions.json, and the .openspec.yaml of each archive folder.
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { changeOf } from "../index/scan.js";
|
|
5
|
+
import { readQuestions, renderQuestions } from "../handoff/questions.js";
|
|
5
6
|
import { cmpStr, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
7
|
export const HANDOFF_PATH = ".ctx/handoff.md";
|
|
7
|
-
/**
|
|
8
|
+
/** REQ-HND-003 the open questions as handoff.md renders them, read from .ctx/questions.json (022/D7). */
|
|
8
9
|
export function openQuestions(root) {
|
|
9
|
-
|
|
10
|
-
if (!existsSync(p))
|
|
11
|
-
return [];
|
|
12
|
-
const items = [];
|
|
13
|
-
let inside = false;
|
|
14
|
-
for (const line of splitLines(readFileSync(p).toString("utf8"))) {
|
|
15
|
-
if (line.startsWith("open_questions:")) {
|
|
16
|
-
inside = true;
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
if (!inside)
|
|
20
|
-
continue;
|
|
21
|
-
if (line.startsWith("- "))
|
|
22
|
-
items.push(pyStrip(line.slice(2)));
|
|
23
|
-
else if (pyStrip(line) && !line.startsWith(" ") && !line.startsWith("\t"))
|
|
24
|
-
break;
|
|
25
|
-
}
|
|
26
|
-
return items;
|
|
10
|
+
return renderQuestions(readQuestions(root)).open_questions;
|
|
27
11
|
}
|
|
28
12
|
/** REQ-HND-003 the current task from the handoff file. */
|
|
29
13
|
function handoffCurrent(root) {
|
|
@@ -77,10 +61,14 @@ export function statusReport(root, entries) {
|
|
|
77
61
|
uncovered: uncovered.length,
|
|
78
62
|
handoff,
|
|
79
63
|
current: handoff ? handoffCurrent(root) : null,
|
|
80
|
-
open_questions:
|
|
64
|
+
open_questions: openQuestions(root),
|
|
81
65
|
};
|
|
82
66
|
}
|
|
83
|
-
|
|
67
|
+
/**
|
|
68
|
+
* REQ-CLI-007 the status lines. The last line is always `next: ` and the command of the decision
|
|
69
|
+
* of `specctx next`, or `none` when the decision has no command (030/D1, 030/D2).
|
|
70
|
+
*/
|
|
71
|
+
export function statusLines(r, next) {
|
|
84
72
|
const out = r.changes.map((c) => `${c.change} done ${c.done} open ${c.open}`);
|
|
85
73
|
if (!r.changes.length) {
|
|
86
74
|
out.push("no active change");
|
|
@@ -98,19 +86,6 @@ export function statusLines(r) {
|
|
|
98
86
|
for (const q of r.open_questions)
|
|
99
87
|
out.push(`- ${q}`);
|
|
100
88
|
}
|
|
101
|
-
|
|
102
|
-
if (next)
|
|
103
|
-
out.push(`next: ${next}`);
|
|
89
|
+
out.push(`next: ${next ?? "none"}`);
|
|
104
90
|
return out;
|
|
105
91
|
}
|
|
106
|
-
/** REQ-CLI-007 the next step by state: an open task, then a change with every task done, then uncovered REQs. */
|
|
107
|
-
export function nextStep(r) {
|
|
108
|
-
if (r.changes.some((c) => c.open > 0))
|
|
109
|
-
return "specctx task next";
|
|
110
|
-
const ready = r.changes.find((c) => c.done > 0 && c.open === 0);
|
|
111
|
-
if (ready)
|
|
112
|
-
return `archive ${ready.change}`;
|
|
113
|
-
if (r.uncovered > 0)
|
|
114
|
-
return "specctx check";
|
|
115
|
-
return null;
|
|
116
|
-
}
|
package/dist/cli/workflow.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 write commands.
|
|
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.
|
|
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";
|
|
@@ -91,7 +91,8 @@ export async function runWorkflow(c) {
|
|
|
91
91
|
mkdirSync(join(c.root, ".ctx"), { recursive: true });
|
|
92
92
|
const { cpLen } = await import("../util/pystr.js");
|
|
93
93
|
// REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
|
|
94
|
-
|
|
94
|
+
// REQ-TASK-002 the Verify command is frozen here, so task done can refuse a changed one (019/D3)
|
|
95
|
+
writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id, verify: t.verify.command }) + "\n");
|
|
95
96
|
// REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
|
|
96
97
|
const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run specctx query --change ${t.change} --kind req to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
97
98
|
if (c.json)
|
|
@@ -110,11 +111,13 @@ export async function runWorkflow(c) {
|
|
|
110
111
|
if (!dry && manual !== undefined) {
|
|
111
112
|
if (!manual.trim())
|
|
112
113
|
throw new UsageExit("--manual needs a note");
|
|
114
|
+
// REQ-TASK-003 a task with a Verify command gets no manual note (019/D1, ADR-004)
|
|
115
|
+
if (t.verify.command !== null)
|
|
116
|
+
throw new UsageExit(`task ${t.id} has a Verify command. Run it with specctx task done ${t.id}.`);
|
|
113
117
|
if (t.done && hasEvidence(c.root, t))
|
|
114
118
|
throw new UsageExit(`task ${t.id} is done`);
|
|
115
|
-
// a manual note records evidence without a run. On a task done by hand it backfills the evidence.
|
|
116
119
|
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
117
|
-
const rel = writeEvidence(c.root, t, entries, { command:
|
|
120
|
+
const rel = writeEvidence(c.root, t, entries, { command: null, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", manual.trim());
|
|
118
121
|
if (!t.done)
|
|
119
122
|
setBox(c.root, t, true);
|
|
120
123
|
clearCurrent(c.root, t.id);
|
|
@@ -124,11 +127,19 @@ export async function runWorkflow(c) {
|
|
|
124
127
|
c.io.out(paint("ok", `done ${t.id} (manual). Evidence: ${rel}\n`, c.io.tty));
|
|
125
128
|
return 0;
|
|
126
129
|
}
|
|
127
|
-
|
|
130
|
+
// REQ-TASK-003 a ticked task with evidence is done. One without evidence gets the repair run (019/D2).
|
|
131
|
+
if (!dry && t.done && hasEvidence(c.root, t))
|
|
128
132
|
throw new UsageExit(`task ${t.id} is done`);
|
|
129
133
|
if (t.verify.command === null) {
|
|
130
134
|
throw new UsageExit(`task ${t.id} has no Verify command. ${dry ? "Nothing to run" : 'A --manual "<note>" is required'}`);
|
|
131
135
|
}
|
|
136
|
+
// REQ-TASK-003 the Verify command that task start froze must still be the one on the task line (019/D3)
|
|
137
|
+
if (!dry) {
|
|
138
|
+
const frozen = readCurrent(c.root);
|
|
139
|
+
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 specctx task start ${t.id} again.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
132
143
|
const { runVerify, tail } = await import("../verify/run.js");
|
|
133
144
|
const { resolve: resolvePath, relative, isAbsolute } = await import("node:path");
|
|
134
145
|
let dir = c.root;
|
|
@@ -153,8 +164,16 @@ export async function runWorkflow(c) {
|
|
|
153
164
|
}
|
|
154
165
|
if (r.timedOut || r.exit !== 0) {
|
|
155
166
|
const msg = r.timedOut ? `verify timed out after ${c.cfg.verify.timeout_s}s` : `verify failed with exit ${r.exit}`;
|
|
167
|
+
// REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
|
|
168
|
+
const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
|
|
169
|
+
const { addQuestion } = await import("../handoff/questions.js");
|
|
170
|
+
const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
|
|
171
|
+
let question = openQuestionFor(c.root, t.id)?.n ?? null;
|
|
172
|
+
if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
|
|
173
|
+
question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail)).n;
|
|
174
|
+
}
|
|
156
175
|
if (c.json)
|
|
157
|
-
print(c, { command: t.verify.command, error: msg, exit: r.exit, tail: outTail, task: t.id });
|
|
176
|
+
print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
|
|
158
177
|
else
|
|
159
178
|
c.io.out(outTail ? outTail + "\n" : "");
|
|
160
179
|
// REQ-VER-003 a timeout keeps its own line on stderr
|
|
@@ -163,6 +182,9 @@ export async function runWorkflow(c) {
|
|
|
163
182
|
// REQ-TASK-003 the refusal is the last line of the output. REQ-CLI-008 it is red on a TTY.
|
|
164
183
|
else if (!c.json)
|
|
165
184
|
c.io.out(paint("fail", `Verify failed (exit ${r.exit}). The task stays open.\n`, c.io.tty));
|
|
185
|
+
// REQ-LOOP-002 the blocked line comes after the failure text, so the last line names the way out
|
|
186
|
+
if (!c.json && question !== null)
|
|
187
|
+
c.io.out(blockedLine(question) + "\n");
|
|
166
188
|
return 1;
|
|
167
189
|
}
|
|
168
190
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -181,38 +203,6 @@ export async function runWorkflow(c) {
|
|
|
181
203
|
c.io.out(paint("ok", `done ${t.id}. Evidence: ${rel}\n`, c.io.tty)); // REQ-CLI-008
|
|
182
204
|
return 0;
|
|
183
205
|
}
|
|
184
|
-
case "task backfill": {
|
|
185
|
-
// REQ-TASK-006 manual evidence for every ticked task of an active change that has none, through the task done --manual path.
|
|
186
|
-
// Archived changes stay untouched (017/D1).
|
|
187
|
-
noMore(c, 0);
|
|
188
|
-
const note = f.manual?.trim();
|
|
189
|
-
if (!note)
|
|
190
|
-
throw new UsageExit('task backfill needs --manual "<note>"');
|
|
191
|
-
const { tasksOf, toTask, hasEvidence } = await import("../tasks/tasks.js");
|
|
192
|
-
const { matchesChange } = await import("../index/scan.js");
|
|
193
|
-
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
194
|
-
const want = f.change;
|
|
195
|
-
const counts = new Map();
|
|
196
|
-
for (const e of tasksOf(entries)) {
|
|
197
|
-
if (e.status !== "done" || e.file.includes("/archive/"))
|
|
198
|
-
continue;
|
|
199
|
-
if (want && !matchesChange(e.change, want))
|
|
200
|
-
continue;
|
|
201
|
-
if (hasEvidence(c.root, e))
|
|
202
|
-
continue;
|
|
203
|
-
const t = toTask(c.root, e);
|
|
204
|
-
writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", note);
|
|
205
|
-
counts.set(e.change, (counts.get(e.change) ?? 0) + 1);
|
|
206
|
-
}
|
|
207
|
-
const { cmpStr } = await import("../util/pystr.js");
|
|
208
|
-
const rows = [...counts.keys()].sort(cmpStr).map((k) => ({ change: k, count: counts.get(k) }));
|
|
209
|
-
const total = rows.reduce((n, r) => n + r.count, 0);
|
|
210
|
-
if (c.json)
|
|
211
|
-
print(c, { changes: rows, total });
|
|
212
|
-
else
|
|
213
|
-
c.io.out(rows.map((r) => `${r.change}: ${r.count}\n`).join("") + `backfilled ${total} task(s)\n`);
|
|
214
|
-
return 0;
|
|
215
|
-
}
|
|
216
206
|
case "task reopen": {
|
|
217
207
|
const { findTask, setBox } = await import("../tasks/tasks.js");
|
|
218
208
|
const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
|
|
@@ -240,17 +230,21 @@ export async function runWorkflow(c) {
|
|
|
240
230
|
const h = writeHandoff(c.root, entries);
|
|
241
231
|
if (c.json)
|
|
242
232
|
print(c, h);
|
|
243
|
-
else
|
|
233
|
+
else {
|
|
234
|
+
// REQ-HND-002 the one time move of old questions into questions.json (022/D8)
|
|
235
|
+
if (h.migrated)
|
|
236
|
+
c.io.out(`migrated ${h.migrated} question(s) to .ctx/questions.json\n`);
|
|
244
237
|
c.io.out(`wrote ${HANDOFF_PATH}\n`);
|
|
238
|
+
}
|
|
245
239
|
return 0;
|
|
246
240
|
}
|
|
247
241
|
case "handoff ask": {
|
|
248
242
|
const { ask } = await import("../handoff/handoff.js");
|
|
249
|
-
const h = ask(c.root, entries, c.p.positional.join(" "));
|
|
243
|
+
const h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change);
|
|
250
244
|
if (c.json)
|
|
251
245
|
print(c, { open_questions: h.open_questions });
|
|
252
246
|
else
|
|
253
|
-
c.io.out(`open question ${h.open_questions.length} added\n`);
|
|
247
|
+
c.io.out(`open question ${h.asked ?? h.open_questions.length} added\n`);
|
|
254
248
|
return 0;
|
|
255
249
|
}
|
|
256
250
|
case "handoff resolve": {
|
|
@@ -268,6 +262,18 @@ export async function runWorkflow(c) {
|
|
|
268
262
|
}
|
|
269
263
|
throw new UsageExit(`unknown command: ${c.p.command}`);
|
|
270
264
|
}
|
|
265
|
+
/** The current task file, or null when it is absent or damaged. */
|
|
266
|
+
function readCurrent(root) {
|
|
267
|
+
const p = join(root, ".ctx/current.json");
|
|
268
|
+
if (!existsSync(p))
|
|
269
|
+
return null;
|
|
270
|
+
try {
|
|
271
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
271
277
|
function clearCurrent(root, task) {
|
|
272
278
|
const p = join(root, ".ctx/current.json");
|
|
273
279
|
if (!existsSync(p))
|