spec-ctx 0.1.1 → 0.2.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 +44 -29
- package/dist/adopt/plan.js +26 -14
- package/dist/assets.generated.js +7 -7
- package/dist/baseline/baseline.js +35 -9
- package/dist/baseline/ratchet.js +81 -0
- package/dist/check/check.js +50 -2
- package/dist/check/hints.js +7 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands.js +36 -12
- package/dist/cli/gate.js +51 -33
- package/dist/cli/help.js +12 -12
- package/dist/cli/status.js +5 -21
- package/dist/cli/workflow.js +50 -41
- package/dist/config.js +61 -1
- package/dist/graph/query.js +4 -1
- package/dist/handoff/handoff.js +38 -23
- package/dist/handoff/questions.js +99 -0
- package/dist/ids/ids.js +11 -6
- package/dist/index/scan.js +13 -8
- package/dist/init/init.js +283 -35
- package/dist/lint/counts.js +60 -0
- package/dist/loop/fails.js +91 -0
- package/dist/loop/next.js +173 -0
- package/dist/loop/openspec.js +41 -0
- package/dist/stats/stats.js +3 -1
- package/dist/tasks/evidence.js +5 -2
- 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/check.js
CHANGED
|
@@ -6,7 +6,8 @@ import { changeKey } from "../index/scan.js";
|
|
|
6
6
|
import { listEvidence } from "../tasks/evidence.js";
|
|
7
7
|
import { cmpTuple } from "../util/pystr.js";
|
|
8
8
|
import { evidenceFindings } from "./evidence.js";
|
|
9
|
-
|
|
9
|
+
// the first ID of a retired row can be a scenario. Only a REQ or ADR can be reused, but the SCN still counts as the row ID.
|
|
10
|
+
const RETIRED_RE = /\b(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})\b/g;
|
|
10
11
|
const REQ_CAP = /^REQ-([A-Z]{2,6})-/;
|
|
11
12
|
export function retiredIds(root) {
|
|
12
13
|
const p = join(root, "decisions", "retired-ids.md");
|
|
@@ -49,6 +50,51 @@ export function duplicateChangeKeys(root) {
|
|
|
49
50
|
}
|
|
50
51
|
return out;
|
|
51
52
|
}
|
|
53
|
+
// REQ-ADOPT-010 the ID of a TO line is bare or in square brackets (027/D4)
|
|
54
|
+
const TO_LINE = /^-\s*TO:\s*`###\s+Requirement:\s*(?:(REQ-[A-Z]{2,6}-\d{3})|\[(REQ-[A-Z]{2,6}-\d{3})\](?=\s|`))?\s*(.*?)`\s*$/;
|
|
55
|
+
/** The headers that the TO lines of the RENAMED blocks of one delta file name: [id or null, title]. */
|
|
56
|
+
function renamedTargets(root, file) {
|
|
57
|
+
const out = [];
|
|
58
|
+
let section = "";
|
|
59
|
+
for (const line of readFileSync(join(root, file), "utf8").split(/\r?\n/)) {
|
|
60
|
+
if (line.startsWith("## ")) {
|
|
61
|
+
section = line.slice(3).trim().split(" ")[0].toUpperCase();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (section !== "RENAMED")
|
|
65
|
+
continue;
|
|
66
|
+
const m = TO_LINE.exec(line.trim());
|
|
67
|
+
if (m)
|
|
68
|
+
out.push([m[1] ?? m[2] ?? null, m[3].trim()]);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* REQ-CHK-001 retitle-needs-renamed: a MODIFIED or REMOVED header of an active change whose ID exists in main
|
|
74
|
+
* with another title, and whose delta file has no RENAMED TO line with that header. Upstream OpenSpec matches
|
|
75
|
+
* delta headers by text, so the archive needs the RENAMED pair (021/D1, ADR-006 item 7).
|
|
76
|
+
*/
|
|
77
|
+
export function retitleFindings(root, entries) {
|
|
78
|
+
const main = new Map();
|
|
79
|
+
for (const e of entries)
|
|
80
|
+
if (e.kind === "req" && e.change === "main" && e.status === "active")
|
|
81
|
+
main.set(e.id, e);
|
|
82
|
+
const targets = new Map();
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.kind !== "req" || (e.status !== "modified" && e.status !== "removed") || e.file.includes("/archive/"))
|
|
86
|
+
continue;
|
|
87
|
+
const m = main.get(e.id);
|
|
88
|
+
if (!m || m.title === e.title)
|
|
89
|
+
continue;
|
|
90
|
+
if (!targets.has(e.file))
|
|
91
|
+
targets.set(e.file, renamedTargets(root, e.file));
|
|
92
|
+
if (targets.get(e.file).some(([id, title]) => title === e.title && (id === null || id === e.id)))
|
|
93
|
+
continue;
|
|
94
|
+
out.push({ file: e.file, line: e.line, rule: "retitle-needs-renamed", id: e.id, detail: `"${m.title}" -> "${e.title}"` });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
52
98
|
/** Sorted, unique findings. The scan never reads .ctx/, so evidence adds no index entry. */
|
|
53
99
|
export function check(root, cfg, data, strict) {
|
|
54
100
|
const entries = data.entries;
|
|
@@ -122,5 +168,7 @@ export function check(root, cfg, data, strict) {
|
|
|
122
168
|
const uniq = new Map();
|
|
123
169
|
for (const f of findings)
|
|
124
170
|
uniq.set(JSON.stringify(f), f);
|
|
125
|
-
|
|
171
|
+
const plain = [...uniq.values()].map(([file, line, rule, id]) => ({ file, line, rule, id }));
|
|
172
|
+
const all = [...plain, ...retitleFindings(root, entries)];
|
|
173
|
+
return all.sort((a, b) => cmpTuple([a.file, a.line, a.rule, a.id], [b.file, b.line, b.rule, b.id]));
|
|
126
174
|
}
|
package/dist/check/hints.js
CHANGED
|
@@ -16,10 +16,16 @@ 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
|
|
19
|
+
"done-without-evidence": { level: "hard", hint: "Run specctx task done. It runs the Verify command and writes the evidence." },
|
|
20
20
|
"stale-evidence": { level: "hard", hint: "Run specctx task reopen and then specctx task done." },
|
|
21
21
|
"evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
|
|
22
22
|
"manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
|
|
23
|
+
// REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
|
|
24
|
+
// REQ-ADOPT-010 FROM copies the main header and TO copies the delta header, each in its own form (027/D4).
|
|
25
|
+
"retitle-needs-renamed": {
|
|
26
|
+
level: "hard",
|
|
27
|
+
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>`",
|
|
28
|
+
},
|
|
23
29
|
};
|
|
24
30
|
export function hintOf(rule) {
|
|
25
31
|
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,7 +18,6 @@ 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
22
|
"handoff ask": {},
|
|
23
23
|
"handoff resolve": {},
|
package/dist/cli/commands.js
CHANGED
|
@@ -23,7 +23,8 @@ async function runCheck(root, cfg, data, strict) {
|
|
|
23
23
|
*/
|
|
24
24
|
export function checkText(r, tty) {
|
|
25
25
|
// REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
|
|
26
|
-
|
|
26
|
+
// REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
|
|
27
|
+
const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
|
|
27
28
|
+ r.warnings.map((w) => `warning: ${w}\n`).join("");
|
|
28
29
|
const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
|
|
29
30
|
const n = r.findings.length + r.warnings.length;
|
|
@@ -90,9 +91,9 @@ export async function run(p, io) {
|
|
|
90
91
|
print(plan);
|
|
91
92
|
else if (!plan.edits.length)
|
|
92
93
|
io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
|
|
93
|
-
// REQ-CLI-007 the dry run ends with the next step.
|
|
94
|
+
// 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).
|
|
94
95
|
else
|
|
95
|
-
io.out(planLines(plan).join("\n") + `\
|
|
96
|
+
io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
|
|
96
97
|
return plan.edits.length ? 1 : 0;
|
|
97
98
|
}
|
|
98
99
|
if (cmd === "baseline write" || cmd === "baseline prune") {
|
|
@@ -106,15 +107,18 @@ export async function run(p, io) {
|
|
|
106
107
|
const data = buildIndex(root, cfg);
|
|
107
108
|
const findings = check(root, cfg, data, true);
|
|
108
109
|
const warnings = manualWarnings(root, data.entries, listEvidence(root));
|
|
110
|
+
// REQ-ADOPT-007 the lint debt of the gate globs joins the baseline
|
|
111
|
+
const { lintCounts } = await import("../lint/counts.js");
|
|
112
|
+
const lint = lintCounts(root, cfg.gate.markdown.join(" ")).counts;
|
|
109
113
|
if (cmd === "baseline write") {
|
|
110
|
-
const list = writeBaseline(root, findings, warnings);
|
|
114
|
+
const list = writeBaseline(root, findings, warnings, lint);
|
|
111
115
|
if (json)
|
|
112
116
|
print({ baseline: BASELINE_PATH, entries: list.length });
|
|
113
117
|
else
|
|
114
118
|
io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
|
|
115
119
|
}
|
|
116
120
|
else {
|
|
117
|
-
const n = pruneBaseline(root, findings, warnings);
|
|
121
|
+
const n = pruneBaseline(root, findings, warnings, lint);
|
|
118
122
|
if (json)
|
|
119
123
|
print({ pruned: n });
|
|
120
124
|
else
|
|
@@ -125,11 +129,17 @@ export async function run(p, io) {
|
|
|
125
129
|
if (cmd === "gate") {
|
|
126
130
|
if (p.positional.length)
|
|
127
131
|
throw new UsageExit("gate takes no arguments");
|
|
128
|
-
const { runGate
|
|
132
|
+
const { runGate } = await import("./gate.js");
|
|
129
133
|
const strict = !!f.strict;
|
|
130
134
|
return runGate(root, {
|
|
131
135
|
strict,
|
|
132
|
-
markdown: f.markdown ??
|
|
136
|
+
markdown: f.markdown ?? cfg.gate.markdown.join(" "),
|
|
137
|
+
// REQ-DIST-002 the env var wins over [gate] openspec, for tests and one-off runs (018/D2)
|
|
138
|
+
openspec: process.env.SPECCTX_OPENSPEC ?? cfg.gate.openspec,
|
|
139
|
+
// REQ-ADOPT-008 the ref for the merge base, and the subject override for tests (020/D4)
|
|
140
|
+
base: cfg.gate.base,
|
|
141
|
+
baselineSubject: process.env.SPECCTX_BASELINE_SUBJECT,
|
|
142
|
+
json,
|
|
133
143
|
check: async () => {
|
|
134
144
|
const { buildIndex } = await import("../index/build.js");
|
|
135
145
|
const { check } = await import("../check/check.js");
|
|
@@ -145,7 +155,8 @@ export async function run(p, io) {
|
|
|
145
155
|
if (p.positional.length < n)
|
|
146
156
|
throw new UsageExit(`${cmd} needs ${what}`);
|
|
147
157
|
};
|
|
148
|
-
|
|
158
|
+
// REQ-QRY-001 no term lists a whole change. Without --change a term is required (017/D6).
|
|
159
|
+
if (cmd === "query" && !f.change)
|
|
149
160
|
need(1, "at least one term");
|
|
150
161
|
if (cmd === "related") {
|
|
151
162
|
need(1, "one ID");
|
|
@@ -154,7 +165,7 @@ export async function run(p, io) {
|
|
|
154
165
|
}
|
|
155
166
|
if (cmd === "bundle")
|
|
156
167
|
need(1, "at least one ID");
|
|
157
|
-
if ((cmd === "status" || cmd === "check") && p.positional.length)
|
|
168
|
+
if ((cmd === "status" || cmd === "check" || cmd === "next") && p.positional.length)
|
|
158
169
|
throw new UsageExit(`${cmd} takes no arguments`);
|
|
159
170
|
const { data, rebuilt } = loadIndex(root, cfg);
|
|
160
171
|
if (rebuilt)
|
|
@@ -176,6 +187,16 @@ export async function run(p, io) {
|
|
|
176
187
|
io.out(statusLines(r).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
|
|
177
188
|
return 0;
|
|
178
189
|
}
|
|
190
|
+
case "next": {
|
|
191
|
+
// REQ-LOOP-001 the decision is a pure function of the files. --json prints the object, else the lines with next: last (024/D1).
|
|
192
|
+
const { decideNext, renderNext } = await import("../loop/next.js");
|
|
193
|
+
const d = decideNext(root, cfg, data, f.change);
|
|
194
|
+
if (json)
|
|
195
|
+
print(d);
|
|
196
|
+
else
|
|
197
|
+
io.out(renderNext(d).split("\n").map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l)).join("\n"));
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
179
200
|
case "query": {
|
|
180
201
|
const { query, queryLine } = await import("../graph/query.js");
|
|
181
202
|
const hits = query(entries, p.positional, {
|
|
@@ -267,12 +288,15 @@ async function init(p, io) {
|
|
|
267
288
|
if (p.flags.json)
|
|
268
289
|
io.out(JSON.stringify(r, null, 2) + "\n");
|
|
269
290
|
else if (r.nothing)
|
|
270
|
-
io.out("nothing to do\n" + paint("dim", "next: specctx status\n", io.tty));
|
|
291
|
+
io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
|
|
271
292
|
else {
|
|
272
293
|
io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
|
|
294
|
+
io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
|
|
273
295
|
io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
io.out(r.notes.map((n) => `${n}\n`).join(""));
|
|
297
|
+
// REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
|
|
298
|
+
const again = ["specctx init", all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
|
|
299
|
+
io.out(paint("dim", `next: ${r.changed.length ? again : "specctx status"}\n`, io.tty));
|
|
276
300
|
}
|
|
277
301
|
return 0;
|
|
278
302
|
}
|
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);
|
|
@@ -40,7 +39,9 @@ export function markdownFiles(root, globs) {
|
|
|
40
39
|
}
|
|
41
40
|
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. */
|
|
43
|
+
export const LINT_DETAIL_CAP = 50;
|
|
44
|
+
export const GATE_STEPS = ["check", "baseline", "lint tells", "lint ste", "validate", "spec-length"];
|
|
44
45
|
/** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
|
|
45
46
|
export function appendGateLog(root, line) {
|
|
46
47
|
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
@@ -65,45 +66,41 @@ async function gateSteps(root, o, io, ran) {
|
|
|
65
66
|
return "check";
|
|
66
67
|
}
|
|
67
68
|
io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
|
|
68
|
-
// gate 2: REQ-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
76
|
+
// REQ-ADOPT-007 the step fails only on a count above its baseline line.
|
|
77
|
+
const { lintCounts, lintRises } = await import("../lint/counts.js");
|
|
78
|
+
const { readBaseline } = await import("../baseline/baseline.js");
|
|
75
79
|
ran.push("lint tells", "lint ste");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (steHard)
|
|
89
|
-
bad.push(`${f}\n` + steReport(ste, r.words, false, steHard, 0));
|
|
80
|
+
const { counts, files } = lintCounts(root, o.markdown);
|
|
81
|
+
const base = readBaseline(root);
|
|
82
|
+
const rises = lintRises(counts, base);
|
|
83
|
+
const baselined = [...base ?? []].filter((l) => l.startsWith("lint ")).length;
|
|
84
|
+
if (rises.length) {
|
|
85
|
+
const newFindings = rises.reduce((a, r) => a + r.current - r.baseline, 0);
|
|
86
|
+
const inFiles = new Set(rises.map((r) => r.key.split(" ")[1])).size;
|
|
87
|
+
const lines = rises.map((r) => `${r.key.slice(5).replace(" ", " ")} ${r.current} (baseline ${r.baseline})`);
|
|
88
|
+
const shown = o.json ? lines : lines.slice(0, LINT_DETAIL_CAP);
|
|
89
|
+
const more = lines.length - shown.length;
|
|
90
|
+
io.out(`linters: fail, ${newFindings} new finding(s) in ${inFiles} file(s)\n` + shown.map((l) => l + "\n").join("") + (more ? `... ${more} more line(s)\n` : ""));
|
|
91
|
+
return rises.some((r) => r.key.includes(" tells:")) ? "lint tells" : "lint ste";
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
io.out("linters: fail\n" + bad.join(""));
|
|
93
|
-
return tellsFailed ? "lint tells" : "lint ste";
|
|
94
|
-
}
|
|
95
|
-
io.out(`linters: ok (${files.length} file(s))\n`);
|
|
93
|
+
io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
|
|
96
94
|
ran.push("validate");
|
|
97
|
-
// gate
|
|
98
|
-
const
|
|
99
|
-
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" });
|
|
100
97
|
if (v.status !== 0) {
|
|
101
98
|
io.out("validate: fail\n" + (v.stdout ?? "") + (v.stderr ?? ""));
|
|
102
99
|
return "validate";
|
|
103
100
|
}
|
|
104
101
|
io.out(`validate: ok${o.strict ? " (strict)" : ""}\n`);
|
|
105
102
|
ran.push("spec-length");
|
|
106
|
-
// gate
|
|
103
|
+
// gate 5: the length of every requirement in the deltas of active changes
|
|
107
104
|
const long = longRequirements(root);
|
|
108
105
|
if (long.length) {
|
|
109
106
|
io.out(long.map((l) => `spec-length: fail ${l.id} ${l.chars} chars\n`).join(""));
|
|
@@ -112,6 +109,27 @@ async function gateSteps(root, o, io, ran) {
|
|
|
112
109
|
io.out("spec-length: ok\n");
|
|
113
110
|
return null;
|
|
114
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
|
+
}
|
|
115
133
|
// REQ-CLI-005 the limit strict validate applies to a requirement after archive
|
|
116
134
|
export const MAX_REQUIREMENT_CHARS = 500;
|
|
117
135
|
/** Lines inside a fenced code block, fence lines included. Same rule as OpenSpec. */
|
package/dist/cli/help.js
CHANGED
|
@@ -23,9 +23,14 @@ 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
25
|
{
|
|
26
|
-
name: "
|
|
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
|
+
},
|
|
30
|
+
{
|
|
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: [
|
|
28
|
-
["--kind <kind>", "Show only entries of this kind."],
|
|
33
|
+
["--kind <kind>", "Show only entries of this kind. Any case works."],
|
|
29
34
|
["--change <name>", "Show only entries of this change. The key or the full folder name works."],
|
|
30
35
|
["--limit <n>", "Show n lines at most. The default is 20. Use 0 for no limit."],
|
|
31
36
|
["--all", "Show all copies of an ID."],
|
|
@@ -53,10 +58,10 @@ 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
|
-
["--markdown <globs>", "Lint these markdown globs. Separate them with spaces."],
|
|
64
|
+
["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
|
|
60
65
|
],
|
|
61
66
|
exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
|
|
62
67
|
},
|
|
@@ -119,10 +124,10 @@ export const TABLE = [
|
|
|
119
124
|
{
|
|
120
125
|
name: "init", group: "Setup", args: "", summary: "Write a default spec-ctx.toml file.",
|
|
121
126
|
flags: [
|
|
122
|
-
["--schema", "Also install the
|
|
123
|
-
["--skills", "Also install the
|
|
127
|
+
["--schema", "Also install the ID templates and add the spec-ctx rules to openspec/config.yaml."],
|
|
128
|
+
["--skills", "Also install the specctx-run skill and its aliases in .claude/skills and .agents/skills."],
|
|
124
129
|
["--all", "Do all three."],
|
|
125
|
-
["--force", "
|
|
130
|
+
["--force", "Replace the files that have other content. spec-ctx.toml stays."],
|
|
126
131
|
],
|
|
127
132
|
exit: PLAIN, example: "specctx init --all",
|
|
128
133
|
},
|
|
@@ -132,11 +137,6 @@ export const TABLE = [
|
|
|
132
137
|
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
138
|
example: "specctx adopt --dry-run",
|
|
134
139
|
},
|
|
135
|
-
{
|
|
136
|
-
name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task 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
140
|
{
|
|
141
141
|
name: "baseline write", group: "Setup", args: "", summary: "Record the current debt in .ctx/baseline.json. `check` then hides it.",
|
|
142
142
|
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,7 +61,7 @@ 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
|
export function statusLines(r) {
|
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,11 +91,14 @@ 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");
|
|
96
|
+
// 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 specctx query --change ${t.change} --kind req to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
|
|
95
98
|
if (c.json)
|
|
96
|
-
print(c, { bundle: b, task: t.id });
|
|
99
|
+
print(c, { bundle: b, hint, task: t.id });
|
|
97
100
|
else
|
|
98
|
-
c.io.out(b.text + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
101
|
+
c.io.out(b.text + (hint ? hint + "\n" : "") + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
|
|
99
102
|
return 0;
|
|
100
103
|
}
|
|
101
104
|
case "task verify":
|
|
@@ -108,11 +111,13 @@ export async function runWorkflow(c) {
|
|
|
108
111
|
if (!dry && manual !== undefined) {
|
|
109
112
|
if (!manual.trim())
|
|
110
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}.`);
|
|
111
117
|
if (t.done && hasEvidence(c.root, t))
|
|
112
118
|
throw new UsageExit(`task ${t.id} is done`);
|
|
113
|
-
// a manual note records evidence without a run. On a task done by hand it backfills the evidence.
|
|
114
119
|
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
115
|
-
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());
|
|
116
121
|
if (!t.done)
|
|
117
122
|
setBox(c.root, t, true);
|
|
118
123
|
clearCurrent(c.root, t.id);
|
|
@@ -122,11 +127,19 @@ export async function runWorkflow(c) {
|
|
|
122
127
|
c.io.out(paint("ok", `done ${t.id} (manual). Evidence: ${rel}\n`, c.io.tty));
|
|
123
128
|
return 0;
|
|
124
129
|
}
|
|
125
|
-
|
|
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))
|
|
126
132
|
throw new UsageExit(`task ${t.id} is done`);
|
|
127
133
|
if (t.verify.command === null) {
|
|
128
134
|
throw new UsageExit(`task ${t.id} has no Verify command. ${dry ? "Nothing to run" : 'A --manual "<note>" is required'}`);
|
|
129
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
|
+
}
|
|
130
143
|
const { runVerify, tail } = await import("../verify/run.js");
|
|
131
144
|
const { resolve: resolvePath, relative, isAbsolute } = await import("node:path");
|
|
132
145
|
let dir = c.root;
|
|
@@ -151,8 +164,16 @@ export async function runWorkflow(c) {
|
|
|
151
164
|
}
|
|
152
165
|
if (r.timedOut || r.exit !== 0) {
|
|
153
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
|
+
}
|
|
154
175
|
if (c.json)
|
|
155
|
-
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 });
|
|
156
177
|
else
|
|
157
178
|
c.io.out(outTail ? outTail + "\n" : "");
|
|
158
179
|
// REQ-VER-003 a timeout keeps its own line on stderr
|
|
@@ -161,6 +182,9 @@ export async function runWorkflow(c) {
|
|
|
161
182
|
// REQ-TASK-003 the refusal is the last line of the output. REQ-CLI-008 it is red on a TTY.
|
|
162
183
|
else if (!c.json)
|
|
163
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");
|
|
164
188
|
return 1;
|
|
165
189
|
}
|
|
166
190
|
const { sha256 } = await import("../util/hash.js");
|
|
@@ -179,37 +203,6 @@ export async function runWorkflow(c) {
|
|
|
179
203
|
c.io.out(paint("ok", `done ${t.id}. Evidence: ${rel}\n`, c.io.tty)); // REQ-CLI-008
|
|
180
204
|
return 0;
|
|
181
205
|
}
|
|
182
|
-
case "task backfill": {
|
|
183
|
-
// REQ-TASK-006 manual evidence for every ticked task that has none, through the task done --manual path
|
|
184
|
-
noMore(c, 0);
|
|
185
|
-
const note = f.manual?.trim();
|
|
186
|
-
if (!note)
|
|
187
|
-
throw new UsageExit('task backfill needs --manual "<note>"');
|
|
188
|
-
const { tasksOf, toTask, hasEvidence } = await import("../tasks/tasks.js");
|
|
189
|
-
const { matchesChange } = await import("../index/scan.js");
|
|
190
|
-
const { writeEvidence } = await import("../tasks/evidence.js");
|
|
191
|
-
const want = f.change;
|
|
192
|
-
const counts = new Map();
|
|
193
|
-
for (const e of tasksOf(entries)) {
|
|
194
|
-
if (e.status !== "done")
|
|
195
|
-
continue;
|
|
196
|
-
if (want && !matchesChange(e.change, want))
|
|
197
|
-
continue;
|
|
198
|
-
if (hasEvidence(c.root, e))
|
|
199
|
-
continue;
|
|
200
|
-
const t = toTask(c.root, e);
|
|
201
|
-
writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", note);
|
|
202
|
-
counts.set(e.change, (counts.get(e.change) ?? 0) + 1);
|
|
203
|
-
}
|
|
204
|
-
const { cmpStr } = await import("../util/pystr.js");
|
|
205
|
-
const rows = [...counts.keys()].sort(cmpStr).map((k) => ({ change: k, count: counts.get(k) }));
|
|
206
|
-
const total = rows.reduce((n, r) => n + r.count, 0);
|
|
207
|
-
if (c.json)
|
|
208
|
-
print(c, { changes: rows, total });
|
|
209
|
-
else
|
|
210
|
-
c.io.out(rows.map((r) => `${r.change}: ${r.count}\n`).join("") + `backfilled ${total} task(s)\n`);
|
|
211
|
-
return 0;
|
|
212
|
-
}
|
|
213
206
|
case "task reopen": {
|
|
214
207
|
const { findTask, setBox } = await import("../tasks/tasks.js");
|
|
215
208
|
const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
|
|
@@ -237,8 +230,12 @@ export async function runWorkflow(c) {
|
|
|
237
230
|
const h = writeHandoff(c.root, entries);
|
|
238
231
|
if (c.json)
|
|
239
232
|
print(c, h);
|
|
240
|
-
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`);
|
|
241
237
|
c.io.out(`wrote ${HANDOFF_PATH}\n`);
|
|
238
|
+
}
|
|
242
239
|
return 0;
|
|
243
240
|
}
|
|
244
241
|
case "handoff ask": {
|
|
@@ -247,7 +244,7 @@ export async function runWorkflow(c) {
|
|
|
247
244
|
if (c.json)
|
|
248
245
|
print(c, { open_questions: h.open_questions });
|
|
249
246
|
else
|
|
250
|
-
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`);
|
|
251
248
|
return 0;
|
|
252
249
|
}
|
|
253
250
|
case "handoff resolve": {
|
|
@@ -265,6 +262,18 @@ export async function runWorkflow(c) {
|
|
|
265
262
|
}
|
|
266
263
|
throw new UsageExit(`unknown command: ${c.p.command}`);
|
|
267
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
|
+
}
|
|
268
277
|
function clearCurrent(root, task) {
|
|
269
278
|
const p = join(root, ".ctx/current.json");
|
|
270
279
|
if (!existsSync(p))
|