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.
- package/LICENSE +21 -0
- package/README.md +245 -2
- package/action.yml +33 -0
- package/dist/adopt/config-write.js +59 -0
- package/dist/adopt/plan.js +276 -0
- package/dist/adopt/write.js +126 -0
- package/dist/assets.generated.js +14 -0
- package/dist/baseline/baseline.js +65 -0
- package/dist/check/check.js +126 -0
- package/dist/check/evidence.js +70 -0
- package/dist/check/hints.js +26 -0
- package/dist/cli/args.js +90 -0
- package/dist/cli/commands.js +275 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/gate.js +187 -0
- package/dist/cli/help.js +230 -0
- package/dist/cli/root.js +32 -0
- package/dist/cli/status.js +116 -0
- package/dist/cli/workflow.js +274 -0
- package/dist/cli.js +13 -0
- package/dist/config.js +119 -0
- package/dist/graph/bundle.js +94 -0
- package/dist/graph/query.js +31 -0
- package/dist/graph/related.js +76 -0
- package/dist/handoff/handoff.js +148 -0
- package/dist/ids/ids.js +115 -0
- package/dist/index/build.js +57 -0
- package/dist/index/canonical.js +44 -0
- package/dist/index/scan.js +357 -0
- package/dist/index/types.js +2 -0
- package/dist/init/init.js +141 -0
- package/dist/lint/cli.js +253 -0
- package/dist/lint/py.js +152 -0
- package/dist/lint/selftest.js +246 -0
- package/dist/lint/ste.js +395 -0
- package/dist/lint/tells.js +116 -0
- package/dist/main.js +74 -0
- package/dist/stats/stats.js +143 -0
- package/dist/stats/write.js +26 -0
- package/dist/tasks/evidence.js +102 -0
- package/dist/tasks/tasks.js +105 -0
- package/dist/util/glob.js +43 -0
- package/dist/util/hash.js +14 -0
- package/dist/util/json.js +16 -0
- package/dist/util/pystr.js +67 -0
- package/dist/util/time.js +11 -0
- package/dist/verify/parse.js +58 -0
- package/dist/verify/run.js +42 -0
- package/dist/version.js +2 -0
- package/package.json +50 -4
- package/schemas/spec-driven-ids/README.md +18 -0
- package/schemas/spec-driven-ids/config-fragment.yaml +25 -0
- package/schemas/spec-driven-ids/schema.yaml +301 -0
- package/schemas/spec-driven-ids/templates/design.md +22 -0
- package/schemas/spec-driven-ids/templates/proposal.md +34 -0
- package/schemas/spec-driven-ids/templates/spec.md +15 -0
- package/schemas/spec-driven-ids/templates/tasks.md +10 -0
- package/skills/specctx-bootstrap/SKILL.md +27 -0
- package/skills/specctx-done/SKILL.md +25 -0
- package/skills/specctx-next/SKILL.md +23 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// REQ-DIST-002 spec-ctx.toml with defaults from REQ-IDX-001 and REQ-QRY-003.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
export const CONFIG_FILE = "spec-ctx.toml";
|
|
6
|
+
export function defaults() {
|
|
7
|
+
return {
|
|
8
|
+
scan: {
|
|
9
|
+
specs: ["openspec/specs/**/*.md"],
|
|
10
|
+
changes: ["openspec/changes/**/*.md"],
|
|
11
|
+
decisions: ["decisions/*.md"],
|
|
12
|
+
code: ["src/**"],
|
|
13
|
+
tests: ["tests/**"],
|
|
14
|
+
ignore: [],
|
|
15
|
+
},
|
|
16
|
+
ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
|
|
17
|
+
bundle: { budget: 8000, depth: 1 },
|
|
18
|
+
verify: { timeout_s: 600 },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export class ConfigError extends Error {
|
|
22
|
+
}
|
|
23
|
+
function strList(v, key) {
|
|
24
|
+
if (typeof v === "string")
|
|
25
|
+
return [v];
|
|
26
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string"))
|
|
27
|
+
return v;
|
|
28
|
+
throw new ConfigError(`${CONFIG_FILE}: ${key} must be a string or a list of strings.`);
|
|
29
|
+
}
|
|
30
|
+
/** Read the config of a project root. A missing file gives all defaults. */
|
|
31
|
+
export function loadConfig(root) {
|
|
32
|
+
const cfg = defaults();
|
|
33
|
+
const p = join(root, CONFIG_FILE);
|
|
34
|
+
if (!existsSync(p))
|
|
35
|
+
return cfg;
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = parse(readFileSync(p, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw new ConfigError(`${CONFIG_FILE}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
|
|
42
|
+
}
|
|
43
|
+
const sec = (name) => {
|
|
44
|
+
const v = raw[name];
|
|
45
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
46
|
+
};
|
|
47
|
+
const scan = sec("scan");
|
|
48
|
+
for (const k of Object.keys(cfg.scan)) {
|
|
49
|
+
if (k in scan)
|
|
50
|
+
cfg.scan[k] = strList(scan[k], `scan.${k}`);
|
|
51
|
+
}
|
|
52
|
+
const ids = sec("ids");
|
|
53
|
+
if ("caps" in ids)
|
|
54
|
+
cfg.ids.caps = strList(ids.caps, "ids.caps");
|
|
55
|
+
if ("task_pattern" in ids) {
|
|
56
|
+
if (typeof ids.task_pattern !== "string")
|
|
57
|
+
throw new ConfigError(`${CONFIG_FILE}: ids.task_pattern must be a string.`);
|
|
58
|
+
cfg.ids.task_pattern = ids.task_pattern;
|
|
59
|
+
}
|
|
60
|
+
if ("paths" in ids) {
|
|
61
|
+
const v = ids.paths;
|
|
62
|
+
if (v === null || typeof v !== "object" || Array.isArray(v) || !Object.values(v).every((x) => typeof x === "string")) {
|
|
63
|
+
throw new ConfigError(`${CONFIG_FILE}: ids.paths must be a table of code = "capability path".`);
|
|
64
|
+
}
|
|
65
|
+
cfg.ids.paths = { ...v };
|
|
66
|
+
}
|
|
67
|
+
const bundle = sec("bundle");
|
|
68
|
+
for (const k of ["budget", "depth"]) {
|
|
69
|
+
if (k in bundle) {
|
|
70
|
+
const v = bundle[k];
|
|
71
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
72
|
+
throw new ConfigError(`${CONFIG_FILE}: bundle.${k} must be an integer of 0 or more.`);
|
|
73
|
+
}
|
|
74
|
+
cfg.bundle[k] = v;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const verify = sec("verify");
|
|
78
|
+
if ("timeout_s" in verify) {
|
|
79
|
+
const v = verify.timeout_s;
|
|
80
|
+
if (typeof v !== "number" || !(v > 0))
|
|
81
|
+
throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
|
|
82
|
+
cfg.verify.timeout_s = v;
|
|
83
|
+
}
|
|
84
|
+
return cfg;
|
|
85
|
+
}
|
|
86
|
+
/** The file that `specctx init` writes. Each key has a comment above it. */
|
|
87
|
+
export const INIT_TEXT = `# spec-ctx configuration. Every key is optional. A missing key takes its default.
|
|
88
|
+
|
|
89
|
+
[scan]
|
|
90
|
+
# Main specs. Markdown files with requirement and scenario headers.
|
|
91
|
+
specs = ["openspec/specs/**/*.md"]
|
|
92
|
+
# Active and archived OpenSpec changes.
|
|
93
|
+
changes = ["openspec/changes/**/*.md"]
|
|
94
|
+
# Decision records.
|
|
95
|
+
decisions = ["decisions/*.md"]
|
|
96
|
+
# Source files that carry code tags. A code tag is a comment with a REQ ID.
|
|
97
|
+
code = ["src/**"]
|
|
98
|
+
# Test files that carry Covers tags and scenario IDs.
|
|
99
|
+
tests = ["tests/**"]
|
|
100
|
+
# Paths that the scan skips.
|
|
101
|
+
ignore = []
|
|
102
|
+
|
|
103
|
+
[ids]
|
|
104
|
+
# Capability codes. check reports a REQ whose code is not in this list. id new accepts only these codes.
|
|
105
|
+
# An empty list turns the rule off, and id new accepts any code of 2 to 6 upper case letters.
|
|
106
|
+
caps = []
|
|
107
|
+
# Pattern of the task number in a tasks.md checkbox.
|
|
108
|
+
task_pattern = '\\d+\\.\\d+'
|
|
109
|
+
|
|
110
|
+
[bundle]
|
|
111
|
+
# Default character budget of a bundle.
|
|
112
|
+
budget = 8000
|
|
113
|
+
# Default depth of related entries in a bundle.
|
|
114
|
+
depth = 1
|
|
115
|
+
|
|
116
|
+
[verify]
|
|
117
|
+
# Seconds before task done stops a Verify command.
|
|
118
|
+
timeout_s = 600
|
|
119
|
+
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { canon } from "../index/canonical.js";
|
|
4
|
+
import { blockRange, readText } from "../index/scan.js";
|
|
5
|
+
import { cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
|
+
import { KIND_RANK, related } from "./related.js";
|
|
7
|
+
export class Lines {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
root;
|
|
10
|
+
constructor(root) {
|
|
11
|
+
this.root = root;
|
|
12
|
+
}
|
|
13
|
+
get(rpath) {
|
|
14
|
+
let l = this.cache.get(rpath);
|
|
15
|
+
if (!l)
|
|
16
|
+
this.cache.set(rpath, (l = splitLines(readText(join(this.root, rpath)))));
|
|
17
|
+
return l;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Markdown text of an entry. Code and test entries print no body, only their provenance line. */
|
|
21
|
+
export function blockText(e, files) {
|
|
22
|
+
if (e.kind === "code" || e.kind === "test")
|
|
23
|
+
return "";
|
|
24
|
+
const lines = files.get(e.file);
|
|
25
|
+
const start = e.line - 1;
|
|
26
|
+
if (e.kind === "task")
|
|
27
|
+
return pyStrip(lines[start] ?? "") + "\n";
|
|
28
|
+
if (e.kind === "decision") {
|
|
29
|
+
const out = [lines[start] ?? ""];
|
|
30
|
+
for (const line of lines.slice(start + 1)) {
|
|
31
|
+
if (!pyStrip(line) || line.startsWith("- ") || line.startsWith("#"))
|
|
32
|
+
break;
|
|
33
|
+
out.push(line);
|
|
34
|
+
}
|
|
35
|
+
return rstrip(out.join("\n")) + "\n";
|
|
36
|
+
}
|
|
37
|
+
const [s, end] = blockRange(lines, start);
|
|
38
|
+
return rstrip(lines.slice(s, end).join("\n")) + "\n";
|
|
39
|
+
}
|
|
40
|
+
function rstrip(s) {
|
|
41
|
+
const t = pyStrip("x" + s);
|
|
42
|
+
return t.slice(1);
|
|
43
|
+
}
|
|
44
|
+
export function provenance(e, reason) {
|
|
45
|
+
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
|
|
46
|
+
}
|
|
47
|
+
export function bundle(root, entries, wanted, budget, depth, all = false) {
|
|
48
|
+
const ids = canon(entries);
|
|
49
|
+
const blocks = [];
|
|
50
|
+
for (const i of wanted) {
|
|
51
|
+
const copies = [ids.get(i)];
|
|
52
|
+
if (all)
|
|
53
|
+
copies.push(...entries.filter((e) => e.id === i && !e.canonical));
|
|
54
|
+
for (const e of copies)
|
|
55
|
+
blocks.push([e, "requested"]);
|
|
56
|
+
}
|
|
57
|
+
const found = new Map();
|
|
58
|
+
for (const i of wanted) {
|
|
59
|
+
// decisions that reference a requested ID are included at every depth
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.kind === "decision" && e.refs.includes(i) && !found.has(e.id))
|
|
62
|
+
found.set(e.id, `decision for ${i}`);
|
|
63
|
+
}
|
|
64
|
+
for (const [r, reason] of related(entries, i, depth, all)) {
|
|
65
|
+
if (!wanted.includes(r) && !found.has(r))
|
|
66
|
+
found.set(r, reason);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// scenarios print inside their requirement
|
|
70
|
+
const tail = [...found.keys()].filter((r) => ids.has(r) && ids.get(r).kind !== "scn");
|
|
71
|
+
tail.sort((a, b) => cmpTuple([KIND_RANK[ids.get(a).kind] ?? 9, a], [KIND_RANK[ids.get(b).kind] ?? 9, b]));
|
|
72
|
+
for (const r of tail)
|
|
73
|
+
blocks.push([ids.get(r), found.get(r)]);
|
|
74
|
+
const files = new Lines(root);
|
|
75
|
+
const out = [];
|
|
76
|
+
const omitted = [];
|
|
77
|
+
let used = 0;
|
|
78
|
+
for (const [e, reason] of blocks) {
|
|
79
|
+
const body = blockText(e, files);
|
|
80
|
+
// a blank line closes a block with a body. Code and test paths stay one line each.
|
|
81
|
+
const t = provenance(e, reason) + body + (body ? "\n" : "");
|
|
82
|
+
const n = cpLen(t);
|
|
83
|
+
if (used + n > budget) {
|
|
84
|
+
omitted.push(e.id);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out.push(t);
|
|
88
|
+
used += n;
|
|
89
|
+
}
|
|
90
|
+
let text = out.join("");
|
|
91
|
+
if (omitted.length)
|
|
92
|
+
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
93
|
+
return { text, omitted };
|
|
94
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
|
|
2
|
+
import { poolOf } from "../index/canonical.js";
|
|
3
|
+
import { matchesChange } from "../index/scan.js";
|
|
4
|
+
import { cmpTuple, pySplit } from "../util/pystr.js";
|
|
5
|
+
export function query(entries, args, o = {}) {
|
|
6
|
+
const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
|
|
7
|
+
const hits = [];
|
|
8
|
+
for (const e of poolOf(entries, !!o.all)) {
|
|
9
|
+
if ((o.kind && e.kind !== o.kind) || (o.change && !matchesChange(e.change, o.change)))
|
|
10
|
+
continue;
|
|
11
|
+
// REQ-IDX-005 --change takes a key or a full folder name
|
|
12
|
+
const idl = e.id.toLowerCase();
|
|
13
|
+
const tl = e.title.toLowerCase();
|
|
14
|
+
const xl = e.text ?? "";
|
|
15
|
+
if (!terms.every((t) => idl.includes(t) || tl.includes(t) || xl.includes(t)))
|
|
16
|
+
continue;
|
|
17
|
+
let score = 0;
|
|
18
|
+
for (const t of terms)
|
|
19
|
+
score += 3 * +idl.includes(t) + 2 * +tl.includes(t) + +xl.includes(t);
|
|
20
|
+
hits.push([-score, e.id, e.file, e.line, e]);
|
|
21
|
+
}
|
|
22
|
+
hits.sort((a, b) => cmpTuple(a.slice(0, 4), b.slice(0, 4)));
|
|
23
|
+
let out = hits.map((h) => h[4]);
|
|
24
|
+
const limit = o.limit ?? 20;
|
|
25
|
+
if (limit > 0)
|
|
26
|
+
out = out.slice(0, limit);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function queryLine(e) {
|
|
30
|
+
return `${e.id} ${e.kind} ${e.status} ${e.file}:${e.line} ${e.title}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// REQ-QRY-002 IDs reachable from a start ID, with the reason each one was included.
|
|
2
|
+
import { canon, poolOf } from "../index/canonical.js";
|
|
3
|
+
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
4
|
+
// REQ-QRY-003 fixed block order after the requested IDs
|
|
5
|
+
export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5 };
|
|
6
|
+
export const REASONS = ["scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers"];
|
|
7
|
+
export function related(entries, start, depth, all = false) {
|
|
8
|
+
const pool = poolOf(entries, all);
|
|
9
|
+
const ids = canon(entries);
|
|
10
|
+
for (const e of pool)
|
|
11
|
+
if (!ids.has(e.id))
|
|
12
|
+
ids.set(e.id, e);
|
|
13
|
+
const back = new Map();
|
|
14
|
+
for (const e of pool) {
|
|
15
|
+
for (const r of e.refs) {
|
|
16
|
+
let s = back.get(r);
|
|
17
|
+
if (!s)
|
|
18
|
+
back.set(r, (s = new Set()));
|
|
19
|
+
s.add(e.id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const found = new Map();
|
|
23
|
+
const add = (i, reason) => {
|
|
24
|
+
if (i !== start && !found.has(i)) {
|
|
25
|
+
found.set(i, reason);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
const s = ids.get(start);
|
|
31
|
+
if (s && s.kind === "req")
|
|
32
|
+
for (const sid of s.scenarios ?? [])
|
|
33
|
+
add(sid, `scenario of ${start}`);
|
|
34
|
+
let frontier = [start];
|
|
35
|
+
for (let d = 0; d < depth; d++) {
|
|
36
|
+
const nxt = [];
|
|
37
|
+
for (const x of frontier) {
|
|
38
|
+
const ex = ids.get(x);
|
|
39
|
+
if (ex) {
|
|
40
|
+
for (const r of ex.refs)
|
|
41
|
+
if (add(r, `ref from ${x}`))
|
|
42
|
+
nxt.push(r);
|
|
43
|
+
if (ex.kind === "req") {
|
|
44
|
+
for (const sid of ex.scenarios ?? [])
|
|
45
|
+
if (add(sid, `scenario of ${x}`))
|
|
46
|
+
nxt.push(sid);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const y of [...(back.get(x) ?? [])].sort(cmpStr)) {
|
|
50
|
+
const ey = ids.get(y);
|
|
51
|
+
const k = ey ? ey.kind : "?";
|
|
52
|
+
let reason;
|
|
53
|
+
if (k === "scn" && ex && (ex.scenarios ?? []).includes(y))
|
|
54
|
+
reason = `scenario of ${x}`;
|
|
55
|
+
else if (k === "decision")
|
|
56
|
+
reason = `decision for ${x}`;
|
|
57
|
+
else if (k === "task")
|
|
58
|
+
reason = `task cites ${x}`;
|
|
59
|
+
else if (k === "code")
|
|
60
|
+
reason = `implements ${x}`;
|
|
61
|
+
else if (k === "test")
|
|
62
|
+
reason = `covers ${x}`;
|
|
63
|
+
else
|
|
64
|
+
reason = `ref to ${x}`;
|
|
65
|
+
if (add(y, reason))
|
|
66
|
+
nxt.push(y);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
frontier = nxt;
|
|
70
|
+
}
|
|
71
|
+
const rank = (i) => {
|
|
72
|
+
const e = ids.get(i);
|
|
73
|
+
return [e ? (KIND_RANK[e.kind] ?? 9) : 9, i];
|
|
74
|
+
};
|
|
75
|
+
return [...found.keys()].sort((a, b) => cmpTuple(rank(a), rank(b))).map((i) => [i, found.get(i)]);
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/ids/ids.js
ADDED
|
@@ -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
|
+
}
|