spec-ctx 0.0.0-stage → 0.1.1

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -2
  3. package/action.yml +33 -0
  4. package/dist/adopt/config-write.js +59 -0
  5. package/dist/adopt/plan.js +276 -0
  6. package/dist/adopt/write.js +126 -0
  7. package/dist/assets.generated.js +14 -0
  8. package/dist/baseline/baseline.js +65 -0
  9. package/dist/check/check.js +126 -0
  10. package/dist/check/evidence.js +70 -0
  11. package/dist/check/hints.js +26 -0
  12. package/dist/cli/args.js +90 -0
  13. package/dist/cli/color.js +23 -0
  14. package/dist/cli/commands.js +278 -0
  15. package/dist/cli/errors.js +3 -0
  16. package/dist/cli/gate.js +187 -0
  17. package/dist/cli/help.js +230 -0
  18. package/dist/cli/root.js +32 -0
  19. package/dist/cli/status.js +116 -0
  20. package/dist/cli/workflow.js +285 -0
  21. package/dist/cli.js +14 -0
  22. package/dist/config.js +119 -0
  23. package/dist/graph/bundle.js +94 -0
  24. package/dist/graph/query.js +31 -0
  25. package/dist/graph/related.js +76 -0
  26. package/dist/handoff/handoff.js +148 -0
  27. package/dist/ids/ids.js +115 -0
  28. package/dist/index/build.js +57 -0
  29. package/dist/index/canonical.js +44 -0
  30. package/dist/index/scan.js +357 -0
  31. package/dist/index/types.js +2 -0
  32. package/dist/init/init.js +169 -0
  33. package/dist/lint/cli.js +253 -0
  34. package/dist/lint/py.js +152 -0
  35. package/dist/lint/selftest.js +246 -0
  36. package/dist/lint/ste.js +395 -0
  37. package/dist/lint/tells.js +116 -0
  38. package/dist/main.js +74 -0
  39. package/dist/stats/stats.js +147 -0
  40. package/dist/stats/write.js +26 -0
  41. package/dist/tasks/evidence.js +102 -0
  42. package/dist/tasks/tasks.js +105 -0
  43. package/dist/util/glob.js +43 -0
  44. package/dist/util/hash.js +14 -0
  45. package/dist/util/json.js +16 -0
  46. package/dist/util/pystr.js +67 -0
  47. package/dist/util/time.js +11 -0
  48. package/dist/verify/parse.js +58 -0
  49. package/dist/verify/run.js +42 -0
  50. package/dist/version.js +2 -0
  51. package/package.json +50 -4
  52. package/schemas/spec-driven-ids/README.md +31 -0
  53. package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
  54. package/schemas/spec-driven-ids/schema.yaml +301 -0
  55. package/schemas/spec-driven-ids/templates/design.md +22 -0
  56. package/schemas/spec-driven-ids/templates/proposal.md +34 -0
  57. package/schemas/spec-driven-ids/templates/spec.md +15 -0
  58. package/schemas/spec-driven-ids/templates/tasks.md +10 -0
  59. package/skills/specctx-bootstrap/SKILL.md +27 -0
  60. package/skills/specctx-done/SKILL.md +25 -0
  61. package/skills/specctx-next/SKILL.md +23 -0
@@ -0,0 +1,126 @@
1
+ // REQ-CHK-001 REQ-CHK-002 REQ-GOV-002 integrity rules over the index.
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canon, SPEC_KINDS } from "../index/canonical.js";
5
+ import { changeKey } from "../index/scan.js";
6
+ import { listEvidence } from "../tasks/evidence.js";
7
+ import { cmpTuple } from "../util/pystr.js";
8
+ import { evidenceFindings } from "./evidence.js";
9
+ const RETIRED_RE = /\b(REQ-[A-Z]{2,6}-\d{3}|ADR-\d{3})\b/g;
10
+ const REQ_CAP = /^REQ-([A-Z]{2,6})-/;
11
+ export function retiredIds(root) {
12
+ const p = join(root, "decisions", "retired-ids.md");
13
+ if (!existsSync(p))
14
+ return new Set();
15
+ // REQ-GOV-002 the retired ID is the first ID of a line. A reason may name the replacement ID, which stays live.
16
+ const out = new Set();
17
+ for (const line of readFileSync(p, "utf8").split("\n")) {
18
+ const m = new RegExp(RETIRED_RE.source).exec(line);
19
+ if (m)
20
+ out.add(m[1]);
21
+ }
22
+ return out;
23
+ }
24
+ /**
25
+ * REQ-CHK-004 two change folders, active or archived, with one key are a hard finding.
26
+ * The first folder in path order keeps the key. Every later folder gets a finding.
27
+ */
28
+ export function duplicateChangeKeys(root) {
29
+ const base = join(root, "openspec/changes");
30
+ const dirs = (abs) => {
31
+ if (!existsSync(abs))
32
+ return [];
33
+ return readdirSync(abs).filter((n) => statSync(join(abs, n)).isDirectory()).sort();
34
+ };
35
+ const folders = [];
36
+ for (const n of dirs(base))
37
+ if (n !== "archive")
38
+ folders.push([`openspec/changes/${n}`, n]);
39
+ // openspec archive prefixes the folder with a date: 2026-10-23-001-foundation
40
+ for (const n of dirs(join(base, "archive")))
41
+ folders.push([`openspec/changes/archive/${n}`, n.replace(/^\d{4}-\d{2}-\d{2}-/, "")]);
42
+ const seen = new Set();
43
+ const out = [];
44
+ for (const [rel, name] of folders.sort((a, b) => cmpTuple(a, b))) {
45
+ const key = changeKey(name);
46
+ if (seen.has(key))
47
+ out.push([rel, 1, "duplicate-change-key", key]);
48
+ seen.add(key);
49
+ }
50
+ return out;
51
+ }
52
+ /** Sorted, unique findings. The scan never reads .ctx/, so evidence adds no index entry. */
53
+ export function check(root, cfg, data, strict) {
54
+ const entries = data.entries;
55
+ const findings = data.problems.map((p) => [...p]);
56
+ findings.push(...duplicateChangeKeys(root));
57
+ const seen = new Set();
58
+ for (const e of entries) {
59
+ if (!SPEC_KINDS.has(e.kind))
60
+ continue;
61
+ // archived folders keep a copy of main specs. A MODIFIED or REMOVED block is a pending edit of one.
62
+ if (e.status === "archived" || e.status === "modified" || e.status === "removed")
63
+ continue;
64
+ if (seen.has(e.id))
65
+ findings.push([e.file, e.line, "duplicate-id", e.id]);
66
+ seen.add(e.id);
67
+ }
68
+ const known = new Set(entries.filter((e) => ["req", "scn", "adr", "task"].includes(e.kind)).map((e) => e.id));
69
+ const ids = canon(entries);
70
+ const retired = retiredIds(root);
71
+ const caps = new Set(cfg.ids.caps);
72
+ for (const e of entries) {
73
+ if ((e.kind === "req" || e.kind === "adr") && retired.has(e.id) && e.status !== "archived") {
74
+ findings.push([e.file, e.line, "reused-id", e.id]);
75
+ }
76
+ for (const r of e.refs) {
77
+ if (!known.has(r)) {
78
+ if (e.kind === "task" && !strict)
79
+ continue;
80
+ findings.push([e.file, e.line, e.kind === "task" ? "unknown-task-req" : "dangling-ref", r]);
81
+ }
82
+ else if (e.kind === "code" && (!ids.has(r) || ids.get(r).status === "archived")) {
83
+ // the tag points at a requirement that lives only in an archived change
84
+ findings.push([e.file, e.line, "code-tag-no-canonical", r]);
85
+ }
86
+ }
87
+ // an archived block is history. An archived REMOVED block has no scenario by design.
88
+ if (e.kind === "req" && e.status !== "removed" && e.status !== "archived" && !(e.scenarios ?? []).length) {
89
+ findings.push([e.file, e.line, "req-without-scn", e.id]);
90
+ }
91
+ if (caps.size && e.kind === "req" && e.status !== "archived") {
92
+ const m = REQ_CAP.exec(e.id);
93
+ if (m && !caps.has(m[1]))
94
+ findings.push([e.file, e.line, "unknown-cap", e.id]);
95
+ }
96
+ }
97
+ if (strict) {
98
+ const covered = new Set(entries.filter((e) => e.kind === "test").flatMap((e) => e.refs));
99
+ const named = new Set(data.scn_named ?? []);
100
+ // an ID that a REMOVED block of an active change names waits for archive, so it needs no test
101
+ const pending = new Set();
102
+ for (const e of entries) {
103
+ if (e.status !== "removed")
104
+ continue;
105
+ pending.add(e.id);
106
+ for (const s of e.scenarios ?? [])
107
+ pending.add(s);
108
+ }
109
+ const scnOf = new Map(entries.filter((e) => e.kind === "req").flatMap((e) => (e.scenarios ?? []).map((s) => [s, e.id])));
110
+ for (const e of entries) {
111
+ if (e.change !== "main" || e.status !== "active")
112
+ continue;
113
+ if (pending.has(e.id) || (e.kind === "scn" && pending.has(scnOf.get(e.id) ?? "")))
114
+ continue;
115
+ if (e.kind === "req" && !covered.has(e.id))
116
+ findings.push([e.file, e.line, "uncovered", e.id]);
117
+ if (e.kind === "scn" && !named.has(e.id))
118
+ findings.push([e.file, e.line, "scenario-untested", e.id]);
119
+ }
120
+ findings.push(...evidenceFindings(root, entries, listEvidence(root)));
121
+ }
122
+ const uniq = new Map();
123
+ for (const f of findings)
124
+ uniq.set(JSON.stringify(f), f);
125
+ return [...uniq.values()].sort((a, b) => cmpTuple(a, b)).map(([file, line, rule, id]) => ({ file, line, rule, id }));
126
+ }
@@ -0,0 +1,70 @@
1
+ // REQ-CHK-003 evidence rules of check --strict.
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileSha256 } from "../util/hash.js";
5
+ export function evidenceFindings(root, entries, evidence) {
6
+ const out = [];
7
+ const tasks = new Map();
8
+ for (const e of entries) {
9
+ if (e.kind !== "task")
10
+ continue;
11
+ const l = tasks.get(e.id) ?? [];
12
+ l.push(e);
13
+ tasks.set(e.id, l);
14
+ }
15
+ const have = new Set();
16
+ for (const rel of evidence) {
17
+ const parts = rel.split("/");
18
+ const id = `${parts[2]}/${parts[3].replace(/\.json$/, "")}`;
19
+ have.add(id);
20
+ const copies = tasks.get(id) ?? [];
21
+ const active = copies.find((t) => !t.file.includes("/archive/"));
22
+ const task = active ?? copies[0];
23
+ if (!task || task.status !== "done") {
24
+ out.push([rel, 1, "evidence-without-done", id]);
25
+ continue;
26
+ }
27
+ // archived changes are exempt from stale-evidence
28
+ if (!active)
29
+ continue;
30
+ let files = {};
31
+ try {
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
+ }
43
+ }
44
+ for (const e of entries) {
45
+ if (e.kind === "task" && e.status === "done" && !e.file.includes("/archive/") && !have.has(e.id)) {
46
+ out.push([e.file, e.line, "done-without-evidence", e.id]);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /** REQ-CHK-003 manual evidence for a task in an active change is a warning, not a finding. */
52
+ export function manualWarnings(root, entries, evidence) {
53
+ const active = new Set(entries.filter((e) => e.kind === "task" && !e.file.includes("/archive/") && e.change !== "main").map((e) => e.id));
54
+ const out = [];
55
+ for (const rel of evidence) {
56
+ const parts = rel.split("/");
57
+ const id = `${parts[2]}/${parts[3].replace(/\.json$/, "")}`;
58
+ if (!active.has(id))
59
+ continue;
60
+ try {
61
+ const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
62
+ if (ev.kind === "manual")
63
+ out.push(`manual-evidence ${id}`);
64
+ }
65
+ catch {
66
+ // a damaged file is a stale-evidence finding already
67
+ }
68
+ }
69
+ return out.sort();
70
+ }
@@ -0,0 +1,26 @@
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
+ /** `hard` rules set exit 1. `advisory` rules print a warning and keep exit 0. */
3
+ export const RULES = {
4
+ "missing-id": { level: "hard", hint: "Add an ID to the header. Run specctx adopt --dry-run to see the plan." },
5
+ "scenario-id-mismatch": { level: "hard", hint: "Give the scenario the number of its requirement." },
6
+ "bad-ref": { level: "hard", hint: "Write only IDs on the Refs line, separated by commas." },
7
+ "task-without-req": { level: "hard", hint: "Cite a requirement in brackets on the task line, for example [REQ-ABC-001]." },
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 specctx id new." },
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 specctx id new." },
12
+ "dangling-ref": { level: "hard", hint: "Fix the ID or remove the link. No entry has this ID." },
13
+ "unknown-task-req": { level: "hard", hint: "The task cites an ID that the index does not hold. Fix the ID on the task line." },
14
+ "req-without-scn": { level: "hard", hint: "Add a scenario under the requirement." },
15
+ "code-tag-no-canonical": { level: "hard", hint: "The tag names an archived requirement. Tag the current requirement." },
16
+ "unknown-cap": { level: "hard", hint: "Add the code to caps under [ids] in spec-ctx.toml." },
17
+ uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
18
+ "scenario-untested": { level: "hard", hint: "Name the scenario ID in a test." },
19
+ "done-without-evidence": { level: "hard", hint: "Run specctx task done, or specctx task backfill for old tasks." },
20
+ "stale-evidence": { level: "hard", hint: "Run specctx task reopen and then specctx task done." },
21
+ "evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
22
+ "manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
23
+ };
24
+ export function hintOf(rule) {
25
+ return RULES[rule]?.hint ?? "Read the check rules in the check spec.";
26
+ }
@@ -0,0 +1,90 @@
1
+ // REQ-CLI-001 argument parser. It loads nothing else, so --version stays fast.
2
+ export const GLOBAL_FLAGS = { root: "string", json: "bool", help: "bool", version: "bool" };
3
+ /** Flags per command. A group command such as `task done` has a two word key. */
4
+ export const COMMAND_FLAGS = {
5
+ build: {},
6
+ status: {},
7
+ query: { kind: "string", change: "string", all: "bool", limit: "int" },
8
+ related: { depth: "int", all: "bool" },
9
+ bundle: { budget: "int", depth: "int", all: "bool" },
10
+ check: { strict: "bool" },
11
+ stats: { change: "string", write: "bool" },
12
+ gate: { strict: "bool", markdown: "string" },
13
+ "id new": { for: "string", count: "int" },
14
+ "id retire": {},
15
+ "task next": { change: "string" },
16
+ "task start": {},
17
+ "task verify": {},
18
+ "task done": { manual: "string" },
19
+ "task reopen": {},
20
+ "task backfill": { manual: "string", change: "string" },
21
+ "handoff write": {},
22
+ "handoff ask": {},
23
+ "handoff resolve": {},
24
+ adopt: { "dry-run": "bool" },
25
+ "baseline write": {},
26
+ "baseline prune": {},
27
+ init: { schema: "bool", skills: "bool", all: "bool", force: "bool" },
28
+ };
29
+ export const GROUPS = ["id", "task", "handoff", "baseline"];
30
+ export const COMMANDS = Object.keys(COMMAND_FLAGS);
31
+ export class UsageError extends Error {
32
+ }
33
+ const SHORT = { "-h": "help", "-V": "version" };
34
+ export function parseArgs(argv) {
35
+ const out = { command: null, positional: [], flags: {} };
36
+ const spec = () => ({ ...GLOBAL_FLAGS, ...(out.command ? COMMAND_FLAGS[out.command] ?? {} : {}) });
37
+ let onlyPositional = false;
38
+ const positional = (a) => {
39
+ if (out.command === null)
40
+ out.command = a;
41
+ else if (GROUPS.includes(out.command) && !out.positional.length && !COMMAND_FLAGS[out.command])
42
+ out.command = `${out.command} ${a}`;
43
+ else
44
+ out.positional.push(a);
45
+ };
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (onlyPositional || !a.startsWith("-") || a === "-") {
49
+ positional(a);
50
+ continue;
51
+ }
52
+ if (a === "--") {
53
+ onlyPositional = true;
54
+ continue;
55
+ }
56
+ let name;
57
+ let value;
58
+ if (SHORT[a])
59
+ name = SHORT[a];
60
+ else if (a.startsWith("--")) {
61
+ const eq = a.indexOf("=");
62
+ name = eq < 0 ? a.slice(2) : a.slice(2, eq);
63
+ value = eq < 0 ? undefined : a.slice(eq + 1);
64
+ }
65
+ else
66
+ throw new UsageError(`unknown flag: ${a}`);
67
+ const kind = spec()[name];
68
+ if (!kind)
69
+ throw new UsageError(`unknown flag: --${name}`);
70
+ if (kind === "bool") {
71
+ if (value !== undefined)
72
+ throw new UsageError(`flag --${name} takes no value`);
73
+ out.flags[name] = true;
74
+ continue;
75
+ }
76
+ if (value === undefined) {
77
+ value = argv[++i];
78
+ if (value === undefined)
79
+ throw new UsageError(`flag --${name} needs a value`);
80
+ }
81
+ if (kind === "int") {
82
+ if (!/^-?\d+$/.test(value))
83
+ throw new UsageError(`flag --${name} needs an integer`);
84
+ out.flags[name] = Number(value);
85
+ }
86
+ else
87
+ out.flags[name] = value;
88
+ }
89
+ return out;
90
+ }
@@ -0,0 +1,23 @@
1
+ // REQ-CLI-008 color on a TTY. Without color the bytes equal the plain output.
2
+ const CODES = { ok: "\x1b[32m", fail: "\x1b[31m", dim: "\x1b[2m" };
3
+ const RESET = "\x1b[0m";
4
+ /**
5
+ * Color is on when `FORCE_COLOR` is set, or when stdout is a TTY and `TERM` is not `dumb`.
6
+ * `NO_COLOR` wins over both. An empty value counts as unset. `FORCE_COLOR=0` counts as unset.
7
+ */
8
+ export function colorOn(tty, env = process.env) {
9
+ if (env.NO_COLOR)
10
+ return false;
11
+ const force = env.FORCE_COLOR;
12
+ if (force && force !== "0" && force !== "false")
13
+ return true;
14
+ return tty && env.TERM !== "dumb";
15
+ }
16
+ /** Wrap one line of text in the code of its kind. A trailing newline stays outside the codes. */
17
+ export function paint(kind, text, tty) {
18
+ if (!colorOn(tty === true))
19
+ return text;
20
+ const nl = text.endsWith("\n") ? "\n" : "";
21
+ const body = nl ? text.slice(0, -1) : text;
22
+ return `${CODES[kind]}${body}${RESET}${nl}`;
23
+ }
@@ -0,0 +1,278 @@
1
+ // REQ-CLI-001 REQ-CLI-002 command actions.
2
+ import { resolve } from "node:path";
3
+ import { UsageExit } from "./errors.js";
4
+ import { findRoot, requireOpenSpec } from "./root.js";
5
+ import { hintOf } from "../check/hints.js";
6
+ import { paint } from "./color.js";
7
+ /** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
8
+ async function runCheck(root, cfg, data, strict) {
9
+ const { check } = await import("../check/check.js");
10
+ const { applyBaseline } = await import("../baseline/baseline.js");
11
+ const findings = check(root, cfg, data, strict);
12
+ let warnings = [];
13
+ if (strict) {
14
+ const { manualWarnings } = await import("../check/evidence.js");
15
+ const { listEvidence } = await import("../tasks/evidence.js");
16
+ warnings = manualWarnings(root, data.entries, listEvidence(root));
17
+ }
18
+ return applyBaseline(root, findings, warnings);
19
+ }
20
+ /**
21
+ * REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
22
+ * A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
23
+ */
24
+ export function checkText(r, tty) {
25
+ // REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
26
+ const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}\n`, tty)).join("")
27
+ + r.warnings.map((w) => `warning: ${w}\n`).join("");
28
+ const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
29
+ const n = r.findings.length + r.warnings.length;
30
+ if (!n)
31
+ return paint("dim", "0 findings\n", tty) + base;
32
+ const files = new Set(r.findings.map((x) => x.file));
33
+ const count = new Map();
34
+ for (const x of r.findings)
35
+ count.set(x.rule, (count.get(x.rule) ?? 0) + 1);
36
+ for (const w of r.warnings) {
37
+ const [rule, task] = w.split(" ");
38
+ count.set(rule, (count.get(rule) ?? 0) + 1);
39
+ // a manual-evidence warning names a task. Its file is the evidence file of the task.
40
+ if (task?.includes("/"))
41
+ files.add(`.ctx/evidence/${task}.json`);
42
+ }
43
+ const rules = [...count.keys()].sort();
44
+ return body
45
+ + "\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("")
47
+ + base;
48
+ }
49
+ export async function run(p, io) {
50
+ const f = p.flags;
51
+ const json = !!f.json;
52
+ const cmd = p.command;
53
+ if (cmd === "init")
54
+ return init(p, io);
55
+ const root = findRoot(io.cwd, f.root);
56
+ requireOpenSpec(root);
57
+ const { loadConfig, ConfigError } = await import("../config.js");
58
+ let cfg;
59
+ try {
60
+ cfg = loadConfig(root);
61
+ }
62
+ catch (e) {
63
+ if (e instanceof ConfigError)
64
+ throw new UsageExit(e.message);
65
+ throw e;
66
+ }
67
+ const { dumps } = await import("../util/json.js");
68
+ const print = (v) => io.out(dumps(v) + "\n");
69
+ if (cmd === "build") {
70
+ if (p.positional.length)
71
+ throw new UsageExit("build takes no arguments");
72
+ const { buildIndex, INDEX_PATH } = await import("../index/build.js");
73
+ const data = buildIndex(root, cfg);
74
+ if (json)
75
+ print({ entries: data.entries.length, index: INDEX_PATH, problems: data.problems.length });
76
+ else
77
+ io.out(`indexed ${data.entries.length} entries, ${data.problems.length} problem(s) -> ${INDEX_PATH}\n`);
78
+ return 0;
79
+ }
80
+ if (cmd === "adopt") {
81
+ if (p.positional.length)
82
+ throw new UsageExit("adopt takes no arguments");
83
+ const { planAdopt, planLines } = await import("../adopt/plan.js");
84
+ const plan = planAdopt(root, cfg);
85
+ if (!f["dry-run"]) {
86
+ const { applyAdopt } = await import("../adopt/write.js");
87
+ return applyAdopt(root, cfg, plan, io, json);
88
+ }
89
+ if (json)
90
+ print(plan);
91
+ else if (!plan.edits.length)
92
+ 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. The exit code stays 1.
94
+ else
95
+ io.out(planLines(plan).join("\n") + `\n${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
96
+ return plan.edits.length ? 1 : 0;
97
+ }
98
+ if (cmd === "baseline write" || cmd === "baseline prune") {
99
+ if (p.positional.length)
100
+ throw new UsageExit(`${cmd} takes no arguments`);
101
+ const { buildIndex } = await import("../index/build.js");
102
+ const { check } = await import("../check/check.js");
103
+ const { manualWarnings } = await import("../check/evidence.js");
104
+ const { listEvidence } = await import("../tasks/evidence.js");
105
+ const { writeBaseline, pruneBaseline, BASELINE_PATH } = await import("../baseline/baseline.js");
106
+ const data = buildIndex(root, cfg);
107
+ const findings = check(root, cfg, data, true);
108
+ const warnings = manualWarnings(root, data.entries, listEvidence(root));
109
+ if (cmd === "baseline write") {
110
+ const list = writeBaseline(root, findings, warnings);
111
+ if (json)
112
+ print({ baseline: BASELINE_PATH, entries: list.length });
113
+ else
114
+ io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
115
+ }
116
+ else {
117
+ const n = pruneBaseline(root, findings, warnings);
118
+ if (json)
119
+ print({ pruned: n });
120
+ else
121
+ io.out(`pruned ${n}\n`);
122
+ }
123
+ return 0;
124
+ }
125
+ if (cmd === "gate") {
126
+ if (p.positional.length)
127
+ throw new UsageExit("gate takes no arguments");
128
+ const { runGate, DEFAULT_MARKDOWN } = await import("./gate.js");
129
+ const strict = !!f.strict;
130
+ return runGate(root, {
131
+ strict,
132
+ markdown: f.markdown ?? DEFAULT_MARKDOWN,
133
+ check: async () => {
134
+ const { buildIndex } = await import("../index/build.js");
135
+ const { check } = await import("../check/check.js");
136
+ void check;
137
+ const data = buildIndex(root, cfg);
138
+ const r = await runCheck(root, cfg, data, strict);
139
+ return { code: r.findings.length ? 1 : 0, out: checkText(r, io.tty) };
140
+ },
141
+ }, io);
142
+ }
143
+ const { loadIndex } = await import("../index/build.js");
144
+ const need = (n, what) => {
145
+ if (p.positional.length < n)
146
+ throw new UsageExit(`${cmd} needs ${what}`);
147
+ };
148
+ if (cmd === "query")
149
+ need(1, "at least one term");
150
+ if (cmd === "related") {
151
+ need(1, "one ID");
152
+ if (p.positional.length > 1)
153
+ throw new UsageExit("related takes one ID");
154
+ }
155
+ if (cmd === "bundle")
156
+ need(1, "at least one ID");
157
+ if ((cmd === "status" || cmd === "check") && p.positional.length)
158
+ throw new UsageExit(`${cmd} takes no arguments`);
159
+ const { data, rebuilt } = loadIndex(root, cfg);
160
+ if (rebuilt)
161
+ io.err("index rebuilt\n");
162
+ const entries = data.entries;
163
+ if (cmd.includes(" ")) {
164
+ const { runWorkflow } = await import("./workflow.js");
165
+ return runWorkflow({ root, cfg, data, p, io, json });
166
+ }
167
+ const all = !!f.all;
168
+ switch (cmd) {
169
+ case "status": {
170
+ const { statusReport, statusLines } = await import("./status.js");
171
+ const r = statusReport(root, entries);
172
+ if (json)
173
+ print(r);
174
+ // REQ-CLI-008 the next: line is dim
175
+ else
176
+ io.out(statusLines(r).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
177
+ return 0;
178
+ }
179
+ case "query": {
180
+ const { query, queryLine } = await import("../graph/query.js");
181
+ const hits = query(entries, p.positional, {
182
+ kind: f.kind, change: f.change, all, limit: f.limit ?? 20,
183
+ });
184
+ if (json)
185
+ print(hits);
186
+ else
187
+ io.out(hits.map((e) => queryLine(e) + "\n").join(""));
188
+ return 0;
189
+ }
190
+ case "related": {
191
+ const { canon } = await import("../index/canonical.js");
192
+ const { related } = await import("../graph/related.js");
193
+ const id = p.positional[0];
194
+ const ids = canon(entries);
195
+ if (!ids.has(id) && !(all && entries.some((e) => e.id === id))) {
196
+ io.err(`unknown id ${id}\n`);
197
+ return 2;
198
+ }
199
+ const rows = [];
200
+ for (const [i, reason] of related(entries, id, f.depth ?? 1, all)) {
201
+ const copies = all ? entries.filter((e) => e.id === i) : ids.has(i) ? [ids.get(i)] : [];
202
+ // canonical copy first, the rest in index order
203
+ copies.sort((a, b) => Number(!a.canonical) - Number(!b.canonical));
204
+ for (const e of copies)
205
+ rows.push([e, reason]);
206
+ }
207
+ if (json)
208
+ print(rows.map(([e, reason]) => ({ ...e, reason })));
209
+ else
210
+ io.out(rows.map(([e, r]) => `${e.kind} ${e.id} ${e.status} ${e.file}:${e.line} ${r}\n`).join(""));
211
+ return 0;
212
+ }
213
+ case "bundle": {
214
+ const { canon } = await import("../index/canonical.js");
215
+ const { bundle } = await import("../graph/bundle.js");
216
+ const ids = canon(entries);
217
+ const unknown = p.positional.filter((i) => !ids.has(i));
218
+ if (unknown.length) {
219
+ io.err(`unknown id(s): ${unknown.join(", ")}\n`);
220
+ return 2;
221
+ }
222
+ const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
223
+ if (json)
224
+ print(b);
225
+ else
226
+ io.out(b.text);
227
+ return 0;
228
+ }
229
+ case "stats": {
230
+ if (p.positional.length)
231
+ throw new UsageExit("stats takes no arguments");
232
+ const { check } = await import("../check/check.js");
233
+ const { manualWarnings } = await import("../check/evidence.js");
234
+ const { listEvidence } = await import("../tasks/evidence.js");
235
+ const { computeStats, statsTable } = await import("../stats/stats.js");
236
+ const s = computeStats(root, entries, check(root, cfg, data, true), manualWarnings(root, entries, listEvidence(root)), f.change);
237
+ if (f.write) {
238
+ const { writeStats } = await import("../stats/write.js");
239
+ const rel = writeStats(root, s);
240
+ if (!json)
241
+ io.out(`wrote ${rel}\n`);
242
+ }
243
+ if (json)
244
+ print(s);
245
+ else
246
+ io.out(statsTable(s).map((l) => l + "\n").join(""));
247
+ return 0;
248
+ }
249
+ case "check": {
250
+ const r = await runCheck(root, cfg, data, !!f.strict);
251
+ if (json)
252
+ print({ baselined: r.baselined, count: r.findings.length, findings: r.findings, warnings: r.warnings });
253
+ else
254
+ io.out(checkText(r, io.tty));
255
+ return r.findings.length ? 1 : 0;
256
+ }
257
+ }
258
+ throw new UsageExit(`unknown command: ${cmd}`);
259
+ }
260
+ async function init(p, io) {
261
+ const { runInit } = await import("../init/init.js");
262
+ if (p.positional.length)
263
+ throw new UsageExit("init takes no arguments");
264
+ const root = resolve(io.cwd, p.flags.root ?? ".");
265
+ const all = !!p.flags.all;
266
+ const r = runInit(root, { schema: all || !!p.flags.schema, skills: all || !!p.flags.skills, force: !!p.flags.force });
267
+ if (p.flags.json)
268
+ io.out(JSON.stringify(r, null, 2) + "\n");
269
+ else if (r.nothing)
270
+ io.out("nothing to do\n" + paint("dim", "next: specctx status\n", io.tty));
271
+ else {
272
+ io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
273
+ io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
274
+ // REQ-CLI-007 init ends with the next step
275
+ io.out(paint("dim", "next: specctx status\n", io.tty));
276
+ }
277
+ return 0;
278
+ }
@@ -0,0 +1,3 @@
1
+ // REQ-CLI-002 a usage or missing input error exits 2.
2
+ export class UsageExit extends Error {
3
+ }