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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quach Hoanh Son
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,246 @@
1
- # Temporary Holding Version
1
+ # spec-ctx
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ spec-ctx ticks an [OpenSpec](https://github.com/Fission-AI/OpenSpec) task only after its Verify command passes and hands the agent one task of spec text, a context ratio of 0.0518 (`docs/metrics/2026-10-25.md`).
4
+
5
+ ```bash
6
+ npx -y github:sonqh/spec-ctx#4490d188ad48 init --all
7
+ npx -y github:sonqh/spec-ctx#4490d188ad48 task next
8
+ npx -y github:sonqh/spec-ctx#4490d188ad48 task start add-mfa/1.3
9
+ ```
10
+
11
+ ```text
12
+ $ specctx task start add-mfa/1.3
13
+ -- REQ-UA-004 | openspec/changes/add-mfa/specs/user-auth/spec.md:5 | active | requested
14
+ ### Requirement: REQ-UA-004 Second factor
15
+ The system SHALL ask for a TOTP code after a valid password.
16
+
17
+ #### Scenario: SCN-UA-004-1 Valid code
18
+ - **WHEN** the user enters the current TOTP code
19
+ - **THEN** the system opens a session
20
+
21
+ #### Scenario: SCN-UA-004-2 Expired code
22
+ - **WHEN** the user enters a code older than 30 seconds
23
+ - **THEN** the system rejects it
24
+ ```
25
+
26
+ ## Why
27
+
28
+ - Checkboxes lie. `task done` runs Verify and writes evidence, and 70 of 86 tasks hold it (evidence ratio 0.81).
29
+ - Context overflows. A bundle holds 0.0518 of the spec text on average (27 tasks).
30
+ - Evidence goes stale. The check caught 36 stale results in 59 reopens.
31
+
32
+ ## Demo
33
+
34
+ ![One task loop: status, task next, task start, an edit, task done, stats](docs/demo/loop.svg)
35
+
36
+ <details>
37
+ <summary>Transcript of the demo</summary>
38
+
39
+ ```text
40
+ $ specctx status
41
+ index rebuilt
42
+ add-mfa done 2 open 1
43
+ uncovered active REQs in main: 5
44
+ next: specctx task next
45
+ $ specctx task next
46
+ add-mfa/1.3 Add the TOTP prompt to the login form.
47
+ reqs: REQ-UA-004
48
+ verify: grep -q totp src/login.ts
49
+ $ specctx task start add-mfa/1.3 | head -12
50
+ -- REQ-UA-004 | openspec/changes/add-mfa/specs/user-auth/spec.md:5 | active | requested
51
+ ### Requirement: REQ-UA-004 Second factor
52
+ The system SHALL ask for a TOTP code after a valid password.
53
+
54
+ #### Scenario: SCN-UA-004-1 Valid code
55
+ - **WHEN** the user enters the current TOTP code
56
+ - **THEN** the system opens a session
57
+
58
+ #### Scenario: SCN-UA-004-2 Expired code
59
+ - **WHEN** the user enters a code older than 30 seconds
60
+ - **THEN** the system rejects it
61
+
62
+ $ printf '// REQ-UA-004\nexport const prompt = "totp";\n' > src/login.ts
63
+ $ specctx task done add-mfa/1.3
64
+ index rebuilt
65
+ done add-mfa/1.3. Evidence: .ctx/evidence/add-mfa/1.3.json
66
+ $ specctx stats --change add-mfa
67
+ index rebuilt
68
+ change context_ratio evidence_ratio stale_catches cycle_time_median_s debt
69
+ add-mfa 0.2950 (n=1) 1.00 (1/1) n/a 6 (n=1) 2
70
+ total 0.2950 (n=1) 1.00 (1/1) n/a 6 (n=1) 13 (0 baselined + 13 new)
71
+ gate_runs n/a
72
+ gate_pass_rate n/a
73
+ gate_failures_caught n/a
74
+ context_ratio = bundle chars / chars of all specs. Lower is better.
75
+ ```
76
+
77
+ </details>
78
+
79
+ The demo runs on a copy of `tests/fixtures/no-id-repo` after `specctx adopt`. `bash docs/demo/record.sh` records it again.
80
+
81
+ ## What it does
82
+
83
+ spec-ctx is a deterministic context engine and task workflow for OpenSpec projects. The CLI is `specctx`.
84
+
85
+ OpenSpec gives you a proposal, specs, a design, and tasks. spec-ctx adds what an AI agent needs to work on them without reading the whole tree:
86
+
87
+ - Stable IDs on every requirement, scenario, decision, and task.
88
+ - An index that links each ID to code, tests, and other IDs.
89
+ - `bundle`: the few kilobytes of spec that one task needs, with the file and line of each block.
90
+ - `task next`, `task start`, `task done`: the checkbox changes only after the `Verify:` command of the task exits 0, and the evidence stays beside the tree.
91
+ - A generated handoff, so the next session starts with one command.
92
+
93
+ No LLM call. No daemon. The same files give the same bytes.
94
+
95
+ ## Install
96
+
97
+ spec-ctx needs Node 22. Run it in a project after `openspec init`.
98
+
99
+ ```bash
100
+ npx -y github:sonqh/spec-ctx#4490d188ad48 init --all # from git, works today
101
+ npx spec-ctx init --all # from npm, after the 0.1.0 publish
102
+ ```
103
+
104
+ The git form builds the package on install with Node only. Pin a commit SHA. A branch name such as `main` also works, but it moves.
105
+
106
+ The command writes `spec-ctx.toml`, installs the `spec-driven-ids` schema into `openspec/schemas/`, adds its rules to `openspec/config.yaml`, and installs three skills into `.claude/skills/` and `.agents/skills/`.
107
+
108
+ The examples below use `specctx`. With `npx`, write the `npx` form in its place. After the publish, `npm install -g spec-ctx` puts `specctx` on your path. Each GitHub release also carries a single binary for Linux and macOS.
109
+
110
+ ## Two paths
111
+
112
+ ### Fresh project
113
+
114
+ ```bash
115
+ npx -y @fission-ai/openspec@latest init
116
+ npx spec-ctx init --all
117
+ specctx gate --strict
118
+ ```
119
+
120
+ ### Existing OpenSpec project
121
+
122
+ ```bash
123
+ npx spec-ctx init --all
124
+ specctx adopt --dry-run # the ID that each header gets. Writes nothing.
125
+ specctx adopt # writes the IDs into main specs and active deltas
126
+ specctx task backfill --manual "adopted"
127
+ specctx baseline write
128
+ specctx gate --strict
129
+ ```
130
+
131
+ - `adopt` gives an ID to every requirement and scenario header without one, in `openspec/specs/` and in the deltas of active changes. A delta header gets the main ID by its text or through its RENAMED line. A delta header that matches neither keeps its text and goes into `adopt-report.md`.
132
+ - `adopt` derives a capability code from each folder name, for example `UA` for `user-auth`. It writes the codes into `spec-ctx.toml`. Rename a code there before the IDs spread.
133
+ - `adopt` runs `check` before and after the write. A finding that the write added undoes every file. A second run prints `nothing to adopt`.
134
+ - `task backfill` writes manual evidence for every ticked task that has none.
135
+ - `baseline write` records the debt of the day in `.ctx/baseline.json`: untested requirements and scenarios, tasks without evidence, and legacy tasks without a citation or a Verify code span. `check` hides that debt and prints `baselined: <n>`. New debt still shows.
136
+ - Legacy tasks stay in the baseline until an agent adds `[REQ-...]` citations and a `Verify:` code span. `specctx baseline prune` then drops them. No command adds a single entry.
137
+
138
+ Both paths have an end to end test: `tests/paths/fresh.sh` and `tests/paths/existing.sh`.
139
+
140
+ ## The loop
141
+
142
+ ```bash
143
+ specctx status # active changes, current task, open questions
144
+ specctx task next # the next open task, its REQ IDs, and its Verify command
145
+ specctx task start 001/1.1 # the spec bundle for that task
146
+ specctx task done 001/1.1 # run Verify, write evidence, set the checkbox
147
+ specctx check --strict # IDs, links, coverage, and evidence
148
+ specctx handoff write # .ctx/handoff.md for the next session
149
+ ```
150
+
151
+ The skills `specctx-bootstrap`, `specctx-next`, and `specctx-done` teach an agent this loop. Install them alone with `npx skills add sonqh/spec-ctx` or `specctx init --skills`.
152
+
153
+ ## Commands
154
+
155
+ | Command | Use |
156
+ | --- | --- |
157
+ | `build` | Write `.ctx/index.json`. Every other command rebuilds a stale index first. |
158
+ | `status` | Active changes with task counts, the current task, and open questions. |
159
+ | `query <term...>` | Entries that hold every term. Flags: `--kind`, `--change`, `--limit`, `--all`. |
160
+ | `related <ID>` | Linked entries with the reason for each link. Flags: `--depth`, `--all`. |
161
+ | `bundle <ID...>` | Spec text of the IDs, their scenarios, decisions, and neighbors. Flags: `--budget`, `--depth`, `--all`. |
162
+ | `check` | ID and link integrity. `--strict` adds test coverage and evidence. |
163
+ | `id new`, `id retire` | Allocate and retire IDs. |
164
+ | `task next`, `start`, `verify`, `done`, `reopen` | The task state machine. |
165
+ | `handoff write`, `ask`, `resolve` | The session handoff and its open questions. |
166
+ | `init` | Config file, schema bundle, and skills. Flags: `--schema`, `--skills`, `--all`, `--force`. |
167
+ | `adopt` | IDs for a project without them. `--dry-run` writes nothing. |
168
+ | `baseline write`, `prune` | The debt ratchet in `.ctx/baseline.json`. |
169
+ | `task backfill` | Manual evidence for ticked tasks without it. |
170
+ | `gate` | `check`, both markdown linters, `openspec validate`, and the 500 character length check on active deltas. |
171
+ | `lint tells`, `lint ste` | The two markdown linters. Flags: `--baseline`, `--disable`, `--json`, `--selftest`. |
172
+ | `help <command>` | The page of one command. `specctx <command> --help` prints the same page. |
173
+
174
+ Every command takes `--root <path>` and `--json`. A task ID takes the change key, such as `008/1.1` or `add-mfa/1.1`, or the full folder name. Outside an OpenSpec project every command but `init` exits 2. Exit codes: 0 for success, 1 for findings or a failed Verify command, 2 for a usage error, 3 for an internal error.
175
+
176
+ ## Config reference
177
+
178
+ `spec-ctx.toml` sits at the project root. Every key is optional.
179
+
180
+ ```toml
181
+ [scan]
182
+ specs = ["openspec/specs/**/*.md"] # main specs
183
+ changes = ["openspec/changes/**/*.md"] # active and archived changes
184
+ decisions = ["decisions/*.md"] # decision records
185
+ code = ["src/**"] # files with code tags
186
+ tests = ["tests/**"] # files with Covers tags and scenario IDs
187
+ ignore = [] # globs that the scan skips
188
+
189
+ [ids]
190
+ caps = [] # capability codes. Empty means any code is valid.
191
+ task_pattern = '\d+\.\d+' # the task number in a tasks.md checkbox
192
+
193
+ [ids.paths]
194
+ UA = "user-auth" # capability path of each code. adopt writes the derived ones.
195
+
196
+ [bundle]
197
+ budget = 8000 # characters
198
+ depth = 1
199
+
200
+ [verify]
201
+ timeout_s = 600
202
+ ```
203
+
204
+ One environment variable changes the engine:
205
+
206
+ | Variable | Effect |
207
+ | --- | --- |
208
+ | `SPECCTX_OPENSPEC` | The command that `gate` runs for `openspec validate`. The default is `npx -y @fission-ai/openspec@latest`. Tests and offline machines set it. |
209
+
210
+ ## Markdown linters
211
+
212
+ `specctx lint tells` and `specctx lint ste` replace `scripts/tells-lint.py` and `scripts/ste-lint.py` from the ste-humanize skill. They take the same flags and give the same output and exit codes. They need Node only.
213
+
214
+ ```bash
215
+ # before
216
+ python3 scripts/tells-lint.py --baseline 0 README.md
217
+ python3 scripts/ste-lint.py --baseline 0 --disable synonym-rotation README.md
218
+ # after
219
+ npx spec-ctx lint tells --baseline 0 README.md
220
+ npx spec-ctx lint ste --baseline 0 --disable synonym-rotation README.md
221
+ ```
222
+
223
+ ## GitHub Action
224
+
225
+ ```yaml
226
+ - uses: sonqh/spec-ctx@v0
227
+ with:
228
+ version: latest # npm version, or a git spec such as github:sonqh/spec-ctx#main
229
+ strict: true
230
+ ```
231
+
232
+ The action runs `specctx gate`: `check --strict`, both markdown linters, `openspec validate --all --strict`, and the length check.
233
+
234
+ ## Status
235
+
236
+ - Archived: 001 engine port, 002 IDs and tasks, 004 adopt existing, 005 metrics, 006 polish, 007 README and demo, 008 first run UX.
237
+ - Open: 003 distribution. Only the npm publish of `0.1.0` waits.
238
+ - Until the publish, install from git with `npx -y github:sonqh/spec-ctx#4490d188ad48`. The npm forms `npx spec-ctx` and `npm install -g spec-ctx` work after it.
239
+
240
+ ## Contribute
241
+
242
+ `docs/CONTRIBUTING.md` holds the build steps, the layout, the ID scheme, and the gate steps.
243
+
244
+ ## License
245
+
246
+ MIT.
package/action.yml ADDED
@@ -0,0 +1,33 @@
1
+ name: spec-ctx check
2
+ description: Run specctx check, the markdown linters, and openspec validate on an OpenSpec project.
3
+ branding:
4
+ icon: check-square
5
+ color: blue
6
+ inputs:
7
+ version:
8
+ description: Version of the spec-ctx npm package, or a git spec such as github:sonqh/spec-ctx#main.
9
+ default: latest
10
+ strict:
11
+ description: Run check and validate in strict mode.
12
+ default: "true"
13
+ markdown:
14
+ description: Globs of the markdown files that the linters read, separated by spaces.
15
+ default: "openspec/**/*.md decisions/*.md *.md"
16
+ specctx:
17
+ description: Command that runs specctx. Empty means npx with the version input.
18
+ default: ""
19
+ runs:
20
+ using: composite
21
+ steps:
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: 22
25
+ - name: spec-ctx gates
26
+ shell: bash
27
+ run: bash "$GITHUB_ACTION_PATH/action/run.sh"
28
+ env:
29
+ INPUT_VERSION: ${{ inputs.version }}
30
+ INPUT_STRICT: ${{ inputs.strict }}
31
+ INPUT_MARKDOWN: ${{ inputs.markdown }}
32
+ INPUT_SPECCTX: ${{ inputs.specctx }}
33
+ SPECCTX_ACTION_PATH: ${{ github.action_path }}
@@ -0,0 +1,59 @@
1
+ // REQ-ADOPT-004 write derived codes into spec-ctx.toml under [ids] caps and [ids.paths]. Comments stay.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "smol-toml";
5
+ import { CONFIG_FILE, INIT_TEXT } from "../config.js";
6
+ /** The new text of spec-ctx.toml with the codes added. Codes already listed stay as they are. */
7
+ export function withCaps(text, caps) {
8
+ const cur = parse(text);
9
+ const have = new Set(cur.ids?.caps ?? []);
10
+ const paths = cur.ids?.paths ?? {};
11
+ const addCaps = caps.filter((c) => !have.has(c.code)).map((c) => c.code);
12
+ const addPaths = caps.filter((c) => paths[c.code] === undefined);
13
+ let lines = text.split("\n");
14
+ const sectionAt = (name) => lines.findIndex((l) => l.trim() === `[${name}]`);
15
+ if (addCaps.length) {
16
+ let ids = sectionAt("ids");
17
+ if (ids < 0) {
18
+ lines.push("", "[ids]");
19
+ ids = lines.length - 1;
20
+ }
21
+ let end = lines.findIndex((l, i) => i > ids && /^\s*\[/.test(l));
22
+ if (end < 0)
23
+ end = lines.length;
24
+ const at = lines.findIndex((l, i) => i > ids && i < end && /^caps\s*=/.test(l));
25
+ const all = [...(cur.ids?.caps ?? []), ...addCaps];
26
+ const line = `caps = [${all.map((c) => JSON.stringify(c)).join(", ")}]`;
27
+ if (at >= 0 && /\]\s*(#.*)?$/.test(lines[at]))
28
+ lines[at] = line;
29
+ else if (at >= 0) {
30
+ // a caps list over several lines: replace it up to the closing bracket
31
+ let close = at;
32
+ while (close < end && !/\]/.test(lines[close]))
33
+ close++;
34
+ lines.splice(at, close - at + 1, line);
35
+ }
36
+ else
37
+ lines.splice(ids + 1, 0, "# Capability codes. adopt added the derived ones.", line);
38
+ }
39
+ if (addPaths.length) {
40
+ let sec = sectionAt("ids.paths");
41
+ if (sec < 0) {
42
+ lines = [...lines.join("\n").replace(/\n*$/, "").split("\n"), "", "[ids.paths]", "# Capability path of each code. adopt wrote the derived ones. Rename a code here before the IDs spread."];
43
+ sec = lines.length - 1;
44
+ }
45
+ let end = lines.findIndex((l, i) => i > sec && /^\s*\[/.test(l));
46
+ if (end < 0)
47
+ end = lines.length;
48
+ while (end > sec + 1 && lines[end - 1].trim() === "")
49
+ end--;
50
+ lines.splice(end, 0, ...addPaths.map((c) => `${c.code} = ${JSON.stringify(c.path)}`));
51
+ }
52
+ const out = lines.join("\n").replace(/\n*$/, "\n");
53
+ parse(out);
54
+ return out;
55
+ }
56
+ export function configText(root) {
57
+ const p = join(root, CONFIG_FILE);
58
+ return existsSync(p) ? readFileSync(p, "utf8") : INIT_TEXT;
59
+ }
@@ -0,0 +1,276 @@
1
+ // REQ-ADOPT-001 REQ-ADOPT-004 the adopt plan: which header gets which ID. It reads files and writes nothing.
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { UsageExit } from "../cli/errors.js";
5
+ import { retiredAll } from "../ids/ids.js";
6
+ import { cmpStr, splitLines } from "../util/pystr.js";
7
+ const REQ_HDR = /^### Requirement:[ \t]*(.*)$/s;
8
+ const SCN_HDR = /^#### Scenario:[ \t]*(.*)$/s;
9
+ const HAS_REQ = /^REQ-([A-Z]{2,6})-(\d{3})(?:[ \t]|$)/;
10
+ const HAS_SCN = /^SCN-([A-Z]{2,6})-(\d{3})-(\d+)(?:[ \t]|$)/;
11
+ const RENAME = /^- (FROM|TO):[ \t]*`### Requirement:[ \t]*(.*)`[ \t]*$/s;
12
+ function listSpecs(root, base) {
13
+ const out = [];
14
+ const walk = (abs, rel) => {
15
+ if (!existsSync(abs))
16
+ return;
17
+ for (const n of readdirSync(abs).sort(cmpStr)) {
18
+ const p = join(abs, n);
19
+ const r = `${rel}/${n}`;
20
+ if (statSync(p).isDirectory())
21
+ walk(p, r);
22
+ else if (n === "spec.md")
23
+ out.push(r);
24
+ }
25
+ };
26
+ walk(join(root, base), base);
27
+ return out;
28
+ }
29
+ function splitId(text, re) {
30
+ const m = re.exec(text);
31
+ if (!m)
32
+ return { id: null, name: text.trim() };
33
+ const id = m[0].trim();
34
+ return { id, name: text.slice(m[0].length).trim() };
35
+ }
36
+ function parse(root, rel, cap) {
37
+ const lines = splitLines(readFileSync(join(root, rel)).toString("utf8"));
38
+ const blocks = [];
39
+ const renames = [];
40
+ let section = "";
41
+ let fence = false;
42
+ let req;
43
+ let from = null;
44
+ lines.forEach((line, i) => {
45
+ const t = line.trim();
46
+ if (t.startsWith("```") || t.startsWith("~~~"))
47
+ fence = !fence;
48
+ if (fence)
49
+ return;
50
+ if (line.startsWith("## ")) {
51
+ section = line.slice(3).trim().split(" ")[0].toUpperCase();
52
+ return;
53
+ }
54
+ let m = REQ_HDR.exec(line);
55
+ if (m) {
56
+ const { id, name } = splitId(m[1], HAS_REQ);
57
+ req = { kind: "req", line: i + 1, raw: line, name, id, section };
58
+ blocks.push(req);
59
+ return;
60
+ }
61
+ m = SCN_HDR.exec(line);
62
+ if (m) {
63
+ const { id, name } = splitId(m[1], HAS_SCN);
64
+ blocks.push({ kind: "scn", line: i + 1, raw: line, name, id, req, section });
65
+ return;
66
+ }
67
+ m = RENAME.exec(line);
68
+ if (m && section === "RENAMED") {
69
+ const { id, name } = splitId(m[2], HAS_REQ);
70
+ const item = { line: i + 1, name, id, raw: line };
71
+ if (m[1] === "FROM")
72
+ from = item;
73
+ else if (from) {
74
+ renames.push({ from, to: item });
75
+ from = null;
76
+ }
77
+ }
78
+ });
79
+ return { rel, cap, lines, blocks, renames };
80
+ }
81
+ /** REQ-ADOPT-004 candidate codes in order: the base code, then two and three letters of the first segment, then a suffix letter. */
82
+ export function codeCandidates(path) {
83
+ const segs = path.split(/[/-]/).map((x) => x.toUpperCase().replace(/[^A-Z]/g, "")).filter(Boolean);
84
+ if (!segs.length)
85
+ return [];
86
+ const rest = segs.slice(1).map((x) => x[0]).join("");
87
+ const base = segs.length === 1 ? segs[0].slice(0, 3) : segs.map((x) => x[0]).join("").slice(0, 6);
88
+ const out = [base, (segs[0].slice(0, 2) + rest).slice(0, 6), (segs[0].slice(0, 3) + rest).slice(0, 6)];
89
+ for (let c = 66; c <= 90; c++)
90
+ out.push((base.slice(0, 5) + String.fromCharCode(c)));
91
+ return [...new Set(out.filter((x) => /^[A-Z]{2,6}$/.test(x)))];
92
+ }
93
+ export function deriveCode(path, taken = new Set()) {
94
+ const code = codeCandidates(path).find((c) => !taken.has(c));
95
+ if (!code)
96
+ throw new UsageExit(`no free capability code for ${path}. Add one under [ids.paths] in spec-ctx.toml`);
97
+ return code;
98
+ }
99
+ export function planAdopt(root, cfg) {
100
+ const mains = listSpecs(root, "openspec/specs");
101
+ const changesDir = join(root, "openspec/changes");
102
+ const deltas = [];
103
+ if (existsSync(changesDir)) {
104
+ for (const c of readdirSync(changesDir).sort(cmpStr)) {
105
+ if (c === "archive" || !statSync(join(changesDir, c)).isDirectory())
106
+ continue;
107
+ deltas.push(...listSpecs(root, `openspec/changes/${c}/specs`));
108
+ }
109
+ }
110
+ const capOf = (rel) => rel.replace(/^openspec\/(changes\/[^/]+\/)?specs\//, "").replace(/\/spec\.md$/, "");
111
+ const files = [...mains, ...deltas].map((r) => parse(root, r, capOf(r)));
112
+ const mainFiles = files.filter((f) => f.rel.startsWith("openspec/specs/"));
113
+ const deltaFiles = files.filter((f) => !f.rel.startsWith("openspec/specs/"));
114
+ // the code of each capability: config paths, then an ID already in its spec, then derived
115
+ const paths = cfg.ids.paths ?? {};
116
+ const codeOfPath = new Map();
117
+ for (const [code, p] of Object.entries(paths))
118
+ codeOfPath.set(p, code);
119
+ // on a collision the shorter path keeps the base code: user-auth keeps UA, upload-api takes UPA
120
+ const capList = [...new Set(files.map((f) => f.cap))].sort((a, b) => a.length - b.length || cmpStr(a, b));
121
+ const taken = new Set([...cfg.ids.caps, ...codeOfPath.values()]);
122
+ const caps = [];
123
+ for (const cap of capList) {
124
+ let code = codeOfPath.get(cap);
125
+ let derived = false;
126
+ if (!code) {
127
+ const existing = files.filter((f) => f.cap === cap).flatMap((f) => f.blocks).find((b) => b.kind === "req" && b.id);
128
+ if (existing)
129
+ code = HAS_REQ.exec(existing.id)[1];
130
+ }
131
+ if (!code) {
132
+ code = deriveCode(cap, taken);
133
+ derived = true;
134
+ taken.add(code);
135
+ }
136
+ caps.push({ code, path: cap, derived });
137
+ }
138
+ caps.sort((a, b) => cmpStr(a.path, b.path));
139
+ const codeFor = new Map(caps.map((c) => [c.path, c.code]));
140
+ // next free number per code: every ID in the tree, the archive, and the retired list
141
+ const used = new Map();
142
+ const bump = (code, n) => {
143
+ used.set(code, Math.max(used.get(code) ?? 0, n));
144
+ };
145
+ const allIds = [...retiredAll(root)];
146
+ for (const f of files)
147
+ for (const b of f.blocks)
148
+ if (b.id)
149
+ allIds.push(b.id);
150
+ const archive = join(root, "openspec/changes/archive");
151
+ if (existsSync(archive)) {
152
+ const walk = (abs) => {
153
+ for (const n of readdirSync(abs)) {
154
+ const p = join(abs, n);
155
+ if (statSync(p).isDirectory())
156
+ walk(p);
157
+ else if (n.endsWith(".md"))
158
+ for (const m of readFileSync(p, "utf8").matchAll(/\bREQ-([A-Z]{2,6})-(\d{3})\b/g))
159
+ allIds.push(m[0]);
160
+ }
161
+ };
162
+ walk(archive);
163
+ }
164
+ for (const id of allIds) {
165
+ const m = /^REQ-([A-Z]{2,6})-(\d{3})$/.exec(id);
166
+ if (m)
167
+ bump(m[1], Number(m[2]));
168
+ }
169
+ const nextReq = (code) => {
170
+ const n = (used.get(code) ?? 0) + 1;
171
+ if (n > 999)
172
+ throw new UsageExit(`no free REQ number for ${code}`);
173
+ used.set(code, n);
174
+ return `REQ-${code}-${String(n).padStart(3, "0")}`;
175
+ };
176
+ const scnUsed = new Map();
177
+ for (const f of files)
178
+ for (const b of f.blocks) {
179
+ if (b.kind === "scn" && b.id) {
180
+ const m = HAS_SCN.exec(b.id + " ");
181
+ const key = `REQ-${m[1]}-${m[2]}`;
182
+ scnUsed.set(key, Math.max(scnUsed.get(key) ?? 0, Number(m[3])));
183
+ }
184
+ }
185
+ const nextScn = (reqId) => {
186
+ const n = (scnUsed.get(reqId) ?? 0) + 1;
187
+ scnUsed.set(reqId, n);
188
+ return `SCN-${reqId.slice(4)}-${n}`;
189
+ };
190
+ const edits = [];
191
+ const unmatched = [];
192
+ const reqHeader = (id, name) => `### Requirement: ${id} ${name}`;
193
+ const scnHeader = (id, name) => `#### Scenario: ${id} ${name}`;
194
+ // main spec: requirement id by name, and scenario id by requirement and name
195
+ const mainReq = new Map();
196
+ const mainScn = new Map();
197
+ for (const f of mainFiles) {
198
+ const code = codeFor.get(f.cap);
199
+ const seen = new Set();
200
+ for (const b of f.blocks) {
201
+ if (b.kind !== "req")
202
+ continue;
203
+ if (seen.has(b.name))
204
+ throw new UsageExit(`${f.rel}:${b.line} repeats the header "${b.name}". Give one of the two requirements another name, then run adopt again`);
205
+ seen.add(b.name);
206
+ }
207
+ for (const b of f.blocks) {
208
+ if (b.kind === "req") {
209
+ const id = b.id ?? nextReq(code);
210
+ if (!b.id)
211
+ edits.push({ file: f.rel, line: b.line, old: b.raw, new: reqHeader(id, b.name) });
212
+ mainReq.set(`${f.cap}\u0000${b.name}`, id);
213
+ b.id = id;
214
+ }
215
+ else if (b.req?.id) {
216
+ const id = b.id ?? nextScn(b.req.id);
217
+ if (!b.id)
218
+ edits.push({ file: f.rel, line: b.line, old: b.raw, new: scnHeader(id, b.name) });
219
+ mainScn.set(`${b.req.id}\u0000${b.name}`, id);
220
+ b.id = id;
221
+ }
222
+ }
223
+ }
224
+ // deltas: the same ID as the main header, through text match or the RENAMED mapping
225
+ for (const f of deltaFiles) {
226
+ const code = codeFor.get(f.cap);
227
+ const renamedTo = new Map();
228
+ for (const r of f.renames) {
229
+ const id = r.from.id ?? mainReq.get(`${f.cap}\u0000${r.from.name}`) ?? null;
230
+ if (!id) {
231
+ unmatched.push({ file: f.rel, line: r.from.line, header: r.from.raw });
232
+ continue;
233
+ }
234
+ renamedTo.set(r.to.name, id);
235
+ if (!r.from.id)
236
+ edits.push({ file: f.rel, line: r.from.line, old: r.from.raw, new: `- FROM: \`${reqHeader(id, r.from.name)}\`` });
237
+ if (!r.to.id)
238
+ edits.push({ file: f.rel, line: r.to.line, old: r.to.raw, new: `- TO: \`${reqHeader(id, r.to.name)}\`` });
239
+ }
240
+ for (const b of f.blocks) {
241
+ if (b.kind === "req") {
242
+ if (b.id)
243
+ continue;
244
+ let id = null;
245
+ if (b.section === "ADDED")
246
+ id = nextReq(code);
247
+ else
248
+ id = renamedTo.get(b.name) ?? mainReq.get(`${f.cap}\u0000${b.name}`) ?? null;
249
+ if (!id) {
250
+ unmatched.push({ file: f.rel, line: b.line, header: b.raw });
251
+ continue;
252
+ }
253
+ edits.push({ file: f.rel, line: b.line, old: b.raw, new: reqHeader(id, b.name) });
254
+ b.id = id;
255
+ }
256
+ else {
257
+ if (b.id || !b.req?.id)
258
+ continue;
259
+ const id = mainScn.get(`${b.req.id}\u0000${b.name}`) ?? nextScn(b.req.id);
260
+ edits.push({ file: f.rel, line: b.line, old: b.raw, new: scnHeader(id, b.name) });
261
+ b.id = id;
262
+ }
263
+ }
264
+ }
265
+ edits.sort((a, b) => cmpStr(a.file, b.file) || a.line - b.line);
266
+ return { edits, caps, unmatched };
267
+ }
268
+ export function planLines(p) {
269
+ const out = p.edits.map((e) => `${e.file}:${e.line} ${e.old} -> ${e.new}`);
270
+ const derived = p.caps.filter((c) => c.derived);
271
+ if (derived.length)
272
+ out.push(...derived.map((c) => `cap: ${c.code} ${c.path}`));
273
+ for (const u of p.unmatched)
274
+ out.push(`unmatched: ${u.file}:${u.line} ${u.header}`);
275
+ return out;
276
+ }