spec-ctx 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -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/commands.js +275 -0
  14. package/dist/cli/errors.js +3 -0
  15. package/dist/cli/gate.js +187 -0
  16. package/dist/cli/help.js +230 -0
  17. package/dist/cli/root.js +32 -0
  18. package/dist/cli/status.js +116 -0
  19. package/dist/cli/workflow.js +274 -0
  20. package/dist/cli.js +13 -0
  21. package/dist/config.js +119 -0
  22. package/dist/graph/bundle.js +94 -0
  23. package/dist/graph/query.js +31 -0
  24. package/dist/graph/related.js +76 -0
  25. package/dist/handoff/handoff.js +148 -0
  26. package/dist/ids/ids.js +115 -0
  27. package/dist/index/build.js +57 -0
  28. package/dist/index/canonical.js +44 -0
  29. package/dist/index/scan.js +357 -0
  30. package/dist/index/types.js +2 -0
  31. package/dist/init/init.js +141 -0
  32. package/dist/lint/cli.js +253 -0
  33. package/dist/lint/py.js +152 -0
  34. package/dist/lint/selftest.js +246 -0
  35. package/dist/lint/ste.js +395 -0
  36. package/dist/lint/tells.js +116 -0
  37. package/dist/main.js +74 -0
  38. package/dist/stats/stats.js +143 -0
  39. package/dist/stats/write.js +26 -0
  40. package/dist/tasks/evidence.js +102 -0
  41. package/dist/tasks/tasks.js +105 -0
  42. package/dist/util/glob.js +43 -0
  43. package/dist/util/hash.js +14 -0
  44. package/dist/util/json.js +16 -0
  45. package/dist/util/pystr.js +67 -0
  46. package/dist/util/time.js +11 -0
  47. package/dist/verify/parse.js +58 -0
  48. package/dist/verify/run.js +42 -0
  49. package/dist/version.js +2 -0
  50. package/package.json +50 -4
  51. package/schemas/spec-driven-ids/README.md +18 -0
  52. package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
  53. package/schemas/spec-driven-ids/schema.yaml +301 -0
  54. package/schemas/spec-driven-ids/templates/design.md +22 -0
  55. package/schemas/spec-driven-ids/templates/proposal.md +34 -0
  56. package/schemas/spec-driven-ids/templates/spec.md +15 -0
  57. package/schemas/spec-driven-ids/templates/tasks.md +10 -0
  58. package/skills/specctx-bootstrap/SKILL.md +27 -0
  59. package/skills/specctx-done/SKILL.md +25 -0
  60. package/skills/specctx-next/SKILL.md +23 -0
@@ -0,0 +1,230 @@
1
+ // REQ-CLI-001 REQ-CLI-002 REQ-CLI-006 help text from one table of command records.
2
+ // `specctx --help` renders the three groups. `specctx <cmd> --help` renders one page.
3
+ // Every page passes `specctx lint ste --baseline 0`. Command names stay in backticks, so the lint reads them as code.
4
+ import { COMMANDS, GROUPS } from "./args.js";
5
+ const OK = ["0", "Success."];
6
+ const USAGE = ["2", "A usage error."];
7
+ const INTERNAL = ["3", "An internal error."];
8
+ const PLAIN = [OK, USAGE, INTERNAL];
9
+ const GLOBAL = [
10
+ ["--root <path>", "Use this project root."],
11
+ ["--json", "Print one JSON document."],
12
+ ["--help", "Show this help."],
13
+ ];
14
+ const LINT_FLAGS = [
15
+ ["--baseline <n>", "Exit 0 when the hard findings are n or fewer. The default is 0."],
16
+ ["--disable <rules>", "Ignore these rules. Separate them with commas."],
17
+ ["--json", "Print the findings as JSON."],
18
+ ["--selftest", "Run the built in tests of the linter."],
19
+ ["--help", "Show this help."],
20
+ ];
21
+ const LINT_EXIT = [["0", "The hard findings are at the baseline or below."], ["1", "The hard findings are over the baseline."], USAGE];
22
+ export const TABLE = [
23
+ // Read
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: "query", group: "Read", args: "<term...>", summary: "Find the entries that contain all terms.",
27
+ flags: [
28
+ ["--kind <kind>", "Show only entries of this kind."],
29
+ ["--change <name>", "Show only entries of this change. The key or the full folder name works."],
30
+ ["--limit <n>", "Show n lines at most. The default is 20. Use 0 for no limit."],
31
+ ["--all", "Show all copies of an ID."],
32
+ ],
33
+ exit: PLAIN, example: "specctx query lockfile --kind req",
34
+ },
35
+ {
36
+ name: "related", group: "Read", args: "<ID>", summary: "Show the entries that link to an ID.",
37
+ flags: [["--depth <n>", "Follow links n steps. The default is 1."], ["--all", "Show all copies of an ID."]],
38
+ exit: [OK, ["2", "A usage error or an unknown ID."], INTERNAL], example: "specctx related REQ-IDX-001",
39
+ },
40
+ {
41
+ name: "bundle", group: "Read", args: "<ID...>", summary: "Print the spec text for the IDs and their links.",
42
+ flags: [
43
+ ["--budget <n>", "Stop at n characters. The default is 8000."],
44
+ ["--depth <n>", "Follow links n steps. The default is 1."],
45
+ ["--all", "Show all copies of an ID."],
46
+ ],
47
+ exit: [OK, ["2", "A usage error or an unknown ID."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
48
+ },
49
+ {
50
+ name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
51
+ flags: [["--strict", "Also find gaps in tests and evidence."]],
52
+ exit: [["0", "No finding."], ["1", "One finding or more."], USAGE, INTERNAL], example: "specctx check --strict",
53
+ },
54
+ {
55
+ name: "gate", group: "Read", args: "",
56
+ summary: "Run `check`, the markdown linters, `openspec validate`, and the length check. Stop at the first failure.",
57
+ flags: [
58
+ ["--strict", "Run `check` and `openspec validate` in strict mode."],
59
+ ["--markdown <globs>", "Lint these markdown globs. Separate them with spaces."],
60
+ ],
61
+ exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
62
+ },
63
+ {
64
+ name: "task next", group: "Read", args: "", summary: "Show the next open task.",
65
+ flags: [["--change <name>", "Look only in this change. The key or the full folder name works."]],
66
+ exit: [["0", "An open task."], ["1", "No open task."], USAGE, INTERNAL], example: "specctx task next --change 008",
67
+ },
68
+ {
69
+ name: "task verify", group: "Read", args: "<task>", summary: "Run the `Verify:` command of a task. Write nothing.",
70
+ flags: [], exit: [["0", "The command passed."], ["1", "The command failed."], USAGE, INTERNAL], example: "specctx task verify 008/1.1",
71
+ },
72
+ {
73
+ name: "stats", group: "Read", args: "", summary: "Show five metrics for each change and the gate runs.",
74
+ flags: [["--change <name>", "Show only this change."], ["--write", "Also write docs/metrics/<date>.md."]],
75
+ exit: PLAIN, example: "specctx stats --write",
76
+ },
77
+ {
78
+ name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
79
+ flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint tells --baseline 0 README.md",
80
+ },
81
+ {
82
+ name: "lint ste", group: "Read", args: "<file...>", summary: "Find the breaks of the structural STE rules in markdown files. Read stdin when no file is given.",
83
+ flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint ste --baseline 0 README.md",
84
+ },
85
+ // Write
86
+ { name: "build", group: "Write", args: "", summary: "Write the index to .ctx/index.json.", flags: [], exit: PLAIN, example: "specctx build" },
87
+ {
88
+ name: "id new", group: "Write", args: "<kind> <code>", summary: "Print the next free ID. The kind is REQ, SCN, ADR, or DEC.",
89
+ flags: [["--for <REQ-ID>", "Give the requirement of a new scenario."], ["--count <n>", "Print n IDs in sequence."]],
90
+ exit: [OK, ["2", "A usage error or an unknown code."], INTERNAL], example: "specctx id new REQ IDX",
91
+ },
92
+ {
93
+ name: "id retire", group: "Write", args: "<ID> <reason>", summary: "Add an ID to decisions/retired-ids.md.",
94
+ flags: [], exit: [OK, ["2", "A usage error, or the ID is still in use."], INTERNAL], example: "specctx id retire REQ-IDX-009 merged into REQ-IDX-001",
95
+ },
96
+ {
97
+ name: "task start", group: "Write", args: "<task>", summary: "Print the bundle of a task and set it as the current task.",
98
+ flags: [], exit: [OK, ["2", "A usage error, or the task is done or unknown."], INTERNAL], example: "specctx task start 008/1.1",
99
+ },
100
+ {
101
+ name: "task done", group: "Write", args: "<task>", summary: "Run the `Verify:` command. On success, write the evidence and set the checkbox.",
102
+ flags: [["--manual <note>", "Record a manual note. A task with no `Verify:` command needs this flag."]],
103
+ exit: [OK, ["1", "The `Verify:` command failed. The task stays open."], USAGE, INTERNAL], example: "specctx task done 008/1.1",
104
+ },
105
+ {
106
+ name: "task reopen", group: "Write", args: "<task> <why>", summary: "Clear the checkbox and keep the old evidence.",
107
+ flags: [], exit: PLAIN, example: "specctx task reopen 008/1.1 the spec changed",
108
+ },
109
+ { 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
+ {
111
+ name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
112
+ flags: [], exit: PLAIN, example: "specctx handoff ask \"Keep the old flag?\"",
113
+ },
114
+ {
115
+ name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
116
+ flags: [], exit: PLAIN, example: "specctx handoff resolve 1 \"yes, for one release\"",
117
+ },
118
+ // Setup
119
+ {
120
+ name: "init", group: "Setup", args: "", summary: "Write a default spec-ctx.toml file.",
121
+ flags: [
122
+ ["--schema", "Also install the spec-driven-ids schema and add its rules to openspec/config.yaml."],
123
+ ["--skills", "Also install the three skills in .claude/skills and .agents/skills."],
124
+ ["--all", "Do all three."],
125
+ ["--force", "Install over files that exist. An existing spec-ctx.toml stays."],
126
+ ],
127
+ exit: PLAIN, example: "specctx init --all",
128
+ },
129
+ {
130
+ name: "adopt", group: "Setup", args: "", summary: "Write IDs into the headers of the main specs and the active deltas. Undo on a new finding.",
131
+ flags: [["--dry-run", "Show the ID that each header without an ID gets. Write nothing."]],
132
+ 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
+ example: "specctx adopt --dry-run",
134
+ },
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
+ {
141
+ name: "baseline write", group: "Setup", args: "", summary: "Record the current debt in .ctx/baseline.json. `check` then hides it.",
142
+ flags: [], exit: PLAIN, example: "specctx baseline write",
143
+ },
144
+ { name: "baseline prune", group: "Setup", args: "", summary: "Remove the entries that no longer apply.", flags: [], exit: PLAIN, example: "specctx baseline prune" },
145
+ {
146
+ name: "help", group: "Setup", args: "<command>", summary: "Show the help page of one command.",
147
+ flags: [], exit: [OK, ["2", "An unknown command."]], example: "specctx help task start",
148
+ },
149
+ ];
150
+ const WIDTH = 29;
151
+ /** One label line: two spaces, the label in backticks, padded. A long label puts the text on the next line. */
152
+ function row(label, text) {
153
+ const cell = `\`${label}\``;
154
+ if (cell.length < WIDTH)
155
+ return ` ${cell.padEnd(WIDTH)}${text}`;
156
+ return ` ${cell}\n ${" ".repeat(WIDTH)}${text}`;
157
+ }
158
+ const usageOf = (c) => `${c.name}${c.args ? " " + c.args : ""}`;
159
+ function overview() {
160
+ const out = ["specctx: a context engine and task workflow for OpenSpec projects.", "", "Usage: specctx <command> [flags]", ""];
161
+ for (const g of ["Read", "Write", "Setup"]) {
162
+ out.push(`${g}:`);
163
+ for (const c of TABLE.filter((x) => x.group === g))
164
+ out.push(row(c.label ?? usageOf(c), c.summary));
165
+ out.push("");
166
+ }
167
+ out.push("Command flags:");
168
+ for (const c of TABLE) {
169
+ if (c.name.startsWith("lint "))
170
+ continue;
171
+ for (const [flag, text] of c.flags)
172
+ out.push(row(`${c.name} ${flag}`, text));
173
+ }
174
+ for (const [flag, text] of LINT_FLAGS.filter(([f]) => f !== "--help"))
175
+ out.push(row(`lint ${flag}`, text));
176
+ out.push("");
177
+ out.push("Global flags:");
178
+ for (const [flag, text] of GLOBAL)
179
+ out.push(row(flag, text));
180
+ out.push(row("--version", "Show the version."));
181
+ out.push("");
182
+ out.push("Exit codes: 0 for success, 1 for findings or a failed `Verify:` command, 2 for a usage error, 3 for an internal error.");
183
+ out.push("Run `specctx help <command>` or `specctx <command> --help` for the page of one command.");
184
+ return out.join("\n") + "\n";
185
+ }
186
+ export const HELP = overview();
187
+ /** REQ-CLI-006 the page of one command: Usage, Flags, Exit codes, Example. */
188
+ export function commandPage(c) {
189
+ const lint = c.name.startsWith("lint ");
190
+ const flags = lint ? c.flags : [...c.flags, ...GLOBAL];
191
+ const out = [
192
+ `specctx ${c.name}: ${c.summary}`,
193
+ "",
194
+ "Usage:",
195
+ ` \`specctx ${usageOf(c)}${flags.length ? " [flags]" : ""}\``,
196
+ "",
197
+ "Flags:",
198
+ ...flags.map(([f, t]) => row(f, t)),
199
+ "",
200
+ "Exit codes:",
201
+ ...c.exit.map(([code, t]) => ` \`${code}\` ${t}`),
202
+ "",
203
+ "Example:",
204
+ ` \`${c.example}\``,
205
+ ];
206
+ return out.join("\n") + "\n";
207
+ }
208
+ export function findHelp(name) {
209
+ return TABLE.find((c) => c.name === name);
210
+ }
211
+ /**
212
+ * The help for `specctx help <words>` or `specctx <words> --help`. A group name such as `task`
213
+ * prints the pages of its subcommands. Unknown words return null.
214
+ */
215
+ export function helpFor(name) {
216
+ const one = findHelp(name);
217
+ if (one)
218
+ return commandPage(one);
219
+ if (GROUPS.includes(name) || name === "lint") {
220
+ const subs = TABLE.filter((c) => c.name.startsWith(name + " "));
221
+ if (subs.length)
222
+ return subs.map(commandPage).join("\n");
223
+ }
224
+ return null;
225
+ }
226
+ /** REQ-CLI-002 REQ-CLI-006 the command list of the unknown command error. `help` and `lint` are in it. */
227
+ export function commandList() {
228
+ const names = [...COMMANDS, "lint", "help"];
229
+ return `commands: ${names.join(", ")}\n`;
230
+ }
@@ -0,0 +1,32 @@
1
+ // REQ-CLI-001 root discovery: --root, else the nearest parent with spec-ctx.toml or openspec/.
2
+ // REQ-CLI-007 a root without openspec/ is no OpenSpec project. The command stops before it writes.
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { UsageExit } from "./errors.js";
6
+ /** REQ-CLI-007 exit 2 with the fixed message when the root has no openspec/ folder. */
7
+ export function requireOpenSpec(root) {
8
+ const os = join(root, "openspec");
9
+ if (existsSync(os) && statSync(os).isDirectory())
10
+ return;
11
+ throw new UsageExit(`not an OpenSpec project (no openspec/ under ${root}). Run npx -y @fission-ai/openspec@latest init first.`);
12
+ }
13
+ export function findRoot(cwd, explicit) {
14
+ if (explicit !== undefined) {
15
+ const r = resolve(cwd, explicit);
16
+ if (!existsSync(r) || !statSync(r).isDirectory())
17
+ throw new UsageExit(`the root ${explicit} is not a directory`);
18
+ return r;
19
+ }
20
+ let dir = resolve(cwd);
21
+ for (;;) {
22
+ if (existsSync(join(dir, "spec-ctx.toml")))
23
+ return dir;
24
+ const os = join(dir, "openspec");
25
+ if (existsSync(os) && statSync(os).isDirectory())
26
+ return dir;
27
+ const up = dirname(dir);
28
+ if (up === dir)
29
+ return resolve(cwd);
30
+ dir = up;
31
+ }
32
+ }
@@ -0,0 +1,116 @@
1
+ // REQ-CLI-001 status reads the index, the handoff file, and the .openspec.yaml of each archive folder.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { changeOf } from "../index/scan.js";
5
+ import { cmpStr, pyStrip, splitLines } from "../util/pystr.js";
6
+ export const HANDOFF_PATH = ".ctx/handoff.md";
7
+ /** First line of each open_questions item in the handoff file. */
8
+ export function openQuestions(root) {
9
+ const p = join(root, HANDOFF_PATH);
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;
27
+ }
28
+ /** REQ-HND-003 the current task from the handoff file. */
29
+ function handoffCurrent(root) {
30
+ const p = join(root, HANDOFF_PATH);
31
+ if (!existsSync(p))
32
+ return null;
33
+ for (const line of splitLines(readFileSync(p).toString("utf8"))) {
34
+ const m = /^current: (.*)$/s.exec(line);
35
+ if (m)
36
+ return pyStrip(m[1]);
37
+ }
38
+ return null;
39
+ }
40
+ /**
41
+ * The `archived` value of an archive folder. OpenSpec does not write it today,
42
+ * so the date at the start of the folder name is the fallback.
43
+ */
44
+ export function archivedAt(root, folder) {
45
+ const p = join(root, "openspec", "changes", "archive", folder, ".openspec.yaml");
46
+ if (existsSync(p)) {
47
+ const m = /^archived:[ \t]*["']?([^"'\s#]+)/m.exec(readFileSync(p, "utf8"));
48
+ if (m)
49
+ return m[1];
50
+ }
51
+ return /^\d{4}-\d{2}-\d{2}/.exec(folder)?.[0] ?? "";
52
+ }
53
+ export function statusReport(root, entries) {
54
+ const changes = new Map();
55
+ const archived = new Set();
56
+ for (const e of entries) {
57
+ const m = /^openspec\/changes\/archive\/([^/]+)\//.exec(e.file);
58
+ if (m) {
59
+ archived.add(m[1]);
60
+ continue;
61
+ }
62
+ if (e.kind === "task" && (e.status === "done" || e.status === "open") && e.change !== "main") {
63
+ let c = changes.get(e.change);
64
+ if (!c)
65
+ changes.set(e.change, (c = { done: 0, open: 0 }));
66
+ c[e.status]++;
67
+ }
68
+ }
69
+ const covered = new Set(entries.filter((e) => e.kind === "test").flatMap((e) => e.refs));
70
+ const uncovered = entries.filter((e) => e.kind === "req" && e.change === "main" && e.status === "active" && !covered.has(e.id));
71
+ // REQ-CLI-001 order by the archived time in .openspec.yaml, then by folder name
72
+ const last = [...archived].map((n) => ({ n, at: archivedAt(root, n) })).sort((a, b) => cmpStr(a.at, b.at) || cmpStr(a.n, b.n)).pop()?.n;
73
+ const handoff = existsSync(join(root, HANDOFF_PATH));
74
+ return {
75
+ changes: [...changes.keys()].sort(cmpStr).map((c) => ({ change: c, ...changes.get(c) })),
76
+ last_archived: last ? changeOf(`openspec/changes/archive/${last}/`) : null,
77
+ uncovered: uncovered.length,
78
+ handoff,
79
+ current: handoff ? handoffCurrent(root) : null,
80
+ open_questions: handoff ? openQuestions(root) : [],
81
+ };
82
+ }
83
+ export function statusLines(r) {
84
+ const out = r.changes.map((c) => `${c.change} done ${c.done} open ${c.open}`);
85
+ if (!r.changes.length) {
86
+ out.push("no active change");
87
+ if (r.last_archived !== null)
88
+ out.push(`last archived: ${r.last_archived}`);
89
+ }
90
+ out.push(`uncovered active REQs in main: ${r.uncovered}`);
91
+ if (r.handoff) {
92
+ out.push(`handoff: ${HANDOFF_PATH} (read it first)`);
93
+ if (r.current !== null)
94
+ out.push(`current: ${r.current}`);
95
+ out.push(`open_questions: ${r.open_questions.length}`);
96
+ // with no active change the questions are the agenda, so status prints them
97
+ if (!r.changes.length)
98
+ for (const q of r.open_questions)
99
+ out.push(`- ${q}`);
100
+ }
101
+ const next = nextStep(r);
102
+ if (next)
103
+ out.push(`next: ${next}`);
104
+ return out;
105
+ }
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
+ }
@@ -0,0 +1,274 @@
1
+ // REQ-IDS-001 REQ-TASK-001 REQ-TASK-002 REQ-TASK-003 REQ-TASK-005 REQ-VER-002 REQ-HND-001 write commands.
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { dumps } from "../util/json.js";
5
+ import { UsageExit } from "./errors.js";
6
+ const arg = (c, n, what) => {
7
+ const v = c.p.positional[n];
8
+ if (v === undefined)
9
+ throw new UsageExit(`${c.p.command} needs ${what}`);
10
+ return v;
11
+ };
12
+ const noMore = (c, n) => {
13
+ if (c.p.positional.length > n)
14
+ throw new UsageExit(`${c.p.command} takes ${n} argument(s)`);
15
+ };
16
+ const print = (c, v) => c.io.out(dumps(v) + "\n");
17
+ export async function runWorkflow(c) {
18
+ const entries = c.data.entries;
19
+ const f = c.p.flags;
20
+ switch (c.p.command) {
21
+ case "id new": {
22
+ const { newIds } = await import("../ids/ids.js");
23
+ const kind = arg(c, 0, "a kind: REQ, SCN, ADR, or DEC");
24
+ noMore(c, 2);
25
+ const ids = newIds(c.root, c.cfg, entries, {
26
+ kind, cap: c.p.positional[1], forReq: f.for, count: f.count ?? 1,
27
+ });
28
+ if (c.json)
29
+ print(c, ids);
30
+ else
31
+ c.io.out(ids.map((i) => i + "\n").join(""));
32
+ return 0;
33
+ }
34
+ case "id retire": {
35
+ const { retire } = await import("../ids/ids.js");
36
+ const id = arg(c, 0, "an ID");
37
+ const reason = c.p.positional.slice(1).join(" ");
38
+ const row = retire(c.root, entries, id, reason);
39
+ if (c.json)
40
+ print(c, { retired: id, row });
41
+ else
42
+ c.io.out(`retired ${id} in decisions/retired-ids.md\n`);
43
+ return 0;
44
+ }
45
+ case "task next": {
46
+ noMore(c, 0);
47
+ const { nextTask } = await import("../tasks/tasks.js");
48
+ const t = nextTask(c.root, entries, f.change);
49
+ if (!t) {
50
+ if (c.json)
51
+ print(c, null);
52
+ else {
53
+ // REQ-TASK-001 name the next step. The exit code stays 1.
54
+ const { activeChanges } = await import("../tasks/tasks.js");
55
+ const { matchesChange } = await import("../index/scan.js");
56
+ const want = f.change;
57
+ const ready = activeChanges(entries).filter((x) => !want || matchesChange(x, want));
58
+ c.io.out(ready.length
59
+ ? `no open task. Every task in the active changes is done. Next: archive ${ready.join(", ")}.\n`
60
+ : "no open task. No active change has a task.\n");
61
+ }
62
+ return 1;
63
+ }
64
+ const bundleCommand = `specctx bundle ${t.reqs.join(" ")} --budget ${c.cfg.bundle.budget}`;
65
+ if (c.json) {
66
+ print(c, { bundle_command: bundleCommand, change: t.change, id: t.id, reqs: t.reqs, text: t.text, verify: t.verify.command });
67
+ }
68
+ else {
69
+ c.io.out(`${t.id} ${t.text}\nreqs: ${t.reqs.join(", ") || "none"}\nverify: ${t.verify.command ?? "manual"}\n`);
70
+ }
71
+ return 0;
72
+ }
73
+ case "task start": {
74
+ const { findTask } = await import("../tasks/tasks.js");
75
+ const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
76
+ noMore(c, 1);
77
+ if (t.done)
78
+ throw new UsageExit(`task ${t.id} is done`);
79
+ if (t.archived)
80
+ throw new UsageExit(`task ${t.id} is in an archived change`);
81
+ const { canon } = await import("../index/canonical.js");
82
+ const { bundle } = await import("../graph/bundle.js");
83
+ const ids = canon(entries);
84
+ const known = t.reqs.filter((r) => ids.has(r));
85
+ for (const r of t.reqs.filter((x) => !ids.has(x)))
86
+ c.io.err(`warning: task ${t.id} cites ${r}, which the index does not hold\n`);
87
+ const b = bundle(c.root, entries, known, c.cfg.bundle.budget, c.cfg.bundle.depth);
88
+ const { isoNow } = await import("../util/time.js");
89
+ const { CURRENT_PATH } = await import("../handoff/handoff.js");
90
+ mkdirSync(join(c.root, ".ctx"), { recursive: true });
91
+ const { cpLen } = await import("../util/pystr.js");
92
+ // REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
93
+ writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id }) + "\n");
94
+ if (c.json)
95
+ print(c, { bundle: b, task: t.id });
96
+ else
97
+ c.io.out(b.text);
98
+ return 0;
99
+ }
100
+ case "task verify":
101
+ case "task done": {
102
+ const { findTask, setBox, hasEvidence } = await import("../tasks/tasks.js");
103
+ const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
104
+ noMore(c, 1);
105
+ const dry = c.p.command === "task verify";
106
+ const manual = f.manual;
107
+ if (!dry && manual !== undefined) {
108
+ if (!manual.trim())
109
+ throw new UsageExit("--manual needs a note");
110
+ if (t.done && hasEvidence(c.root, t))
111
+ throw new UsageExit(`task ${t.id} is done`);
112
+ // a manual note records evidence without a run. On a task done by hand it backfills the evidence.
113
+ const { writeEvidence } = await import("../tasks/evidence.js");
114
+ const rel = writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", manual.trim());
115
+ if (!t.done)
116
+ setBox(c.root, t, true);
117
+ clearCurrent(c.root, t.id);
118
+ if (c.json)
119
+ print(c, { evidence: rel, kind: "manual", task: t.id });
120
+ else
121
+ c.io.out(`done ${t.id} (manual). Evidence: ${rel}\n`);
122
+ return 0;
123
+ }
124
+ if (!dry && t.done)
125
+ throw new UsageExit(`task ${t.id} is done`);
126
+ if (t.verify.command === null) {
127
+ throw new UsageExit(`task ${t.id} has no Verify command. ${dry ? "Nothing to run" : 'A --manual "<note>" is required'}`);
128
+ }
129
+ const { runVerify, tail } = await import("../verify/run.js");
130
+ const { resolve: resolvePath, relative, isAbsolute } = await import("node:path");
131
+ let dir = c.root;
132
+ if (t.verify.cwd !== undefined) {
133
+ // REQ-VER-004 the cwd prefix must stay inside the project root
134
+ dir = resolvePath(c.root, t.verify.cwd);
135
+ const rel = relative(c.root, dir);
136
+ if (rel.startsWith("..") || isAbsolute(rel))
137
+ throw new UsageExit(`the Verify cwd ${t.verify.cwd} is outside the project root`);
138
+ const { existsSync: exists } = await import("node:fs");
139
+ if (!exists(dir))
140
+ throw new UsageExit(`the Verify cwd ${t.verify.cwd} does not exist`);
141
+ }
142
+ const r = await runVerify(t.verify.command, dir, c.cfg.verify.timeout_s);
143
+ const outTail = tail(r.output);
144
+ if (dry) {
145
+ if (c.json)
146
+ print(c, { command: t.verify.command, exit: r.exit, tail: outTail, timed_out: r.timedOut });
147
+ else
148
+ c.io.out(`${outTail ? outTail + "\n" : ""}exit ${r.timedOut ? "timeout" : r.exit}\n`);
149
+ return r.exit === 0 ? 0 : 1;
150
+ }
151
+ if (r.timedOut || r.exit !== 0) {
152
+ const msg = r.timedOut ? `verify timed out after ${c.cfg.verify.timeout_s}s` : `verify failed with exit ${r.exit}`;
153
+ if (c.json)
154
+ print(c, { command: t.verify.command, error: msg, exit: r.exit, tail: outTail, task: t.id });
155
+ else
156
+ c.io.out(outTail ? outTail + "\n" : "");
157
+ c.io.err(`specctx: ${msg}. Task ${t.id} stays open.\n`);
158
+ return 1;
159
+ }
160
+ const { sha256 } = await import("../util/hash.js");
161
+ const { writeEvidence } = await import("../tasks/evidence.js");
162
+ // the Verify command can change tagged files, so the digests come from the tree after the run
163
+ const { loadIndex } = await import("../index/build.js");
164
+ const after = loadIndex(c.root, c.cfg).data.entries;
165
+ const rel = writeEvidence(c.root, t, after, {
166
+ command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: tail(r.stderr.toString("utf8")),
167
+ }, "command", undefined, t.verify.cwd);
168
+ setBox(c.root, t, true);
169
+ clearCurrent(c.root, t.id);
170
+ if (c.json)
171
+ print(c, { evidence: rel, kind: "command", task: t.id });
172
+ else
173
+ c.io.out(`done ${t.id}. Evidence: ${rel}\n`);
174
+ return 0;
175
+ }
176
+ case "task backfill": {
177
+ // REQ-TASK-006 manual evidence for every ticked task that has none, through the task done --manual path
178
+ noMore(c, 0);
179
+ const note = f.manual?.trim();
180
+ if (!note)
181
+ throw new UsageExit('task backfill needs --manual "<note>"');
182
+ const { tasksOf, toTask, hasEvidence } = await import("../tasks/tasks.js");
183
+ const { matchesChange } = await import("../index/scan.js");
184
+ const { writeEvidence } = await import("../tasks/evidence.js");
185
+ const want = f.change;
186
+ const counts = new Map();
187
+ for (const e of tasksOf(entries)) {
188
+ if (e.status !== "done")
189
+ continue;
190
+ if (want && !matchesChange(e.change, want))
191
+ continue;
192
+ if (hasEvidence(c.root, e))
193
+ continue;
194
+ const t = toTask(c.root, e);
195
+ writeEvidence(c.root, t, entries, { command: t.verify.command, exit: null, stdout_sha256: null, stderr_tail: "" }, "manual", note);
196
+ counts.set(e.change, (counts.get(e.change) ?? 0) + 1);
197
+ }
198
+ const { cmpStr } = await import("../util/pystr.js");
199
+ const rows = [...counts.keys()].sort(cmpStr).map((k) => ({ change: k, count: counts.get(k) }));
200
+ const total = rows.reduce((n, r) => n + r.count, 0);
201
+ if (c.json)
202
+ print(c, { changes: rows, total });
203
+ else
204
+ c.io.out(rows.map((r) => `${r.change}: ${r.count}\n`).join("") + `backfilled ${total} task(s)\n`);
205
+ return 0;
206
+ }
207
+ case "task reopen": {
208
+ const { findTask, setBox } = await import("../tasks/tasks.js");
209
+ const t = findTask(c.root, entries, arg(c, 0, "a task ID"));
210
+ const reason = c.p.positional.slice(1).join(" ").trim();
211
+ if (!reason)
212
+ throw new UsageExit("task reopen needs a reason");
213
+ if (!t.done)
214
+ throw new UsageExit(`task ${t.id} is open`);
215
+ if (t.archived)
216
+ throw new UsageExit(`task ${t.id} is in an archived change`);
217
+ const { retireEvidence } = await import("../tasks/evidence.js");
218
+ const { addReopened } = await import("../handoff/handoff.js");
219
+ setBox(c.root, t, false);
220
+ const moved = retireEvidence(c.root, t, reason);
221
+ addReopened(c.root, entries, t.id, reason);
222
+ if (c.json)
223
+ print(c, { evidence: moved, task: t.id });
224
+ else
225
+ c.io.out(`reopened ${t.id}.${moved ? ` Old evidence: ${moved}` : ""}\n`);
226
+ return 0;
227
+ }
228
+ case "handoff write": {
229
+ noMore(c, 0);
230
+ const { writeHandoff, HANDOFF_PATH } = await import("../handoff/handoff.js");
231
+ const h = writeHandoff(c.root, entries);
232
+ if (c.json)
233
+ print(c, h);
234
+ else
235
+ c.io.out(`wrote ${HANDOFF_PATH}\n`);
236
+ return 0;
237
+ }
238
+ case "handoff ask": {
239
+ const { ask } = await import("../handoff/handoff.js");
240
+ const h = ask(c.root, entries, c.p.positional.join(" "));
241
+ if (c.json)
242
+ print(c, { open_questions: h.open_questions });
243
+ else
244
+ c.io.out(`open question ${h.open_questions.length} added\n`);
245
+ return 0;
246
+ }
247
+ case "handoff resolve": {
248
+ const { resolve } = await import("../handoff/handoff.js");
249
+ const n = arg(c, 0, "a question number");
250
+ if (!/^\d+$/.test(n))
251
+ throw new UsageExit("handoff resolve needs a question number");
252
+ const h = resolve(c.root, entries, Number(n), c.p.positional.slice(1).join(" "));
253
+ if (c.json)
254
+ print(c, { open_questions: h.open_questions, resolved: h.resolved });
255
+ else
256
+ c.io.out(`resolved question ${n}\n`);
257
+ return 0;
258
+ }
259
+ }
260
+ throw new UsageExit(`unknown command: ${c.p.command}`);
261
+ }
262
+ function clearCurrent(root, task) {
263
+ const p = join(root, ".ctx/current.json");
264
+ if (!existsSync(p))
265
+ return;
266
+ try {
267
+ const cur = JSON.parse(readFileSync(p, "utf8"));
268
+ if (cur.task === task)
269
+ rmSync(p);
270
+ }
271
+ catch {
272
+ // a damaged current.json stays for the user to read
273
+ }
274
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // REQ-CLI-001 bin entry. tests call main() from src/main.ts in process.
3
+ import { main } from "./main.js";
4
+ process.stdout.on("error", (e) => {
5
+ if (e.code === "EPIPE")
6
+ process.exit(0);
7
+ });
8
+ const code = await main(process.argv.slice(2), {
9
+ cwd: process.cwd(),
10
+ out: (s) => process.stdout.write(s),
11
+ err: (s) => process.stderr.write(s),
12
+ });
13
+ process.exitCode = code;