spec-ctx 0.3.0 → 0.4.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/README.md +34 -3
- package/action.yml +8 -0
- package/dist/adopt/write.js +3 -2
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +23 -0
- package/dist/check/hints.js +6 -2
- package/dist/cli/args.js +3 -3
- package/dist/cli/commands.js +24 -10
- package/dist/cli/config.js +14 -0
- package/dist/cli/gate.js +133 -4
- package/dist/cli/help.js +9 -3
- package/dist/cli/workflow.js +4 -1
- package/dist/config.js +1 -1
- package/dist/gate/rerun.js +169 -0
- package/dist/graph/bundle.js +5 -0
- package/dist/handoff/handoff.js +7 -5
- package/dist/ids/ids.js +4 -0
- package/dist/index/scan.js +6 -2
- package/dist/init/init.js +13 -8
- package/dist/loop/fails.js +5 -6
- package/dist/loop/next.js +56 -25
- package/dist/util/time.js +1 -0
- package/dist/verify/run.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +7 -2
package/README.md
CHANGED
|
@@ -284,10 +284,41 @@ npx spec-ctx lint ste --baseline 0 --disable synonym-rotation README.md
|
|
|
284
284
|
|
|
285
285
|
The action runs `specctx gate`: `check --strict`, both markdown linters, `openspec validate --all --strict`, and the length check.
|
|
286
286
|
|
|
287
|
+
### Rerun
|
|
288
|
+
|
|
289
|
+
```yaml
|
|
290
|
+
on: pull_request
|
|
291
|
+
permissions:
|
|
292
|
+
contents: read
|
|
293
|
+
jobs:
|
|
294
|
+
rerun:
|
|
295
|
+
runs-on: ubuntu-latest
|
|
296
|
+
steps:
|
|
297
|
+
- uses: actions/checkout@v4
|
|
298
|
+
with: { fetch-depth: 0 }
|
|
299
|
+
- uses: sonqh/spec-ctx@v0
|
|
300
|
+
with:
|
|
301
|
+
rerun: true
|
|
302
|
+
base: origin/${{ github.base_ref }}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
With `rerun: true` the action runs `specctx gate --rerun` and no other step. It runs the Verify command of each task that the pull request closed and compares the exit code with the evidence. `base` is the ref for the merge base. Without `rerun`, the action ignores `base`.
|
|
306
|
+
|
|
307
|
+
On `push`, the default base gives a merge base equal to HEAD, so the rerun compares nothing. It prints one line and passes. Pass `base: ${{ github.event.before }}` on push. A push that creates a branch gives forty zeros. The rerun prints `rerun: base is the zero commit (a new branch), nothing to compare` and passes. After a force push the old commit may be missing from the clone. The rerun then prints `rerun: base <ref> is not in this clone. Fetch it or use fetch-depth: 0` and exits 1.
|
|
308
|
+
|
|
309
|
+
A Verify command is code from the pull request. The boundary:
|
|
310
|
+
|
|
311
|
+
- Use the `pull_request` and `push` events only. The action exits 1 on any other event.
|
|
312
|
+
- Never use `pull_request_target` or `workflow_run` with code from a pull request. The action exits 1 on `pull_request_target`.
|
|
313
|
+
- Set `permissions: contents: read`.
|
|
314
|
+
- Give the job no secrets.
|
|
315
|
+
- For fork pull requests, turn on the repository setting that asks for approval before a workflow runs.
|
|
316
|
+
- Check out with `fetch-depth: 0`. The merge base needs full history.
|
|
317
|
+
|
|
287
318
|
## Status
|
|
288
319
|
|
|
289
|
-
- Published: `spec-ctx@0.
|
|
290
|
-
- Archived: 001 to 025 and
|
|
320
|
+
- Published: `spec-ctx@0.4.0` on npm, with tags `v0.4.0` and `v0` and binaries on the GitHub release.
|
|
321
|
+
- Archived: 001 to 025, 027 to 034, and 036 to 042. No change is open. See `CHANGELOG.md` for each release.
|
|
291
322
|
- A version bump on `main` publishes the next release. See `docs/CONTRIBUTING.md`.
|
|
292
323
|
- Upstream: the ID scheme is proposed as a comment on [OpenSpec#1967](https://github.com/Fission-AI/OpenSpec/issues/1967#issuecomment-6414314730). The schema bundle is submitted to the community table in [OpenSpec#2157](https://github.com/Fission-AI/OpenSpec/issues/2157). No pull request until a maintainer asks.
|
|
293
324
|
|
|
@@ -297,7 +328,7 @@ The action runs `specctx gate`: `check --strict`, both markdown linters, `opensp
|
|
|
297
328
|
|
|
298
329
|
| change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
|
|
299
330
|
| --- | --- | --- | --- | --- | --- |
|
|
300
|
-
| total | 0.
|
|
331
|
+
| total | 0.0228 (n=116) | 0.93 (204/220) | 56 (of 106 reopens) | 61 (n=116) | 0 (0 baselined + 0 new) |
|
|
301
332
|
|
|
302
333
|
- `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better.
|
|
303
334
|
- `evidence_ratio`: done tasks with a passed Verify command, divided by all done tasks. The rest have a manual note. Higher is better.
|
package/action.yml
CHANGED
|
@@ -16,6 +16,12 @@ inputs:
|
|
|
16
16
|
specctx:
|
|
17
17
|
description: Command that runs specctx. Empty means npx with the version input.
|
|
18
18
|
default: ""
|
|
19
|
+
rerun:
|
|
20
|
+
description: Run only gate --rerun. It reruns the Verify command of each task that the pull request closed. Use it on pull_request or push only. On push, pass the commit before the push (github.event.before) as base.
|
|
21
|
+
default: "false"
|
|
22
|
+
base:
|
|
23
|
+
description: With rerun, the ref for the merge base, such as origin/main. On push, use the commit before the push (github.event.before). Empty uses the default of spec-ctx.toml. Without rerun, the action ignores it.
|
|
24
|
+
default: ""
|
|
19
25
|
runs:
|
|
20
26
|
using: composite
|
|
21
27
|
steps:
|
|
@@ -30,4 +36,6 @@ runs:
|
|
|
30
36
|
INPUT_STRICT: ${{ inputs.strict }}
|
|
31
37
|
INPUT_MARKDOWN: ${{ inputs.markdown }}
|
|
32
38
|
INPUT_SPECCTX: ${{ inputs.specctx }}
|
|
39
|
+
INPUT_RERUN: ${{ inputs.rerun }}
|
|
40
|
+
INPUT_BASE: ${{ inputs.base }}
|
|
33
41
|
SPECCTX_ACTION_PATH: ${{ github.action_path }}
|
package/dist/adopt/write.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { UsageExit } from "../cli/errors.js";
|
|
5
|
-
import { CONFIG_FILE
|
|
5
|
+
import { CONFIG_FILE } from "../config.js";
|
|
6
|
+
import { loadConfigOrExit } from "../cli/config.js";
|
|
6
7
|
import { dumps } from "../util/json.js";
|
|
7
8
|
import { configText, withCaps } from "./config-write.js";
|
|
8
9
|
export const REPORT_FILE = "adopt-report.md";
|
|
9
10
|
async function findings(root) {
|
|
10
11
|
const { buildIndex } = await import("../index/build.js");
|
|
11
12
|
const { check } = await import("../check/check.js");
|
|
12
|
-
const cfg =
|
|
13
|
+
const cfg = loadConfigOrExit(root);
|
|
13
14
|
const data = buildIndex(root, cfg);
|
|
14
15
|
return new Set(check(root, cfg, data, false).map((f) => `${f.file}:${f.line} ${f.rule} ${f.id}`));
|
|
15
16
|
}
|
package/dist/assets.generated.js
CHANGED
|
@@ -10,5 +10,5 @@ export const ASSETS = {
|
|
|
10
10
|
"skills/specctx-bootstrap/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtYm9vdHN0cmFwCmRlc2NyaXB0aW9uOiBTdGFydCBhIHNlc3Npb24gaW4gYW4gT3BlblNwZWMgcHJvamVjdCB0aGF0IHVzZXMgc3BlYy1jdHguIFVzZSBhdCB0aGUgc3RhcnQgb2YgZXZlcnkgc2Vzc2lvbiwgYW5kIG9uICJzdGFydCIsICJib290c3RyYXAiLCBvciAid2hhdCBzaG91bGQgSSB3b3JrIG9uIi4gTG9hZHMgb25seSB0aGUgc3BlYyB0ZXh0IHRoYXQgdGhlIG5leHQgdGFzayBuZWVkcy4KLS0tCgpUaGlzIHNraWxsIGlzIG5vdyBgc3BlY2N0eC1ydW5gLiBSdW4gaXQuCg==",
|
|
11
11
|
"skills/specctx-done/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtZG9uZQpkZXNjcmlwdGlvbjogQ2xvc2UgYSB0YXNrIGluIGFuIE9wZW5TcGVjIHByb2plY3QgdGhhdCB1c2VzIHNwZWMtY3R4LiBSdW5zIHRoZSBWZXJpZnkgY29tbWFuZCwgd3JpdGVzIGV2aWRlbmNlLCBzZXRzIHRoZSBjaGVja2JveCwgd3JpdGVzIHRoZSBoYW5kb2ZmLCBhbmQgc2hvd3MgdGhlIG1ldHJpY3MuIFVzZSB3aGVuIHRoZSB3b3JrIG9mIGEgdGFzayBpcyBjb21wbGV0ZSwgb3Igb24gImRvbmUiLCAiY2xvc2UgdGhlIHRhc2siLCBhbmQgImVuZCB0aGUgc2Vzc2lvbiIuCi0tLQoKVGhpcyBza2lsbCBpcyBub3cgYHNwZWNjdHgtcnVuYC4gUnVuIGl0Lgo=",
|
|
12
12
|
"skills/specctx-next/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtbmV4dApkZXNjcmlwdGlvbjogUGljayBhbmQgc3RhcnQgdGhlIG5leHQgdGFzayBpbiB0aGUgbWlkZGxlIG9mIGEgc2Vzc2lvbiBpbiBhbiBPcGVuU3BlYyBwcm9qZWN0IHRoYXQgdXNlcyBzcGVjLWN0eC4gVXNlIGFmdGVyIHlvdSBjbG9zZSBhIHRhc2ssIG9yIG9uICJuZXh0IHRhc2siIGFuZCAid2hhdCBpcyBuZXh0Ii4KLS0tCgpUaGlzIHNraWxsIGlzIG5vdyBgc3BlY2N0eC1ydW5gLiBSdW4gaXQuCg==",
|
|
13
|
-
"skills/specctx-run/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLiBXaGVuIHRoYXQga2V5IGlzIGFic2VudCwgYG9wZW5zcGVjYCBpcyBgbnB4IC15IEBmaXNzaW9uLWFpL29wZW5zcGVjQGxhdGVzdGAuCgojIyBUaGUgbG9vcAoKMS4gUnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYC4gUmVhZCBgYWN0aW9uYCwgYGNoYW5nZWAgKGA8Yz5gKSwgYHRhc2tgIChgPHQ+YCksIGBjb21tYW5kYCwgYHJlYXNvbmAsIGFuZCBgaHVtYW5gLgoyLiBBY3Qgb24gYGFjdGlvbmAgYXMgdGhlIHRhYmxlIGJlbG93IHNheXMuCjMuIFJ1biBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KNC4gUmVwZWF0IGZyb20gc3RlcCAxIHVudGlsIGBhY3Rpb25gIGlzIGBzdG9wYCBvciBgaHVtYW5gIGlzIHRydWUuCgojIyBBY3Rpb25zCgotIGBwbGFuYDogdGFrZSB0aGUgY2hhbmdlIG5hbWUgZnJvbSB0aGUgcmVxdWVzdCBvZiB0aGUgdXNlciBpbiBrZWJhYiBjYXNlLiBUaGF0IG5hbWUgaXMgYDxjPmAuIEZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciBgPGM+YC4gUmVhZCBleGlzdGluZyByZXF1aXJlbWVudHMgd2l0aCBgc3BlY2N0eCBidW5kbGUgPGlkPmAgb3IgYHNwZWNjdHggcXVlcnlgLCBuZXZlciBgb3BlbnNwZWMgc2hvd2AuIFRha2UgZXZlcnkgSUQgZnJvbSBgc3BlY2N0eCBpZCBuZXcgUkVRIDxDQVA+
|
|
13
|
+
"skills/specctx-run/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLiBXaGVuIHRoYXQga2V5IGlzIGFic2VudCwgYG9wZW5zcGVjYCBpcyBgbnB4IC15IEBmaXNzaW9uLWFpL29wZW5zcGVjQGxhdGVzdGAuCgojIyBUaGUgbG9vcAoKMS4gUnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYC4gUmVhZCBgYWN0aW9uYCwgYGNoYW5nZWAgKGA8Yz5gKSwgYHRhc2tgIChgPHQ+YCksIGBjb21tYW5kYCwgYHJlYXNvbmAsIGFuZCBgaHVtYW5gLgoyLiBBY3Qgb24gYGFjdGlvbmAgYXMgdGhlIHRhYmxlIGJlbG93IHNheXMuCjMuIFJ1biBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KNC4gUmVwZWF0IGZyb20gc3RlcCAxIHVudGlsIGBhY3Rpb25gIGlzIGBzdG9wYCBvciBgaHVtYW5gIGlzIHRydWUuCgojIyBBY3Rpb25zCgotIGBwbGFuYDogdGFrZSB0aGUgY2hhbmdlIG5hbWUgZnJvbSB0aGUgcmVxdWVzdCBvZiB0aGUgdXNlciBpbiBrZWJhYiBjYXNlLiBUaGF0IG5hbWUgaXMgYDxjPmAuIEZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciBgPGM+YC4gUmVhZCBleGlzdGluZyByZXF1aXJlbWVudHMgd2l0aCBgc3BlY2N0eCBidW5kbGUgPGlkPmAgb3IgYHNwZWNjdHggcXVlcnlgLCBuZXZlciBgb3BlbnNwZWMgc2hvd2AuIFRha2UgZXZlcnkgSUQgZnJvbSBgc3BlY2N0eCBpZCBuZXcgUkVRIDxDQVA+YCBhbmQgYHNwZWNjdHggaWQgbmV3IFNDTiA8Q0FQPiAtLWZvciA8UkVRPmAuIFdoZW4gdGhlIGNoYW5nZSBuZWVkcyBzZXZlcmFsIElEcyBvZiBvbmUga2luZCwgcnVuIGBzcGVjY3R4IGlkIG5ldyAtLWNvdW50IDxuPmAgb25jZS4gR2l2ZSBldmVyeSB0YXNrIGEgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3Bhbi4gVGhlbiBydW4gYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgIGFuZCBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gVGhlIGdhdGUgcnVucyB0aGUgT3BlblNwZWMgdmFsaWRhdGUgYW5kIHRoZSBzcGVjLWxlbmd0aCBjaGVjay4gRml4IGV2ZXJ5IGZpbmRpbmcuIFRoZW4gcnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYCBhbmQgcnVuIHRoZSBjb21tYW5kIGl0IHByaW50cy4gVGhpcyBpcyB0aGUgcGxhbiBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB3aXRoIGBodW1hbjogdHJ1ZWAgdW50aWwgdGhlIHVzZXIgcmVzb2x2ZXMgdGhlIHF1ZXN0aW9uLgotIGBjaXRlYDogcnVuIGBzcGVjY3R4IHF1ZXJ5IC0tY2hhbmdlIDxjPiAtLWtpbmQgcmVxYC4gQWRkIHRoZSBgW1JFUS0uLi5dYCBjaXRhdGlvbiBhbmQgYSBgVmVyaWZ5OmAgY29kZSBzcGFuIHRvIHRoZSB0YXNrIGxpbmUgbmFtZWQgaW4gYHRhc2tgLiBSdW4gYHNwZWNjdHggY2hlY2tgLiBDb250aW51ZS4KLSBgc3RhcnRgOiBydW4gYHNwZWNjdHggdGFzayBzdGFydCA8dD5gLiBSZWFkIHRoZSBidW5kbGUgaXQgcHJpbnRzLiBDb250aW51ZS4KLSBgd29ya2A6IGltcGxlbWVudCB0aGUgdGFzay4gVGFnIGNvZGUgd2l0aCBgLy8gUkVRLS4uLmAuIFRhZyB0ZXN0cyB3aXRoIGAvLyBDb3ZlcnM6IFJFUS0uLi5gLiBUaGVuIHJ1biBgc3BlY2N0eCB0YXNrIGRvbmUgPHQ+YC4KLSBgZml4YDogcmVhZCBgcmVhc29uYC4gSXQgaG9sZHMgdGhlIHRhaWwgb2YgdGhlIGZhaWxlZCBWZXJpZnkgb3V0cHV0LiBGaXggdGhlIGNhdXNlLiBSdW4gYHNwZWNjdHggdGFzayBkb25lIDx0PmAgYWdhaW4uCi0gYHJlcGFpcmA6IHJ1biB0aGUgY29tbWFuZCBpbiB0aGUgYGNvbW1hbmRgIGZpZWxkLiBJdCBydW5zIFZlcmlmeSBhZ2FpbiBmb3IgYSB0aWNrZWQgdGFzayB3aG9zZSBldmlkZW5jZSBpcyBhYnNlbnQgb3Igc3RhbGUuCi0gYGdhdGVgOiBydW4gYHNwZWNjdHggZ2F0ZSAtLXN0cmljdGAuIEZpeCBldmVyeSBmaW5kaW5nLiBGb3IgYSBgc3RhbGUtZXZpZGVuY2VgIGZpbmRpbmcsIGdvIGJhY2sgdG8gc3RlcCAxLiBgbmV4dGAgcmV0dXJucyBgcmVwYWlyYCBhbmQgaXRzIGNvbW1hbmQgcmVwYWlycyBldmVyeSBzdGFsZSB0YXNrLiBSdW4gdGhlIGdhdGUgYWdhaW4gdW50aWwgaXQgZXhpdHMgMC4KLSBgYXJjaGl2ZWA6IHdoZW4gYGh1bWFuYCBpcyB0cnVlLCBydW4gYHNwZWNjdHggaGFuZG9mZiBhc2sgLS1jaGFuZ2UgPGM+ICJhcmNoaXZlIDxjPj8iYCBhbmQgc3RvcC4gVGhpcyBpcyB0aGUgYXJjaGl2ZSBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB1bnRpbCB0aGUgdXNlciByZXNvbHZlcyB0aGUgcXVlc3Rpb24uIFdoZW4gYGh1bWFuYCBpcyBmYWxzZSwgdGhlIHVzZXIgYXBwcm92ZWQgb3IgYFtsb29wXSBhcmNoaXZlYCBpcyBgYXV0b2AuIFRoZW4gcnVuIGVhY2ggc3RlcCB1bmRlciBgb3BlcmF0aW9ucy5hcmNoaXZlLmd1aWRhbmNlYCBpbiBgb3BlbnNwZWMvY29uZmlnLnlhbWxgLiBUaGVuIHJ1biB0aGUgY29tbWFuZCBpbiB0aGUgYGNvbW1hbmRgIGZpZWxkLiBUaGVuIHJ1biBgb3BlbnNwZWMgdmFsaWRhdGUgLS1hbGwgLS1zdHJpY3RgLCBgc3BlY2N0eCBjaGVjayAtLXN0cmljdGAsIGFuZCBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KLSBgYXNrYDogcHJpbnQgYHJlYXNvbmAuIEl0IGlzIHRoZSBvcGVuIHF1ZXN0aW9uLiBTdG9wLiBUaGUgdXNlciBhbnN3ZXJzIHdpdGggYHNwZWNjdHggaGFuZG9mZiByZXNvbHZlIDxuPiAiPGFuc3dlcj4iYC4KLSBgc3RvcGA6IHByaW50IGByZWFzb25gLiBTdG9wLgoKIyMgUnVsZXMKCi0gVGhpcyBza2lsbCBvdmVycmlkZXMgdHdvIHN0ZXBzIG9mIHRoZSB1cHN0cmVhbSBgb3BlbnNwZWMtcHJvcG9zZWAgc2tpbGwuIEl0IHJlYWRzIHNwZWNzIHdpdGggYHNwZWNjdHggYnVuZGxlYCBvciBgc3BlY2N0eCBxdWVyeWAsIG5vdCBgb3BlbnNwZWMgc2hvd2AuIEl0IG5ldmVyIHJ1bnMgdGhlIGNsb3NpbmcgYC9vcHN4OmFwcGx5YC4KLSBOZXZlciB0aWNrIGEgY2hlY2tib3ggYnkgaGFuZC4gYHNwZWNjdHggdGFzayBkb25lYCBpcyB0aGUgb25seSB3YXkgYSB0YXNrIGNsb3Nlcy4KLSBOZXZlciB1c2UgYC0tbWFudWFsYCBvbiBhIHRhc2sgd2l0aCBhIFZlcmlmeSBjb21tYW5kLgotIE5ldmVyIGNhbGwgYC9vcHN4OmFwcGx5YC4gVGhpcyBza2lsbCByZXBsYWNlcyBpdC4KLSBUbyB3cml0ZSBhIE1PRElGSUVEIGJsb2NrLCBydW4gYHNwZWNjdHggYnVuZGxlIDxSRVE+IC0tYmxvY2tgLiBJdCBwcmludHMgdGhlIGJsb2NrIGFzIGl0IHNpdHMgaW4gdGhlIHNwZWMgZmlsZS4gQSBNT0RJRklFRCBibG9jayBrZWVwcyBldmVyeSBzY2VuYXJpbyB0aXRsZSB1bmNoYW5nZWQuCi0gYGdhdGVgIGNvdW50cyB0aGUgUmVmcyBsaW5lIGluIHRoZSA1MDAgY2hhcmFjdGVycyBvZiBhIHJlcXVpcmVtZW50LgotIFRoZSBDSEFOR0VMT0cgZW50cnkgZ29lcyB1bmRlciBgIyMgVW5yZWxlYXNlZGAsIG5ldmVyIHVuZGVyIGEgcmVsZWFzZWQgdmVyc2lvbi4KLSBBZnRlciBhbiBlZGl0IHVuZGVyIGBza2lsbHMvYCwgcnVuIGBidW4gcnVuIGJ1aWxkICYmIG5vZGUgZGlzdC9jbGkuanMgaW5pdCAtLXNraWxscyAtLWZvcmNlYCB0byByZWZyZXNoIHRoZSBjb3BpZXMuCi0gTmV2ZXIgcmVhZCBgb3BlbnNwZWMvc3BlY3MvYCBvciBgb3BlbnNwZWMvY2hhbmdlcy8qL3NwZWNzL2AgZGlyZWN0bHkuIFVzZSB0aGUgYnVuZGxlIGZyb20gYHRhc2sgc3RhcnRgIG9yIGBzcGVjY3R4IGJ1bmRsZSA8aWQ+YC4KLSBTdG9wIG9uIGFueSBgYWN0aW9uYCBub3QgaW4gdGhlIGxpc3QgYWJvdmUuIFByaW50IHRoZSBvYmplY3QgYW5kIGFzayB0aGUgdXNlci4KLSBLZWVwIG5vIHN0YXRlIGluIHlvdXIgY29udGV4dC4gQSBuZXcgc2Vzc2lvbiBzdGFydHMgdGhlIHNhbWUgbG9vcCBhZ2FpbiB3aXRoIHN0ZXAgMS4K",
|
|
14
14
|
};
|
package/dist/check/check.js
CHANGED
|
@@ -190,6 +190,29 @@ export function check(root, cfg, data, strict) {
|
|
|
190
190
|
if (e.kind === "scn" && !named.has(e.id))
|
|
191
191
|
findings.push([e.file, e.line, "scenario-untested", e.id]);
|
|
192
192
|
}
|
|
193
|
+
// REQ-CHK-008 a delta scenario needs a test once its change has no open task (038/D1, D2)
|
|
194
|
+
const tasksOf = new Map();
|
|
195
|
+
for (const e of entries) {
|
|
196
|
+
if (e.kind !== "task" || e.change === "main" || e.file.includes("/archive/"))
|
|
197
|
+
continue;
|
|
198
|
+
const t = tasksOf.get(e.change) ?? { open: 0, all: 0 };
|
|
199
|
+
t.all++;
|
|
200
|
+
if (e.status !== "done")
|
|
201
|
+
t.open++;
|
|
202
|
+
tasksOf.set(e.change, t);
|
|
203
|
+
}
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
if ((e.kind !== "scn" && e.kind !== "req") || e.change === "main" || (e.status !== "active" && e.status !== "modified"))
|
|
206
|
+
continue;
|
|
207
|
+
const t = tasksOf.get(e.change);
|
|
208
|
+
if (!t || t.open > 0 || pending.has(e.id))
|
|
209
|
+
continue;
|
|
210
|
+
// REQ-CHK-009 a delta requirement needs a Covers tag on the same terms (039/D2)
|
|
211
|
+
if (e.kind === "req" && !covered.has(e.id))
|
|
212
|
+
findings.push([e.file, e.line, "uncovered", e.id]);
|
|
213
|
+
if (e.kind === "scn" && !named.has(e.id))
|
|
214
|
+
findings.push([e.file, e.line, "scenario-untested", e.id]);
|
|
215
|
+
}
|
|
193
216
|
findings.push(...evidenceFindings(root, entries, listEvidence(root)));
|
|
194
217
|
}
|
|
195
218
|
const uniq = new Map();
|
package/dist/check/hints.js
CHANGED
|
@@ -9,13 +9,17 @@ export const RULES = {
|
|
|
9
9
|
"duplicate-id": { level: "hard", hint: "Give one of the two entries a new ID from specctx id new." },
|
|
10
10
|
"duplicate-change-key": { level: "hard", hint: "Rename one change folder, so that each key names one change." },
|
|
11
11
|
"reused-id": { level: "hard", hint: "The ID is retired. Get a new ID from specctx id new." },
|
|
12
|
-
|
|
12
|
+
// REQ-CHK-007 the hint names the string literal cause and the fix (033/D3)
|
|
13
|
+
"dangling-ref": {
|
|
14
|
+
level: "hard",
|
|
15
|
+
hint: "No entry has this ID. Fix the ID or remove the link. A tag inside a string literal or fixture text also reads as a link. Build that text at run time, or move the file under a path in [scan] ignore.",
|
|
16
|
+
},
|
|
13
17
|
"unknown-task-req": { level: "hard", hint: "The task cites an ID that the index does not hold. Fix the ID on the task line." },
|
|
14
18
|
"req-without-scn": { level: "hard", hint: "Add a scenario under the requirement." },
|
|
15
19
|
"code-tag-no-canonical": { level: "hard", hint: "The tag names an archived requirement. Tag the current requirement." },
|
|
16
20
|
"unknown-cap": { level: "hard", hint: "Add the code to caps under [ids] in spec-ctx.toml." },
|
|
17
21
|
uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
|
|
18
|
-
"scenario-untested": { level: "hard", hint: "Name the scenario ID in a test." },
|
|
22
|
+
"scenario-untested": { level: "hard", hint: "Name the scenario ID in a test with a // Covers: <SCN> tag." },
|
|
19
23
|
"done-without-evidence": { level: "hard", hint: "Run specctx task done. It runs the Verify command and writes the evidence." },
|
|
20
24
|
// REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
|
|
21
25
|
"stale-evidence": { level: "hard", hint: "Run specctx task reopen <task> <why>, then specctx task done <task>." },
|
package/dist/cli/args.js
CHANGED
|
@@ -7,10 +7,10 @@ export const COMMAND_FLAGS = {
|
|
|
7
7
|
next: { change: "string" },
|
|
8
8
|
query: { kind: "string", change: "string", all: "bool", limit: "int" },
|
|
9
9
|
related: { depth: "int", all: "bool" },
|
|
10
|
-
bundle: { budget: "int", depth: "int", all: "bool" },
|
|
10
|
+
bundle: { budget: "int", depth: "int", all: "bool", block: "bool" },
|
|
11
11
|
check: { strict: "bool" },
|
|
12
12
|
stats: { change: "string", write: "bool" },
|
|
13
|
-
gate: { strict: "bool", markdown: "string" },
|
|
13
|
+
gate: { strict: "bool", markdown: "string", rerun: "bool", base: "string" },
|
|
14
14
|
"id new": { for: "string", count: "int" },
|
|
15
15
|
"id retire": {},
|
|
16
16
|
"task next": { change: "string" },
|
|
@@ -19,7 +19,7 @@ export const COMMAND_FLAGS = {
|
|
|
19
19
|
"task done": { manual: "string" },
|
|
20
20
|
"task reopen": {},
|
|
21
21
|
"handoff write": {},
|
|
22
|
-
"handoff ask": { change: "string" },
|
|
22
|
+
"handoff ask": { change: "string", plan: "bool" },
|
|
23
23
|
"handoff resolve": {},
|
|
24
24
|
adopt: { "dry-run": "bool" },
|
|
25
25
|
"baseline write": {},
|
package/dist/cli/commands.js
CHANGED
|
@@ -73,16 +73,8 @@ export async function run(p, io) {
|
|
|
73
73
|
return init(p, io);
|
|
74
74
|
const root = findRoot(io.cwd, f.root);
|
|
75
75
|
requireOpenSpec(root);
|
|
76
|
-
const {
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
cfg = loadConfig(root);
|
|
80
|
-
}
|
|
81
|
-
catch (e) {
|
|
82
|
-
if (e instanceof ConfigError)
|
|
83
|
-
throw new UsageExit(e.message);
|
|
84
|
-
throw e;
|
|
85
|
-
}
|
|
76
|
+
const { loadConfigOrExit } = await import("./config.js");
|
|
77
|
+
const cfg = loadConfigOrExit(root);
|
|
86
78
|
const { dumps } = await import("../util/json.js");
|
|
87
79
|
const print = (v) => io.out(dumps(v) + "\n");
|
|
88
80
|
if (cmd === "build") {
|
|
@@ -147,6 +139,13 @@ export async function run(p, io) {
|
|
|
147
139
|
if (cmd === "gate") {
|
|
148
140
|
if (p.positional.length)
|
|
149
141
|
throw new UsageExit("gate takes no arguments");
|
|
142
|
+
// REQ-CLI-009 the rerun mode runs only the rerun step
|
|
143
|
+
if (f.base !== undefined && !f.rerun)
|
|
144
|
+
throw new UsageExit("--base needs --rerun");
|
|
145
|
+
if (f.rerun) {
|
|
146
|
+
const { runRerun } = await import("./gate.js");
|
|
147
|
+
return runRerun(root, cfg, { strict: !!f.strict, base: f.base || cfg.gate.base, json }, io);
|
|
148
|
+
}
|
|
150
149
|
const { runGate } = await import("./gate.js");
|
|
151
150
|
const strict = !!f.strict;
|
|
152
151
|
return runGate(root, {
|
|
@@ -262,6 +261,21 @@ export async function run(p, io) {
|
|
|
262
261
|
io.err(`unknown id(s): ${unknown.join(", ")}\n`);
|
|
263
262
|
return 2;
|
|
264
263
|
}
|
|
264
|
+
// REQ-QRY-004 --block prints the requirement blocks verbatim and nothing else (034/D2)
|
|
265
|
+
if (f.block) {
|
|
266
|
+
const notReq = p.positional.filter((i) => ids.get(i).kind !== "req");
|
|
267
|
+
if (notReq.length) {
|
|
268
|
+
io.err(`not a requirement: ${notReq.join(", ")}\n`);
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
const { reqBlocks } = await import("../graph/bundle.js");
|
|
272
|
+
const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
|
|
273
|
+
if (json)
|
|
274
|
+
print({ text, omitted: [] });
|
|
275
|
+
else
|
|
276
|
+
io.out(text);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
265
279
|
const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
|
|
266
280
|
if (json)
|
|
267
281
|
print(b);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// REQ-DIST-009 the one function that every command uses to read spec-ctx.toml (033/D4).
|
|
2
|
+
import { ConfigError, loadConfig } from "../config.js";
|
|
3
|
+
import { UsageExit } from "./errors.js";
|
|
4
|
+
/** Read the config. A config error becomes a usage error: one line on stderr and exit 2, never a stack. */
|
|
5
|
+
export function loadConfigOrExit(root) {
|
|
6
|
+
try {
|
|
7
|
+
return loadConfig(root);
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
if (e instanceof ConfigError)
|
|
11
|
+
throw new UsageExit(e.message);
|
|
12
|
+
throw e;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/cli/gate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// REQ-CLI-004 specctx gate: check, the baseline against the merge base, both linters, openspec validate, spec length. Stops at the first failure.
|
|
1
|
+
// REQ-LOOP-011 REQ-STAT-005 REQ-CLI-009 REQ-CLI-004 specctx gate: check, the baseline against the merge base, both linters, openspec validate, spec length. Stops at the first failure.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
5
|
import { anyMatch, globRe } from "../util/glob.js";
|
|
6
6
|
import { cmpStr } from "../util/pystr.js";
|
|
7
7
|
const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
|
|
@@ -45,7 +45,7 @@ export const GATE_STEPS = ["check", "baseline", "lint tells", "lint ste", "valid
|
|
|
45
45
|
/** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
|
|
46
46
|
export function appendGateLog(root, line) {
|
|
47
47
|
mkdirSync(join(root, ".ctx"), { recursive: true });
|
|
48
|
-
const sorted = { at: line.at, duration_s: line.duration_s, failed_step: line.failed_step, ok: line.ok, steps: line.steps };
|
|
48
|
+
const sorted = { at: line.at, duration_s: line.duration_s, failed_step: line.failed_step, ok: line.ok, steps: line.steps, strict: line.strict };
|
|
49
49
|
appendFileSync(join(root, GATE_LOG), JSON.stringify(sorted) + "\n");
|
|
50
50
|
}
|
|
51
51
|
export async function runGate(root, o, io) {
|
|
@@ -53,7 +53,7 @@ export async function runGate(root, o, io) {
|
|
|
53
53
|
const ran = [];
|
|
54
54
|
const r = await gateSteps(root, o, io, ran);
|
|
55
55
|
const { isoNow } = await import("../util/time.js");
|
|
56
|
-
appendGateLog(root, { at: isoNow(), ok: r === null, failed_step: r, duration_s: Math.round((Date.now() - start) / 1000), steps: [...ran] });
|
|
56
|
+
appendGateLog(root, { at: isoNow(), ok: r === null, failed_step: r, duration_s: Math.round((Date.now() - start) / 1000), steps: [...ran], strict: o.strict });
|
|
57
57
|
return r === null ? 0 : 1;
|
|
58
58
|
}
|
|
59
59
|
/** The gate steps. Returns null when every step passed, else the name of the failed step. */
|
|
@@ -203,3 +203,132 @@ export function longRequirements(root) {
|
|
|
203
203
|
}
|
|
204
204
|
return out;
|
|
205
205
|
}
|
|
206
|
+
/** REQ-CLI-009 the recursion guard: a rerun that a rerun started stops at once. */
|
|
207
|
+
export const RERUN_GUARD = "SPECCTX_RERUN";
|
|
208
|
+
/**
|
|
209
|
+
* REQ-CLI-009 REQ-VER-005 REQ-VER-006 REQ-VER-007 `gate --rerun`. It runs the Verify command of each task that the range
|
|
210
|
+
* closed, one group at a time, and compares the exit code with the evidence. It writes no evidence.
|
|
211
|
+
*/
|
|
212
|
+
export async function runRerun(root, cfg, o, io) {
|
|
213
|
+
if (process.env[RERUN_GUARD]) {
|
|
214
|
+
io.out(o.json ? JSON.stringify({ ok: true, skipped: `${RERUN_GUARD} is set` }) + "\n" : `rerun: skipped, ${RERUN_GUARD} is set\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
const { dumps } = await import("../util/json.js");
|
|
219
|
+
const { loadIndex } = await import("../index/build.js");
|
|
220
|
+
const { scanner } = await import("../index/scan.js");
|
|
221
|
+
const { closedTasks, compare, groupRuns, judge, formatMismatch } = await import("../gate/rerun.js");
|
|
222
|
+
const { isWeakVerify, parseVerify } = await import("../verify/parse.js");
|
|
223
|
+
const { evidenceRel } = await import("../tasks/tasks.js");
|
|
224
|
+
const { runVerify } = await import("../verify/run.js");
|
|
225
|
+
const { isoNow } = await import("../util/time.js");
|
|
226
|
+
const finish = (ok) => {
|
|
227
|
+
appendGateLog(root, { at: isoNow(), ok, failed_step: ok ? null : "rerun", duration_s: Math.round((Date.now() - start) / 1000), steps: ["rerun"], strict: o.strict });
|
|
228
|
+
return ok ? 0 : 1;
|
|
229
|
+
};
|
|
230
|
+
// REQ-VER-013 a zero base passes and a base that this clone lacks fails, before any merge base (042/D3)
|
|
231
|
+
const { baseProblem } = await import("../gate/rerun.js");
|
|
232
|
+
const bp = baseProblem(root, o.base);
|
|
233
|
+
if (bp) {
|
|
234
|
+
io.out(o.json ? dumps({ base: o.base, ...(bp.ok ? { warning: bp.line } : { error: bp.line }), merge_base: null, ok: bp.ok, tasks: [] }) + "\n" : bp.line + "\n");
|
|
235
|
+
return finish(bp.ok);
|
|
236
|
+
}
|
|
237
|
+
const { data } = loadIndex(root, cfg);
|
|
238
|
+
const closed = closedTasks(root, o.base, data.entries, scanner(root, cfg).taskRe);
|
|
239
|
+
if (!closed.ok) {
|
|
240
|
+
io.out(o.json ? dumps({ base: o.base, error: closed.line, merge_base: null, ok: false, tasks: [] }) + "\n" : closed.line + "\n");
|
|
241
|
+
return finish(false);
|
|
242
|
+
}
|
|
243
|
+
// REQ-VER-010 a merge base at HEAD compares nothing. Say so and pass (041/D4).
|
|
244
|
+
const { headSha, HEAD_BASE_LINE } = await import("../gate/rerun.js");
|
|
245
|
+
if (closed.mergeBase === headSha(root)) {
|
|
246
|
+
io.out(o.json ? dumps({ base: o.base, merge_base: closed.mergeBase, ok: true, tasks: [], warning: HEAD_BASE_LINE }) + "\n" : HEAD_BASE_LINE + "\n");
|
|
247
|
+
return finish(true);
|
|
248
|
+
}
|
|
249
|
+
const lines = [];
|
|
250
|
+
const pending = [];
|
|
251
|
+
for (const e of closed.tasks) {
|
|
252
|
+
const evPath = evidenceRel(e);
|
|
253
|
+
let ev = null;
|
|
254
|
+
try {
|
|
255
|
+
const j = JSON.parse(readFileSync(join(root, evPath), "utf8"));
|
|
256
|
+
ev = { kind: j.kind, command: j.verify?.command ?? null, exit: j.verify?.exit ?? null, ...(j.cwd !== undefined ? { cwd: j.cwd } : {}) };
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
ev = null;
|
|
260
|
+
}
|
|
261
|
+
const tl = readFileSync(join(root, e.file), "utf8").split(/\r?\n/)[e.line - 1] ?? "";
|
|
262
|
+
const spec = parseVerify(tl);
|
|
263
|
+
const v = compare({ command: spec.command, ...(spec.cwd !== undefined ? { cwd: spec.cwd } : {}) }, ev, o.strict, isWeakVerify);
|
|
264
|
+
const base = { id: e.id, status: "", kind: "ok", file: e.file, line: e.line, evidence: evPath };
|
|
265
|
+
if (v.act === "warn")
|
|
266
|
+
lines.push({ ...base, status: "warn manual", kind: "warn" });
|
|
267
|
+
else if (v.act === "fail")
|
|
268
|
+
lines.push({ ...base, status: v.kind, kind: "fail", ...(v.detail ? { detail: v.detail } : {}) });
|
|
269
|
+
else {
|
|
270
|
+
const l = { ...base, status: "ok" };
|
|
271
|
+
lines.push(l);
|
|
272
|
+
pending.push({ id: e.id, command: v.command, cwd: v.cwd, expected: v.expected, line: l });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// REQ-VER-007 one run per frozen command and cwd. Groups run one at a time.
|
|
276
|
+
const env = { ...process.env, CI: "1", [RERUN_GUARD]: "1" };
|
|
277
|
+
for (const g of groupRuns(pending)) {
|
|
278
|
+
const dir = resolve(root, g.cwd);
|
|
279
|
+
let out;
|
|
280
|
+
if (dir !== resolve(root) && !dir.startsWith(resolve(root) + sep))
|
|
281
|
+
out = { exit: null, timedOut: false, output: `cwd ${g.cwd} is outside the project root\n` };
|
|
282
|
+
// REQ-VER-009 a missing cwd and a spawn error are a mismatch of the group, not a crash (041/D3)
|
|
283
|
+
else if (!existsSync(dir))
|
|
284
|
+
out = { exit: null, timedOut: false, output: `cwd does not exist: ${g.cwd}\n` };
|
|
285
|
+
else {
|
|
286
|
+
try {
|
|
287
|
+
const r = await runVerify(g.command, dir, cfg.verify.timeout_s, env);
|
|
288
|
+
out = { exit: r.exit, timedOut: r.timedOut, output: r.output };
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
out = { exit: null, timedOut: false, output: `spawn failed: ${e instanceof Error ? e.message : String(e)}\n` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const m of g.members) {
|
|
295
|
+
if (judge(m.expected, out) === "ok")
|
|
296
|
+
continue;
|
|
297
|
+
m.line.status = "mismatch";
|
|
298
|
+
m.line.kind = "fail";
|
|
299
|
+
m.line.expected_exit = m.expected;
|
|
300
|
+
m.line.actual_exit = out.exit;
|
|
301
|
+
m.line.timed_out = out.timedOut;
|
|
302
|
+
if (out.exit === null && !out.timedOut)
|
|
303
|
+
m.line.detail = out.output.trim().split("\n")[0] ?? "";
|
|
304
|
+
m.line.report = formatMismatch({ id: m.id, file: m.line.file, line: m.line.line, evidence: m.line.evidence, expected: m.expected, outcome: out, command: g.command, cwd: g.cwd, timeoutS: cfg.verify.timeout_s });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const failed = lines.filter((l) => l.kind === "fail");
|
|
308
|
+
const ok = failed.length === 0;
|
|
309
|
+
if (o.json) {
|
|
310
|
+
io.out(dumps({
|
|
311
|
+
base: o.base,
|
|
312
|
+
merge_base: closed.mergeBase,
|
|
313
|
+
ok,
|
|
314
|
+
tasks: lines.map((l) => {
|
|
315
|
+
const { report, ...rest } = l;
|
|
316
|
+
void report;
|
|
317
|
+
return rest;
|
|
318
|
+
}),
|
|
319
|
+
}) + "\n");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
if (!lines.length)
|
|
323
|
+
io.out("rerun: no closed tasks\n");
|
|
324
|
+
for (const l of lines) {
|
|
325
|
+
if (l.report)
|
|
326
|
+
io.out(l.report);
|
|
327
|
+
else
|
|
328
|
+
io.out(`${l.id}: ${l.status}${l.detail ? ` (${l.detail})` : ""}\n`);
|
|
329
|
+
}
|
|
330
|
+
if (lines.length)
|
|
331
|
+
io.out(`rerun: ${lines.length} task(s), ${failed.length} failure(s)\n`);
|
|
332
|
+
}
|
|
333
|
+
return finish(ok);
|
|
334
|
+
}
|
package/dist/cli/help.js
CHANGED
|
@@ -48,8 +48,9 @@ export const TABLE = [
|
|
|
48
48
|
["--budget <n>", "Stop at n characters. The default is 8000."],
|
|
49
49
|
["--depth <n>", "Follow links n steps. The default is 1."],
|
|
50
50
|
["--all", "Show all copies of an ID."],
|
|
51
|
+
["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
|
|
51
52
|
],
|
|
52
|
-
exit: [OK, ["2", "A usage error or an
|
|
53
|
+
exit: [OK, ["2", "A usage error, an unknown ID, or an ID that is not a requirement."], INTERNAL], example: "specctx bundle REQ-IDX-001 --budget 3000",
|
|
53
54
|
},
|
|
54
55
|
{
|
|
55
56
|
name: "check", group: "Read", args: "", summary: "Find ID and link errors.",
|
|
@@ -62,6 +63,8 @@ export const TABLE = [
|
|
|
62
63
|
flags: [
|
|
63
64
|
["--strict", "Run `check` and `openspec validate` in strict mode."],
|
|
64
65
|
["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
|
|
66
|
+
["--rerun", "Run only the rerun step. Run the command of each task that the range closed. Compare its exit code with the evidence."],
|
|
67
|
+
["--base <ref>", "With --rerun, take the merge base with this ref. The default is [gate] base."],
|
|
65
68
|
],
|
|
66
69
|
exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
|
|
67
70
|
},
|
|
@@ -114,8 +117,11 @@ export const TABLE = [
|
|
|
114
117
|
{ name: "handoff write", group: "Write", args: "", summary: "Write .ctx/handoff.md from the index and the evidence.", flags: [], exit: PLAIN, example: "specctx handoff write" },
|
|
115
118
|
{
|
|
116
119
|
name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
|
|
117
|
-
flags: [
|
|
118
|
-
|
|
120
|
+
flags: [
|
|
121
|
+
["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."],
|
|
122
|
+
["--plan", "Mark the question as the plan approval. Only this mark ends the plan stop. It needs --change."],
|
|
123
|
+
],
|
|
124
|
+
exit: PLAIN, example: "specctx handoff ask --change 008 --plan \"plan ready for 008-tasks. Approve to start the tasks?\"",
|
|
119
125
|
},
|
|
120
126
|
{
|
|
121
127
|
name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
|
package/dist/cli/workflow.js
CHANGED
|
@@ -240,7 +240,10 @@ export async function runWorkflow(c) {
|
|
|
240
240
|
}
|
|
241
241
|
case "handoff ask": {
|
|
242
242
|
const { ask } = await import("../handoff/handoff.js");
|
|
243
|
-
const
|
|
243
|
+
const plan = c.p.flags.plan === true;
|
|
244
|
+
if (plan && c.p.flags.change === undefined)
|
|
245
|
+
throw new UsageExit("--plan needs --change");
|
|
246
|
+
const h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change, plan);
|
|
244
247
|
if (c.json)
|
|
245
248
|
print(c, { open_questions: h.open_questions });
|
|
246
249
|
else
|
package/dist/config.js
CHANGED
|
@@ -43,7 +43,7 @@ export function loadConfig(root) {
|
|
|
43
43
|
raw = parse(readFileSync(p, "utf8"));
|
|
44
44
|
}
|
|
45
45
|
catch (e) {
|
|
46
|
-
throw new ConfigError(`${
|
|
46
|
+
throw new ConfigError(`${p}: the file is not valid TOML. ${e.message.split("\n")[0]}`);
|
|
47
47
|
}
|
|
48
48
|
const sec = (name) => {
|
|
49
49
|
const v = raw[name];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// REQ-VER-013 REQ-VER-005 REQ-VER-006 REQ-VER-007 the rerun step: the tasks a range closed, the compare, the groups (036/D1).
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { changeKey, changeOf } from "../index/scan.js";
|
|
4
|
+
import { cmpStr } from "../util/pystr.js";
|
|
5
|
+
function git(root, args) {
|
|
6
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
7
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
|
|
8
|
+
}
|
|
9
|
+
/** REQ-VER-005 the one line of a failed merge base. A shallow clone gets a second sentence. */
|
|
10
|
+
export function noMergeBaseLine(root, base) {
|
|
11
|
+
const shallow = git(root, ["rev-parse", "--is-shallow-repository"]);
|
|
12
|
+
const tail = shallow.ok && shallow.out === "true" ? ". This clone is shallow" : "";
|
|
13
|
+
return `rerun: no merge base with ${base}, fetch full history (fetch-depth: 0)${tail}`;
|
|
14
|
+
}
|
|
15
|
+
/** REQ-VER-005 the task ID order: the change key, then the group and the number as integers. */
|
|
16
|
+
export function cmpTaskId(a, b) {
|
|
17
|
+
const [ka, na = ""] = a.split("/");
|
|
18
|
+
const [kb, nb = ""] = b.split("/");
|
|
19
|
+
const k = cmpStr(ka, kb);
|
|
20
|
+
if (k !== 0)
|
|
21
|
+
return k;
|
|
22
|
+
const pa = na.split(".").map(Number);
|
|
23
|
+
const pb = nb.split(".").map(Number);
|
|
24
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
25
|
+
const d = (pa[i] ?? -1) - (pb[i] ?? -1);
|
|
26
|
+
if (d !== 0)
|
|
27
|
+
return d < 0 ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
/** REQ-VER-005 the task lines of one tasks.md text, with the key that the index would give them. */
|
|
32
|
+
export function parseTasksFile(path, text, taskRe) {
|
|
33
|
+
const key = changeKey(changeOf(path));
|
|
34
|
+
const archived = path.includes("/archive/");
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const line of text.split(/\r?\n/)) {
|
|
37
|
+
const m = taskRe.exec(line);
|
|
38
|
+
if (m)
|
|
39
|
+
out.push({ id: `${key}/${m[2]}`, done: m[1].toLowerCase() === "x", archived });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** REQ-VER-005 one state per ID. An active copy wins over an archived copy, as findTask does. */
|
|
44
|
+
export function mergeStates(states) {
|
|
45
|
+
const out = new Map();
|
|
46
|
+
for (const s of states) {
|
|
47
|
+
const had = out.get(s.id);
|
|
48
|
+
if (!had || (had.archived && !s.archived))
|
|
49
|
+
out.set(s.id, s);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** REQ-VER-005 the state of every task in the tasks.md files at a commit. */
|
|
54
|
+
export function tasksAt(root, sha, taskRe) {
|
|
55
|
+
const ls = git(root, ["ls-tree", "-z", "-r", "--name-only", sha, "--", "openspec/changes"]);
|
|
56
|
+
const all = [];
|
|
57
|
+
if (!ls.ok)
|
|
58
|
+
return new Map();
|
|
59
|
+
for (const p of ls.out.split("\0").filter((x) => x.endsWith("/tasks.md")).sort(cmpStr)) {
|
|
60
|
+
const show = spawnSync("git", ["show", `${sha}:${p}`], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
61
|
+
if (show.status === 0)
|
|
62
|
+
all.push(...parseTasksFile(p, show.stdout, taskRe));
|
|
63
|
+
}
|
|
64
|
+
return mergeStates(all);
|
|
65
|
+
}
|
|
66
|
+
/** REQ-VER-005 the index entries of tasks, one per ID. The active copy wins. */
|
|
67
|
+
export function headStates(entries) {
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (e.kind !== "task")
|
|
71
|
+
continue;
|
|
72
|
+
const archived = e.file.includes("/archive/");
|
|
73
|
+
const had = out.get(e.id);
|
|
74
|
+
if (!had || (had.state.archived && !archived))
|
|
75
|
+
out.set(e.id, { state: { id: e.id, done: e.status === "done", archived }, entry: e });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** REQ-VER-010 the commit that HEAD names, or "" when git fails. */
|
|
80
|
+
export function headSha(root) {
|
|
81
|
+
const h = git(root, ["rev-parse", "HEAD"]);
|
|
82
|
+
return h.ok ? h.out : "";
|
|
83
|
+
}
|
|
84
|
+
/** REQ-VER-010 the line for a merge base equal to HEAD. */
|
|
85
|
+
export const HEAD_BASE_LINE = "rerun: merge base is HEAD, nothing to compare. On push, pass --base <the commit before the push>";
|
|
86
|
+
/** REQ-VER-013 the line for a base of forty zeros, the `before` of a push that creates a branch. */
|
|
87
|
+
export const ZERO_BASE_LINE = "rerun: base is the zero commit (a new branch), nothing to compare";
|
|
88
|
+
/** REQ-VER-013 a base that ends the rerun before the merge base: the zero commit passes, an unknown commit fails. */
|
|
89
|
+
export function baseProblem(root, base) {
|
|
90
|
+
if (/^0{40}$/.test(base))
|
|
91
|
+
return { ok: true, line: ZERO_BASE_LINE };
|
|
92
|
+
const known = !base.startsWith("-") && git(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).ok;
|
|
93
|
+
if (known)
|
|
94
|
+
return null;
|
|
95
|
+
return { ok: false, line: `rerun: base ${base} is not in this clone. Fetch it or use fetch-depth: 0` };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* REQ-VER-005 the tasks that are done at HEAD and open or absent at the merge base, in ID order.
|
|
99
|
+
* `entries` is the live index. `taskRe` is the task regex of the scanner.
|
|
100
|
+
*/
|
|
101
|
+
export function closedTasks(root, base, entries, taskRe) {
|
|
102
|
+
const mb = git(root, ["merge-base", "HEAD", base]);
|
|
103
|
+
if (!mb.ok || !mb.out)
|
|
104
|
+
return { ok: false, line: noMergeBaseLine(root, base) };
|
|
105
|
+
const before = tasksAt(root, mb.out, taskRe);
|
|
106
|
+
const tasks = [];
|
|
107
|
+
for (const [id, { state, entry }] of headStates(entries)) {
|
|
108
|
+
if (state.done && !before.get(id)?.done)
|
|
109
|
+
tasks.push(entry);
|
|
110
|
+
}
|
|
111
|
+
tasks.sort((a, b) => cmpTaskId(a.id, b.id));
|
|
112
|
+
return { ok: true, mergeBase: mb.out, tasks };
|
|
113
|
+
}
|
|
114
|
+
/** REQ-VER-006 compare one closed task with its evidence, in the fixed order of 036/D4. */
|
|
115
|
+
export function compare(verify, evidence, strict, weak) {
|
|
116
|
+
if (evidence !== null && evidence.kind === "manual")
|
|
117
|
+
return strict ? { act: "fail", kind: "manual" } : { act: "warn", why: "manual" };
|
|
118
|
+
if (evidence === null)
|
|
119
|
+
return { act: "fail", kind: "no-evidence" };
|
|
120
|
+
const cwd = evidence.cwd ?? ".";
|
|
121
|
+
if (verify.command !== evidence.command || (verify.cwd ?? ".") !== cwd) {
|
|
122
|
+
return { act: "fail", kind: "verify-drift", detail: `tasks.md has ${verify.command ?? "no command"}${verify.cwd ? ` (cwd ${verify.cwd})` : ""}, evidence has ${evidence.command ?? "no command"}${evidence.cwd ? ` (cwd ${evidence.cwd})` : ""}` };
|
|
123
|
+
}
|
|
124
|
+
if (evidence.command === null)
|
|
125
|
+
return { act: "fail", kind: "no-evidence" };
|
|
126
|
+
if (strict && weak(evidence.command))
|
|
127
|
+
return { act: "fail", kind: "weak-verify" };
|
|
128
|
+
return { act: "run", command: evidence.command, cwd, expected: evidence.exit };
|
|
129
|
+
}
|
|
130
|
+
/** REQ-VER-007 the key that makes two runs the same run. */
|
|
131
|
+
export const runKey = (v) => `${v.cwd}\u0000${v.command}`;
|
|
132
|
+
/** REQ-VER-007 one group per frozen command and cwd, in the ID order of the first task. */
|
|
133
|
+
export function groupRuns(items) {
|
|
134
|
+
const sorted = [...items].sort((a, b) => cmpTaskId(a.id, b.id));
|
|
135
|
+
const groups = new Map();
|
|
136
|
+
for (const it of sorted) {
|
|
137
|
+
const key = runKey(it);
|
|
138
|
+
const g = groups.get(key);
|
|
139
|
+
if (g)
|
|
140
|
+
g.members.push(it);
|
|
141
|
+
else
|
|
142
|
+
groups.set(key, { key, command: it.command, cwd: it.cwd, members: [it] });
|
|
143
|
+
}
|
|
144
|
+
return [...groups.values()];
|
|
145
|
+
}
|
|
146
|
+
/** REQ-VER-006 ok when the exit code equals the evidence. A timeout or a different code is a mismatch. */
|
|
147
|
+
export function judge(expected, o) {
|
|
148
|
+
return !o.timedOut && o.exit !== null && o.exit === expected ? "ok" : "mismatch";
|
|
149
|
+
}
|
|
150
|
+
const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
151
|
+
/** REQ-VER-007 the line that runs the frozen command on a developer machine as the rerun runs it. */
|
|
152
|
+
export function reproduceLine(command, cwd) {
|
|
153
|
+
return `reproduce: cd ${shq(cwd)} && env CI=1 SPECCTX_RERUN=1 sh -c ${shq(command)}`;
|
|
154
|
+
}
|
|
155
|
+
/** REQ-VER-007 the report of a mismatch. The output tail is the last 20 lines. */
|
|
156
|
+
export function formatMismatch(m) {
|
|
157
|
+
const tailLines = m.outcome.output.replace(/\n$/, "") === "" ? [] : m.outcome.output.replace(/\n$/, "").split("\n").slice(-20);
|
|
158
|
+
const actual = m.outcome.timedOut ? `timed out after ${m.timeoutS}s` : `exit ${m.outcome.exit}`;
|
|
159
|
+
return [
|
|
160
|
+
`${m.id}: mismatch`,
|
|
161
|
+
` at ${m.file}:${m.line}`,
|
|
162
|
+
` evidence ${m.evidence}`,
|
|
163
|
+
` expected exit ${m.expected}`,
|
|
164
|
+
` actual ${actual}`,
|
|
165
|
+
" output (last 20 lines):",
|
|
166
|
+
...tailLines.map((l) => ` ${l}`),
|
|
167
|
+
` ${reproduceLine(m.command, m.cwd)}`,
|
|
168
|
+
].join("\n") + "\n";
|
|
169
|
+
}
|
package/dist/graph/bundle.js
CHANGED
|
@@ -44,6 +44,11 @@ function rstrip(s) {
|
|
|
44
44
|
export function provenance(e, reason) {
|
|
45
45
|
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
|
|
46
46
|
}
|
|
47
|
+
/** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
|
|
48
|
+
export function reqBlocks(root, reqs) {
|
|
49
|
+
const files = new Lines(root);
|
|
50
|
+
return reqs.map((e) => blockText(e, files)).join("\n");
|
|
51
|
+
}
|
|
47
52
|
export function bundle(root, entries, wanted, budget, depth, all = false) {
|
|
48
53
|
const ids = canon(entries);
|
|
49
54
|
const blocks = [];
|
package/dist/handoff/handoff.js
CHANGED
|
@@ -149,7 +149,7 @@ function refresh(root, entries) {
|
|
|
149
149
|
* REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6).
|
|
150
150
|
* REQ-LOOP-005 with a change, the question carries the active change folder, so next blocks the change (029/D1).
|
|
151
151
|
*/
|
|
152
|
-
export function ask(root, entries, text, change) {
|
|
152
|
+
export function ask(root, entries, text, change, plan = false) {
|
|
153
153
|
let task = currentTask(root);
|
|
154
154
|
let stage;
|
|
155
155
|
if (change !== undefined) {
|
|
@@ -157,17 +157,19 @@ export function ask(root, entries, text, change) {
|
|
|
157
157
|
if (folder === undefined)
|
|
158
158
|
throw new UsageExit(`no active change ${change}`);
|
|
159
159
|
task = folder;
|
|
160
|
-
stage = stageOf(entries, folder);
|
|
160
|
+
stage = stageOf(entries, folder, plan);
|
|
161
161
|
}
|
|
162
162
|
const q = addQuestion(root, task, text, stage);
|
|
163
163
|
const h = refresh(root, entries);
|
|
164
164
|
h.asked = q.n;
|
|
165
165
|
return h;
|
|
166
166
|
}
|
|
167
|
-
/** REQ-LOOP-007 archive when the change has a task and every task is [x]
|
|
168
|
-
export function stageOf(entries, folder) {
|
|
167
|
+
/** REQ-LOOP-007 archive when the change has a task and every task is [x]. Else plan with the flag and design without it. The text plays no part (041/D2). */
|
|
168
|
+
export function stageOf(entries, folder, plan = false) {
|
|
169
169
|
const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
|
|
170
|
-
|
|
170
|
+
if (tasks.length > 0 && tasks.every((t) => t.status === "done"))
|
|
171
|
+
return "archive";
|
|
172
|
+
return plan ? "plan" : "design";
|
|
171
173
|
}
|
|
172
174
|
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
173
175
|
export function resolve(root, entries, n, answer) {
|
package/dist/ids/ids.js
CHANGED
|
@@ -39,6 +39,10 @@ export function newIds(root, cfg, entries, o) {
|
|
|
39
39
|
const checkCap = (cap) => {
|
|
40
40
|
if (!cap)
|
|
41
41
|
throw new UsageExit(`id new ${kind} needs a capability code`);
|
|
42
|
+
// REQ-IDS-005 a code with a number is a scenario number typed in the code slot (038/D4)
|
|
43
|
+
const num = /^([A-Z]{2,6})-\d+$/.exec(cap);
|
|
44
|
+
if (num)
|
|
45
|
+
throw new UsageExit(`the capability code ${cap} holds a number. ${kind === "SCN" ? `Use id new SCN ${num[1]} --for REQ-${num[1]}-<NNN>.` : `Use id new ${kind} ${num[1]}.`}`);
|
|
42
46
|
if (!/^[A-Z]{2,6}$/.test(cap))
|
|
43
47
|
throw new UsageExit(`the capability code ${cap} must be 2 to 6 upper case letters`);
|
|
44
48
|
// REQ-IDS-001 an empty caps list accepts any code. A non-empty list must hold the code.
|
package/dist/index/scan.js
CHANGED
|
@@ -16,7 +16,9 @@ const ADR_HDR = new RegExp(`^# (ADR-\\d{3})${S}+(.*)$`, "s");
|
|
|
16
16
|
const REFS_RE = new RegExp(`^Refs:${S}*(.+)$`, "s");
|
|
17
17
|
const TASK_REQ = new RegExp(`\\[(REQ-[A-Z]{2,6}-\\d{3}(?:${S}*,${S}*REQ-[A-Z]{2,6}-\\d{3})*)\\]`, "g");
|
|
18
18
|
const CODE_TAG = new RegExp(`(?://|#|/\\*|\\*)${S}*(REQ-[A-Z]{2,6}-\\d{3})`);
|
|
19
|
-
|
|
19
|
+
// REQ-IDX-007 the rest of the line after Covers:, and each REQ ID in it with word boundaries (042/D4)
|
|
20
|
+
const COVER_TAG = /Covers:(.*)$/s;
|
|
21
|
+
const REQ_ID = /\bREQ-[A-Z]{2,6}-\d{3}\b/g;
|
|
20
22
|
const SENTENCE_END = new RegExp(`(?<=[.!?])${S}`);
|
|
21
23
|
const COMMA = new RegExp(`${S}*,${S}*`);
|
|
22
24
|
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
@@ -348,7 +350,9 @@ export function scanSource(sc, rpath, entries, scnNamed) {
|
|
|
348
350
|
if (isTest) {
|
|
349
351
|
const m = COVER_TAG.exec(line);
|
|
350
352
|
if (m) {
|
|
351
|
-
|
|
353
|
+
// a Covers line never counts as a code tag, with or without a REQ ID
|
|
354
|
+
const ids = [...new Set(m[1].match(REQ_ID) ?? [])];
|
|
355
|
+
for (const rid of ids) {
|
|
352
356
|
entries.push(entry({ id: `${rpath}:${i}`, kind: "test", file: rpath, line: i, title: pyStrip(line), change: "main", status: "active", refs: [rid] }));
|
|
353
357
|
}
|
|
354
358
|
continue;
|
package/dist/init/init.js
CHANGED
|
@@ -4,7 +4,8 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { ASSETS } from "../assets.generated.js";
|
|
6
6
|
import { UsageExit } from "../cli/errors.js";
|
|
7
|
-
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT
|
|
7
|
+
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
|
|
8
|
+
import { loadConfigOrExit } from "../cli/config.js";
|
|
8
9
|
export const SCHEMA = "spec-driven-ids";
|
|
9
10
|
/** REQ-SCH-003 where the templates land in a project. Upstream reads no project override for the stock templates, so this is a reference path (025/D4). */
|
|
10
11
|
export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
|
|
@@ -70,7 +71,7 @@ export function fragmentText(brackets) {
|
|
|
70
71
|
}
|
|
71
72
|
/** REQ-SCH-006 the value of [ids] brackets, false when spec-ctx.toml is absent (032/D2). */
|
|
72
73
|
export function bracketsOf(root) {
|
|
73
|
-
return existsSync(join(root, CONFIG_FILE)) ?
|
|
74
|
+
return existsSync(join(root, CONFIG_FILE)) ? loadConfigOrExit(root).ids.brackets : false;
|
|
74
75
|
}
|
|
75
76
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
76
77
|
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
|
|
@@ -419,7 +420,9 @@ export function runInit(root, o) {
|
|
|
419
420
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
420
421
|
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
421
422
|
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
422
|
-
|
|
423
|
+
// REQ-DIST-013 a skills refresh in a project with spec-ctx.toml touches only the skill folders (038/D3)
|
|
424
|
+
const skillsOnly = o.skills && !o.schema && existsSync(join(root, CONFIG_FILE));
|
|
425
|
+
const ignoreDone = skillsOnly || existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
|
|
423
426
|
if (same && ignoreDone && !o.force) {
|
|
424
427
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
425
428
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
@@ -478,11 +481,13 @@ export function runInit(root, o) {
|
|
|
478
481
|
res.notes.push(LEGACY_SCHEMA_NOTE);
|
|
479
482
|
}
|
|
480
483
|
// REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
484
|
+
if (!skillsOnly) {
|
|
485
|
+
const added = writeGitignore(root);
|
|
486
|
+
if (added)
|
|
487
|
+
res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
|
|
488
|
+
else
|
|
489
|
+
res.kept.push(GITIGNORE);
|
|
490
|
+
}
|
|
486
491
|
// REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
|
|
487
492
|
if (o.skills && ignoresClaude(root))
|
|
488
493
|
res.notes.push(CLAUDE_NOTE);
|
package/dist/loop/fails.js
CHANGED
|
@@ -80,22 +80,21 @@ export function writeFail(root, task, verify, tail) {
|
|
|
80
80
|
writeFileSync(join(root, target), dumps(file) + "\n");
|
|
81
81
|
return { rel: target, file };
|
|
82
82
|
}
|
|
83
|
-
/** REQ-LOOP-002 the question text at the cap. Strict mode. */
|
|
83
|
+
/** REQ-LOOP-002 the question text at the cap. It quotes the failing line (033/D2). Strict mode. */
|
|
84
84
|
export function capQuestion(task, attempts, tail) {
|
|
85
|
-
|
|
86
|
-
return `task ${task} failed Verify ${attempts} times. Last: ${first}. What should change?`;
|
|
85
|
+
return `task ${task} failed Verify ${attempts} times. Last: ${failLine(tail)}. What should change?`;
|
|
87
86
|
}
|
|
88
87
|
/** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
|
|
89
88
|
export function blockedLine(n) {
|
|
90
89
|
return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
|
|
91
90
|
}
|
|
92
91
|
/**
|
|
93
|
-
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail
|
|
94
|
-
*
|
|
92
|
+
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:`, `FAIL`, or `(fail)`
|
|
93
|
+
* after trimming (039/D1), else the last non-empty line, else `no output`. The match is case sensitive.
|
|
95
94
|
*/
|
|
96
95
|
export function failLine(tail) {
|
|
97
96
|
const lines = tail.split("\n").map((l) => l.trimEnd());
|
|
98
|
-
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL)/.test(l)).at(-1);
|
|
97
|
+
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL|\(fail\))/.test(l)).at(-1);
|
|
99
98
|
if (hit !== undefined)
|
|
100
99
|
return hit.trim();
|
|
101
100
|
return lines.filter((l) => l.trim()).at(-1)?.trim() ?? "no output";
|
package/dist/loop/next.js
CHANGED
|
@@ -14,7 +14,7 @@ import { openspecCommand, openspecStatus } from "./openspec.js";
|
|
|
14
14
|
/** The closed action list. A skill stops on any value outside it. */
|
|
15
15
|
export const ACTIONS = ["plan", "cite", "start", "work", "fix", "repair", "gate", "archive", "ask", "stop"];
|
|
16
16
|
/** The keys of the object, sorted. Every state prints all of them. */
|
|
17
|
-
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task"];
|
|
17
|
+
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task", "tasks"];
|
|
18
18
|
/** The rules of check whose finding means a task or a header lacks its citation (024/D2 step 4). */
|
|
19
19
|
// REQ-LOOP-004 a weak Verify command is a citation gap too (028/D7)
|
|
20
20
|
const CITE_RULES = new Set(["missing-id", "task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
@@ -46,22 +46,25 @@ function questions(root) {
|
|
|
46
46
|
return [];
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/** REQ-LOOP-011 the last line of the gate log that is not a rerun line, or null. A line with steps exactly ["rerun"] never counts (041/D1). Only `ok` with `strict: true` passes (042/D2). */
|
|
50
50
|
function lastGate(root) {
|
|
51
51
|
const p = join(root, ".ctx/gate-log.jsonl");
|
|
52
52
|
if (!existsSync(p))
|
|
53
53
|
return null;
|
|
54
54
|
const lines = readFileSync(p, "utf8").split("\n").filter((l) => l.trim());
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
56
|
+
try {
|
|
57
|
+
const o = JSON.parse(lines[i]);
|
|
58
|
+
const rerun = Array.isArray(o.steps) && o.steps.length === 1 && o.steps[0] === "rerun";
|
|
59
|
+
if (rerun)
|
|
60
|
+
continue;
|
|
61
|
+
return { at: typeof o.at === "string" ? o.at : "", ok: o.ok === true && o.strict === true };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
64
66
|
}
|
|
67
|
+
return null;
|
|
65
68
|
}
|
|
66
69
|
/** The `at` of the newest live evidence file of a change key, or "" with none. */
|
|
67
70
|
function newestEvidenceAt(root, key) {
|
|
@@ -91,8 +94,13 @@ function readCurrent(root) {
|
|
|
91
94
|
/** REQ-LOOP-001 the decision of 024/D2. A pure function of the files. It writes nothing. */
|
|
92
95
|
export function decideNext(root, cfg, data, want) {
|
|
93
96
|
const openspec = openspecCommand(cfg.gate.openspec);
|
|
94
|
-
const d = { action: "stop", attempt: 0, change: null, command: null, human: false, max_attempts: cfg.loop.max_attempts, reason: null, state: "unknown", task: null };
|
|
95
|
-
const done = (o) =>
|
|
97
|
+
const d = { action: "stop", attempt: 0, change: null, command: null, human: false, max_attempts: cfg.loop.max_attempts, reason: null, state: "unknown", task: null, tasks: [] };
|
|
98
|
+
const done = (o) => {
|
|
99
|
+
const r = { ...d, ...o };
|
|
100
|
+
if (!o.tasks)
|
|
101
|
+
r.tasks = r.task === null ? [] : [r.task];
|
|
102
|
+
return r;
|
|
103
|
+
};
|
|
96
104
|
// step 0 and step 1: the change under consideration, or the plan stop with no change
|
|
97
105
|
const folders = activeFolders(root);
|
|
98
106
|
const folder = want === undefined ? folders[0] : folders.find((f) => matchesChange(f, want));
|
|
@@ -131,9 +139,28 @@ export function decideNext(root, cfg, data, want) {
|
|
|
131
139
|
}
|
|
132
140
|
// step 5: a hand tick without evidence gets the repair run
|
|
133
141
|
const tasks = tasksOf(entries).filter(ofChange).sort((a, b) => cmpTuple([a.file, a.line], [b.file, b.line]));
|
|
142
|
+
// REQ-LOOP-009 step 4b: the plan stop. It needs no [x] task, no current task, and no question with stage plan, open or resolved. Only --plan sets that stage (034/D1, 041/D2).
|
|
143
|
+
const cur0 = readCurrent(root);
|
|
144
|
+
const started = tasks.some((t) => t.status === "done") || (cur0?.task !== undefined && cur0.task.split("/")[0] === key);
|
|
145
|
+
if (!started && !qs.some((q) => q.stage === "plan" && q.task === folder)) {
|
|
146
|
+
const text = `plan ready for ${folder}. Approve to start the tasks?`;
|
|
147
|
+
return done({ action: "ask", command: `specctx handoff ask --change ${folder} --plan "${text}"`, human: true, reason: text, state: "plan_stop" });
|
|
148
|
+
}
|
|
134
149
|
const ticked = tasks.find((t) => t.status === "done" && !hasEvidence(root, t));
|
|
135
150
|
if (ticked)
|
|
136
151
|
return done({ action: "repair", command: `specctx task done ${ticked.id}`, state: "done_without_evidence", task: ticked.id });
|
|
152
|
+
// REQ-LOOP-008 the done tasks whose evidence went stale, earliest first (031/D4, 031/D5)
|
|
153
|
+
const stale = [];
|
|
154
|
+
for (const t of tasks) {
|
|
155
|
+
if (t.status !== "done")
|
|
156
|
+
continue;
|
|
157
|
+
const path = staleOf(root, t.id);
|
|
158
|
+
if (path !== null)
|
|
159
|
+
stale.push({ id: t.id, path });
|
|
160
|
+
}
|
|
161
|
+
// REQ-LOOP-010 a current task keeps its action. The reason names the stale tasks that wait (034/D4).
|
|
162
|
+
const waiting = stale.length ? `stale waiting: ${stale.map((x) => x.id).join(", ")}` : null;
|
|
163
|
+
const withWaiting = (r) => (waiting === null ? r : r === null ? waiting : `${r}. ${waiting}`);
|
|
137
164
|
// step 6: the current task of this change
|
|
138
165
|
const cur = readCurrent(root);
|
|
139
166
|
const current = cur?.task && cur.task.split("/")[0] === key ? tasks.find((t) => t.id === cur.task && t.status === "open") : undefined;
|
|
@@ -141,23 +168,26 @@ export function decideNext(root, cfg, data, want) {
|
|
|
141
168
|
const fails = failsInWindow(root, current.id, qs);
|
|
142
169
|
const last = fails.at(-1);
|
|
143
170
|
// REQ-LOOP-006 the reason names the last fail line of the tail (029/D3)
|
|
144
|
-
const reason = last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null;
|
|
171
|
+
const reason = withWaiting(last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null);
|
|
145
172
|
if (fails.length >= cfg.loop.max_attempts && !openQuestionFor(root, current.id, qs)) {
|
|
146
173
|
const q = capQuestion(current.id, fails.length, last?.file.stderr_tail ?? "");
|
|
147
174
|
return done({ action: "ask", attempt: fails.length, command: `specctx handoff ask "${q}"`, human: true, reason, state: "attempts_exhausted", task: current.id });
|
|
148
175
|
}
|
|
149
176
|
if (fails.length)
|
|
150
177
|
return done({ action: "fix", attempt: fails.length, command: `specctx task done ${current.id}`, reason, state: "task_failed", task: current.id });
|
|
151
|
-
return done({ action: "work", command: `specctx task done ${current.id}`, state: "task_started", task: current.id });
|
|
152
|
-
}
|
|
153
|
-
// REQ-LOOP-008 step 6b: the
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
178
|
+
return done({ action: "work", command: `specctx task done ${current.id}`, reason, state: "task_started", task: current.id });
|
|
179
|
+
}
|
|
180
|
+
// REQ-LOOP-008 step 6b: the stale tasks repair after the current task (031/D4, 031/D5)
|
|
181
|
+
if (stale.length) {
|
|
182
|
+
// REQ-LOOP-008 one turn repairs every stale task (033/D1)
|
|
183
|
+
return done({
|
|
184
|
+
action: "repair",
|
|
185
|
+
command: stale.map((s) => `specctx task reopen ${s.id} "stale evidence: ${s.path}" && specctx task done ${s.id}`).join(" && "),
|
|
186
|
+
reason: stale.map((s) => `stale-evidence ${s.id} ${s.path}`).join(", "),
|
|
187
|
+
state: "evidence_stale",
|
|
188
|
+
task: stale[0].id,
|
|
189
|
+
tasks: stale.map((s) => s.id),
|
|
190
|
+
});
|
|
161
191
|
}
|
|
162
192
|
// step 7: the next open task
|
|
163
193
|
const open = nextTask(root, entries, folder);
|
|
@@ -181,7 +211,8 @@ export function decideNext(root, cfg, data, want) {
|
|
|
181
211
|
}
|
|
182
212
|
/** REQ-LOOP-001 REQ-CLI-007 the human form: action first, one line per key, next last (024/D1). Strict mode. */
|
|
183
213
|
export function renderNext(d) {
|
|
184
|
-
|
|
214
|
+
// REQ-LOOP-012 an empty list prints none, a list joins with a comma and a space
|
|
215
|
+
const show = (v) => (v === null ? "none" : Array.isArray(v) ? (v.length ? v.join(", ") : "none") : String(v));
|
|
185
216
|
const lines = [`action: ${d.action}`];
|
|
186
217
|
for (const k of KEYS) {
|
|
187
218
|
if (k === "action" || k === "command")
|
package/dist/util/time.js
CHANGED
package/dist/verify/run.js
CHANGED
|
@@ -5,10 +5,11 @@ export function tail(text, n = TAIL_LINES) {
|
|
|
5
5
|
const lines = text.replace(/\n$/, "").split("\n");
|
|
6
6
|
return text === "" ? "" : lines.slice(-n).join("\n");
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/** REQ-VER-007 `env` replaces the environment of the child. The rerun passes the current env plus CI and SPECCTX_RERUN. */
|
|
9
|
+
export function runVerify(command, cwd, timeoutS, env) {
|
|
9
10
|
return new Promise((resolve, reject) => {
|
|
10
11
|
// a new process group, so the timeout stops the children of sh too
|
|
11
|
-
const child = spawn("sh", ["-c", command], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
const child = spawn("sh", ["-c", command], { cwd, detached: true, ...(env ? { env } : {}), stdio: ["ignore", "pipe", "pipe"] });
|
|
12
13
|
const out = [];
|
|
13
14
|
const err = [];
|
|
14
15
|
let output = "";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// REQ-CLI-001 version string. tests/cli/version.test.ts keeps it equal to package.json.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.4.0";
|
package/package.json
CHANGED
|
@@ -16,22 +16,27 @@ Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the comm
|
|
|
16
16
|
|
|
17
17
|
## Actions
|
|
18
18
|
|
|
19
|
-
- `plan`: take the change name from the request of the user in kebab case. That name is `<c>`. Follow the upstream `openspec-propose` skill for `<c>`. Read existing requirements with `specctx bundle <id>` or `specctx query`, never `openspec show`. Take every ID from `specctx id new REQ <CAP>` and `specctx id new SCN <CAP> --for <REQ>`. When the change needs several IDs of one kind, run `specctx id new --count <n>` once. Give every task a `[REQ-...]` citation and a `Verify:` code span. Then run `specctx check --strict` and `specctx gate --strict`. The gate runs the OpenSpec validate and the spec-length check. Fix every finding. Then run `specctx
|
|
19
|
+
- `plan`: take the change name from the request of the user in kebab case. That name is `<c>`. Follow the upstream `openspec-propose` skill for `<c>`. Read existing requirements with `specctx bundle <id>` or `specctx query`, never `openspec show`. Take every ID from `specctx id new REQ <CAP>` and `specctx id new SCN <CAP> --for <REQ>`. When the change needs several IDs of one kind, run `specctx id new --count <n>` once. Give every task a `[REQ-...]` citation and a `Verify:` code span. Then run `specctx check --strict` and `specctx gate --strict`. The gate runs the OpenSpec validate and the spec-length check. Fix every finding. Then run `specctx next --json` and run the command it prints. This is the plan stop. `next` returns `ask` with `human: true` until the user resolves the question.
|
|
20
20
|
- `cite`: run `specctx query --change <c> --kind req`. Add the `[REQ-...]` citation and a `Verify:` code span to the task line named in `task`. Run `specctx check`. Continue.
|
|
21
21
|
- `start`: run `specctx task start <t>`. Read the bundle it prints. Continue.
|
|
22
22
|
- `work`: implement the task. Tag code with `// REQ-...`. Tag tests with `// Covers: REQ-...`. Then run `specctx task done <t>`.
|
|
23
23
|
- `fix`: read `reason`. It holds the tail of the failed Verify output. Fix the cause. Run `specctx task done <t>` again.
|
|
24
24
|
- `repair`: run the command in the `command` field. It runs Verify again for a ticked task whose evidence is absent or stale.
|
|
25
|
-
- `gate`: run `specctx gate --strict`. Fix every finding. Run
|
|
25
|
+
- `gate`: run `specctx gate --strict`. Fix every finding. For a `stale-evidence` finding, go back to step 1. `next` returns `repair` and its command repairs every stale task. Run the gate again until it exits 0.
|
|
26
26
|
- `archive`: when `human` is true, run `specctx handoff ask --change <c> "archive <c>?"` and stop. This is the archive stop. `next` returns `ask` until the user resolves the question. When `human` is false, the user approved or `[loop] archive` is `auto`. Then run each step under `operations.archive.guidance` in `openspec/config.yaml`. Then run the command in the `command` field. Then run `openspec validate --all --strict`, `specctx check --strict`, and `specctx handoff write`.
|
|
27
27
|
- `ask`: print `reason`. It is the open question. Stop. The user answers with `specctx handoff resolve <n> "<answer>"`.
|
|
28
28
|
- `stop`: print `reason`. Stop.
|
|
29
29
|
|
|
30
30
|
## Rules
|
|
31
31
|
|
|
32
|
+
- This skill overrides two steps of the upstream `openspec-propose` skill. It reads specs with `specctx bundle` or `specctx query`, not `openspec show`. It never runs the closing `/opsx:apply`.
|
|
32
33
|
- Never tick a checkbox by hand. `specctx task done` is the only way a task closes.
|
|
33
34
|
- Never use `--manual` on a task with a Verify command.
|
|
34
35
|
- Never call `/opsx:apply`. This skill replaces it.
|
|
36
|
+
- To write a MODIFIED block, run `specctx bundle <REQ> --block`. It prints the block as it sits in the spec file. A MODIFIED block keeps every scenario title unchanged.
|
|
37
|
+
- `gate` counts the Refs line in the 500 characters of a requirement.
|
|
38
|
+
- The CHANGELOG entry goes under `## Unreleased`, never under a released version.
|
|
39
|
+
- After an edit under `skills/`, run `bun run build && node dist/cli.js init --skills --force` to refresh the copies.
|
|
35
40
|
- Never read `openspec/specs/` or `openspec/changes/*/specs/` directly. Use the bundle from `task start` or `specctx bundle <id>`.
|
|
36
41
|
- Stop on any `action` not in the list above. Print the object and ask the user.
|
|
37
42
|
- Keep no state in your context. A new session starts the same loop again with step 1.
|