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/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
|
-
#
|
|
1
|
+
# spec-ctx
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
+
}
|