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,148 @@
1
+ // REQ-HND-001 REQ-HND-002 the generated session handoff in .ctx/handoff.md.
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { UsageExit } from "../cli/errors.js";
5
+ import { changeKey } from "../index/scan.js";
6
+ import { listEvidence } from "../tasks/evidence.js";
7
+ import { activeChanges, nextTask } from "../tasks/tasks.js";
8
+ import { cmpStr, splitLines } from "../util/pystr.js";
9
+ import { isoNow, today } from "../util/time.js";
10
+ export const HANDOFF_PATH = ".ctx/handoff.md";
11
+ export const CURRENT_PATH = ".ctx/current.json";
12
+ const SCALARS = ["change", "current", "base", "as_of"];
13
+ const LISTS = ["done", "reopened", "open_questions", "resolved"];
14
+ function empty() {
15
+ return { change: "none", current: "none", base: "none", as_of: "none", done: [], reopened: [], open_questions: [], resolved: [], next: "", evidence: "" };
16
+ }
17
+ /** Parse a handoff file. A list item keeps its indented continuation lines. */
18
+ export function parseHandoff(text) {
19
+ const h = empty();
20
+ let list = null;
21
+ for (const line of splitLines(text)) {
22
+ if (list && line.startsWith("- ")) {
23
+ list.push(line.slice(2));
24
+ continue;
25
+ }
26
+ if (list && /^[ \t]/.test(line) && line.trim() && list.length) {
27
+ list[list.length - 1] += "\n" + line;
28
+ continue;
29
+ }
30
+ list = null;
31
+ const m = /^([a-z_]+):(?: (.*))?$/.exec(line);
32
+ if (!m)
33
+ continue;
34
+ const key = m[1];
35
+ if (LISTS.includes(key))
36
+ list = h[key];
37
+ else if (SCALARS.includes(key) || key === "next" || key === "evidence") {
38
+ h[key] = (m[2] ?? "").trim();
39
+ }
40
+ }
41
+ return h;
42
+ }
43
+ export function renderHandoff(h) {
44
+ const out = ["# Handoff"];
45
+ for (const k of SCALARS)
46
+ out.push(`${k}: ${h[k]}`);
47
+ for (const k of LISTS) {
48
+ out.push(`${k}:`);
49
+ for (const item of h[k])
50
+ out.push(`- ${item}`);
51
+ }
52
+ out.push(`next: ${h.next}`, `evidence: ${h.evidence}`);
53
+ return out.join("\n") + "\n";
54
+ }
55
+ export function readHandoff(root) {
56
+ const p = join(root, HANDOFF_PATH);
57
+ return existsSync(p) ? parseHandoff(readFileSync(p).toString("utf8")) : null;
58
+ }
59
+ function save(root, h) {
60
+ mkdirSync(join(root, ".ctx"), { recursive: true });
61
+ writeFileSync(join(root, HANDOFF_PATH), renderHandoff(h));
62
+ }
63
+ export function currentTask(root) {
64
+ try {
65
+ const c = JSON.parse(readFileSync(join(root, CURRENT_PATH), "utf8"));
66
+ return c.task ?? "none";
67
+ }
68
+ catch {
69
+ return "none";
70
+ }
71
+ }
72
+ function evidenceRows(root, active) {
73
+ // REQ-IDX-005 evidence files name the change by its key
74
+ const nums = new Set(active.map(changeKey));
75
+ const rows = [];
76
+ for (const rel of listEvidence(root)) {
77
+ try {
78
+ const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
79
+ if (typeof ev.task === "string" && nums.has(ev.task.split("/")[0]))
80
+ rows.push({ task: ev.task, at: String(ev.at), kind: String(ev.kind) });
81
+ }
82
+ catch {
83
+ // a damaged evidence file is a check finding, not a handoff error
84
+ }
85
+ }
86
+ return rows;
87
+ }
88
+ const AT = / \(at (\S+)\)$/;
89
+ /** Generate the handoff. Two runs with no change in between write the same bytes. */
90
+ export function writeHandoff(root, entries) {
91
+ const prev = readHandoff(root);
92
+ const active = activeChanges(entries);
93
+ const next = nextTask(root, entries);
94
+ const rows = evidenceRows(root, active);
95
+ const asOf = rows.length ? rows.map((r) => r.at).sort(cmpStr).pop() : "none";
96
+ // the done window starts where the previous handoff ended, unless no new evidence came since
97
+ let base = "none";
98
+ if (prev)
99
+ base = prev.as_of === asOf ? prev.base : prev.as_of;
100
+ const after = (at) => base === "none" || cmpStr(at, base) > 0;
101
+ const h = {
102
+ change: next ? next.change : active[0] ?? "none",
103
+ current: currentTask(root),
104
+ base,
105
+ as_of: asOf,
106
+ done: rows.filter((r) => after(r.at)).map((r) => r.task).sort(cmpStr),
107
+ reopened: (prev?.reopened ?? []).filter((item) => {
108
+ const m = AT.exec(item.split("\n")[0]);
109
+ return !m || after(m[1]);
110
+ }),
111
+ open_questions: prev?.open_questions ?? [],
112
+ resolved: prev?.resolved ?? [],
113
+ next: next ? `${next.id} ${next.text}` : "no open task",
114
+ evidence: `${rows.length} task(s), ${rows.filter((r) => r.kind === "command").length} command, ${rows.filter((r) => r.kind === "manual").length} manual`,
115
+ };
116
+ save(root, h);
117
+ return h;
118
+ }
119
+ function ensure(root, entries) {
120
+ return readHandoff(root) ?? writeHandoff(root, entries);
121
+ }
122
+ export function ask(root, entries, text) {
123
+ const q = text.replace(/\s*\n\s*/g, " ").trim();
124
+ if (!q)
125
+ throw new UsageExit("handoff ask needs a question");
126
+ const h = ensure(root, entries);
127
+ h.open_questions.push(`${q} (task ${currentTask(root)}, ${today()})`);
128
+ save(root, h);
129
+ return h;
130
+ }
131
+ export function resolve(root, entries, n, answer) {
132
+ const h = ensure(root, entries);
133
+ if (!Number.isInteger(n) || n < 1 || n > h.open_questions.length) {
134
+ throw new UsageExit(`no open question ${n}. The handoff has ${h.open_questions.length}`);
135
+ }
136
+ const a = answer.replace(/\s*\n\s*/g, " ").trim();
137
+ if (!a)
138
+ throw new UsageExit("handoff resolve needs an answer");
139
+ const [item] = h.open_questions.splice(n - 1, 1);
140
+ h.resolved.push(`${item.split("\n")[0]} answer: ${a}`);
141
+ save(root, h);
142
+ return h;
143
+ }
144
+ export function addReopened(root, entries, task, reason) {
145
+ const h = ensure(root, entries);
146
+ h.reopened.push(`${task} ${reason.replace(/\s*\n\s*/g, " ").trim()} (at ${isoNow()})`);
147
+ save(root, h);
148
+ }
@@ -0,0 +1,115 @@
1
+ // REQ-IDS-001 REQ-IDS-002 REQ-IDS-003 ID allocation and retirement.
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { UsageExit } from "../cli/errors.js";
5
+ import { canon } from "../index/canonical.js";
6
+ import { changeKey, resolveChange } from "../index/scan.js";
7
+ import { today } from "../util/time.js";
8
+ export const RETIRED_FILE = "decisions/retired-ids.md";
9
+ const ANY_ID = /\b(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})\b/g;
10
+ export function retiredAll(root) {
11
+ const p = join(root, RETIRED_FILE);
12
+ if (!existsSync(p))
13
+ return new Set();
14
+ // the retired ID is the first ID of a line. A reason may name a live replacement ID.
15
+ const out = new Set();
16
+ for (const line of readFileSync(p, "utf8").split("\n")) {
17
+ const m = new RegExp(ANY_ID.source).exec(line);
18
+ if (m)
19
+ out.add(m[1]);
20
+ }
21
+ return out;
22
+ }
23
+ const pad = (n, w = 3) => String(n).padStart(w, "0");
24
+ /** Next unused IDs. Next is one more than the highest number among all index entries and retired IDs. */
25
+ export function newIds(root, cfg, entries, o) {
26
+ const kind = o.kind.toUpperCase();
27
+ if (!Number.isInteger(o.count) || o.count < 1)
28
+ throw new UsageExit("--count needs an integer of 1 or more");
29
+ const known = [...entries.map((e) => e.id), ...retiredAll(root)];
30
+ const next = (re) => {
31
+ let max = 0;
32
+ for (const id of known) {
33
+ const m = re.exec(id);
34
+ if (m)
35
+ max = Math.max(max, Number(m[1]));
36
+ }
37
+ return max + 1;
38
+ };
39
+ const checkCap = (cap) => {
40
+ if (!cap)
41
+ throw new UsageExit(`id new ${kind} needs a capability code`);
42
+ if (!/^[A-Z]{2,6}$/.test(cap))
43
+ throw new UsageExit(`the capability code ${cap} must be 2 to 6 upper case letters`);
44
+ // REQ-IDS-001 an empty caps list accepts any code. A non-empty list must hold the code.
45
+ if (cfg.ids.caps.length && !cfg.ids.caps.includes(cap)) {
46
+ throw new UsageExit(`unknown-cap ${cap}. Configured codes: ${cfg.ids.caps.join(", ")}. Add "${cap}" to caps under [ids] in spec-ctx.toml.`);
47
+ }
48
+ return cap;
49
+ };
50
+ const seq = (start, fmt) => Array.from({ length: o.count }, (_, i) => fmt(start + i));
51
+ switch (kind) {
52
+ case "REQ": {
53
+ const cap = checkCap(o.cap);
54
+ const start = next(new RegExp(`^REQ-${cap}-(\\d{3})$`));
55
+ if (start + o.count - 1 > 999)
56
+ throw new UsageExit(`no free REQ number for ${cap}`);
57
+ return seq(start, (n) => `REQ-${cap}-${pad(n)}`);
58
+ }
59
+ case "SCN": {
60
+ const cap = checkCap(o.cap);
61
+ if (!o.forReq)
62
+ throw new UsageExit("id new SCN needs --for <REQ-ID>");
63
+ const m = /^REQ-([A-Z]{2,6})-(\d{3})$/.exec(o.forReq);
64
+ if (!m || m[1] !== cap)
65
+ throw new UsageExit(`--for ${o.forReq} is not a REQ ID of ${cap}`);
66
+ const start = next(new RegExp(`^SCN-${cap}-${m[2]}-(\\d+)$`));
67
+ return seq(start, (n) => `SCN-${cap}-${m[2]}-${n}`);
68
+ }
69
+ case "ADR": {
70
+ const start = next(/^ADR-(\d{3})$/);
71
+ if (start + o.count - 1 > 999)
72
+ throw new UsageExit("no free ADR number");
73
+ return seq(start, (n) => `ADR-${pad(n)}`);
74
+ }
75
+ case "DEC": {
76
+ // REQ-IDX-005 a design decision ID is <change key>/D<n>. The CAP argument names the change by key or folder.
77
+ if (!o.cap)
78
+ throw new UsageExit("id new DEC needs a change name or key");
79
+ const folder = resolveChange(entries, o.cap);
80
+ if (folder === undefined)
81
+ throw new UsageExit(`no change with the key ${changeKey(o.cap)}`);
82
+ const num = changeKey(folder);
83
+ const start = next(new RegExp(`^${num.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/D(\\d+)$`));
84
+ return seq(start, (n) => `${num}/D${n}`);
85
+ }
86
+ }
87
+ throw new UsageExit(`id new needs REQ, SCN, ADR, or DEC, not ${o.kind}`);
88
+ }
89
+ /** Append a row to decisions/retired-ids.md. A canonical ID is still in use and stays. */
90
+ export function retire(root, entries, id, reason) {
91
+ if (!/^(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})$/.test(id))
92
+ throw new UsageExit(`${id} is not a REQ, SCN, or ADR ID`);
93
+ if (!reason.trim())
94
+ throw new UsageExit("id retire needs a reason");
95
+ // an archived copy is history. Only a copy in openspec/specs/ or an active change keeps the ID in use.
96
+ const live = canon(entries).get(id);
97
+ if (live && live.status !== "archived")
98
+ throw new UsageExit(`${id} is still in use at ${live.file}:${live.line}`);
99
+ if (retiredAll(root).has(id))
100
+ throw new UsageExit(`${id} is already retired`);
101
+ const p = join(root, RETIRED_FILE);
102
+ let text = existsSync(p) ? readFileSync(p, "utf8") : "";
103
+ if (!/^\| ID \| Date \| Reason \|$/m.test(text)) {
104
+ if (!text.trim())
105
+ text = "# Retired IDs\n\nIDs that were removed. Never reuse them.\n";
106
+ text = text.replace(/\n*$/, "\n\n") + "| ID | Date | Reason |\n| --- | --- | --- |\n";
107
+ }
108
+ else if (!text.endsWith("\n"))
109
+ text += "\n";
110
+ const row = `| ${id} | ${today()} | ${reason.trim().replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ")} |`;
111
+ mkdirSync(join(root, "decisions"), { recursive: true });
112
+ writeFileSync(p, text + row + "\n");
113
+ rmSync(join(root, ".ctx/index.json"), { force: true });
114
+ return row;
115
+ }
@@ -0,0 +1,57 @@
1
+ // REQ-IDX-001 REQ-IDX-002 REQ-IDX-004 build, write, and load .ctx/index.json.
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { dumps } from "../util/json.js";
5
+ import { cmpStr, cmpTuple } from "../util/pystr.js";
6
+ import { markCanonical } from "./canonical.js";
7
+ import { scanMarkdown, scannedOf, scanner, scanSource, walk } from "./scan.js";
8
+ export const INDEX_PATH = ".ctx/index.json";
9
+ export function buildIndex(root, cfg, files, sc) {
10
+ const s = sc ?? scanner(root, cfg);
11
+ const list = files ?? walk(s);
12
+ const entries = [];
13
+ const problems = [];
14
+ const scnNamed = new Set();
15
+ for (const f of list) {
16
+ if (f.kind === "md")
17
+ scanMarkdown(s, f.path, entries, problems);
18
+ else
19
+ scanSource(s, f.path, entries, scnNamed);
20
+ }
21
+ markCanonical(entries);
22
+ entries.sort((a, b) => cmpTuple([a.id, a.file, a.line], [b.id, b.file, b.line]));
23
+ const uniq = new Map();
24
+ for (const p of problems)
25
+ uniq.set(JSON.stringify(p), p);
26
+ const probs = [...uniq.values()].sort((a, b) => cmpTuple(a, b));
27
+ const data = {
28
+ entries,
29
+ problems: probs,
30
+ scanned: scannedOf(root, list),
31
+ scn_named: [...scnNamed].sort(cmpStr),
32
+ };
33
+ mkdirSync(join(root, ".ctx"), { recursive: true });
34
+ writeFileSync(join(root, INDEX_PATH), dumps(data) + "\n");
35
+ return data;
36
+ }
37
+ /** REQ-IDX-004 compare the stored scan record with a fresh stat walk. Rebuild on a mismatch. */
38
+ export function loadIndex(root, cfg) {
39
+ const sc = scanner(root, cfg);
40
+ const files = walk(sc);
41
+ const p = join(root, INDEX_PATH);
42
+ let data = null;
43
+ if (existsSync(p)) {
44
+ try {
45
+ data = JSON.parse(readFileSync(p, "utf8"));
46
+ }
47
+ catch {
48
+ data = null;
49
+ }
50
+ }
51
+ const fresh = scannedOf(root, files);
52
+ if (data === null || !data.scanned || data.scanned.count !== fresh.count
53
+ || data.scanned.newest_mtime_ms !== fresh.newest_mtime_ms || Object.keys(data.scanned).length !== 2) {
54
+ return { data: buildIndex(root, cfg, files, sc), rebuilt: true };
55
+ }
56
+ return { data, rebuilt: false };
57
+ }
@@ -0,0 +1,44 @@
1
+ // REQ-IDX-003 one canonical entry per REQ, SCN, and ADR ID.
2
+ import { cmpTuple } from "../util/pystr.js";
3
+ export const SPEC_KINDS = new Set(["req", "scn", "adr"]);
4
+ /** Canonical order: active change, then openspec/specs, then archive. A removed entry is never canonical. */
5
+ export function tier(e) {
6
+ const st = e.status;
7
+ if (st === "removed")
8
+ return null;
9
+ if (st === "modified" || (st === "active" && e.change !== "main"))
10
+ return 0;
11
+ if (st === "active")
12
+ return 1;
13
+ return 2;
14
+ }
15
+ export function markCanonical(entries) {
16
+ const best = new Map();
17
+ for (const e of entries) {
18
+ if (!SPEC_KINDS.has(e.kind)) {
19
+ e.canonical = true;
20
+ continue;
21
+ }
22
+ e.canonical = false;
23
+ const t = tier(e);
24
+ if (t === null)
25
+ continue;
26
+ const key = [t, e.file, e.line];
27
+ const cur = best.get(e.id);
28
+ if (!cur || cmpTuple(key, cur[0]) < 0)
29
+ best.set(e.id, [key, e]);
30
+ }
31
+ for (const [, e] of best.values())
32
+ e.canonical = true;
33
+ }
34
+ /** Canonical entries by ID. A later entry with the same ID wins, as in the Python oracle. */
35
+ export function canon(entries) {
36
+ const m = new Map();
37
+ for (const e of entries)
38
+ if (e.canonical)
39
+ m.set(e.id, e);
40
+ return m;
41
+ }
42
+ export function poolOf(entries, all) {
43
+ return all ? entries : entries.filter((e) => e.canonical);
44
+ }