spec-ctx 0.2.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 CHANGED
@@ -229,7 +229,7 @@ ignore = [] # globs that the scan skips
229
229
  [ids]
230
230
  caps = [] # capability codes. Empty means any code is valid.
231
231
  task_pattern = '\d+\.\d+' # the task number in a tasks.md checkbox
232
- brackets = false # true: adopt and id new write [REQ-UA-001]. The scan reads both forms.
232
+ brackets = false # true: adopt, id new, and init --schema write [REQ-UA-001] in headers. The scan reads both forms.
233
233
 
234
234
  [ids.paths]
235
235
  UA = "user-auth" # capability path of each code. adopt writes the derived ones.
@@ -251,6 +251,8 @@ max_attempts = 3 # Verify failures on one task before tas
251
251
  archive = "ask" # "ask" stops before archive for a human. "auto" lets the loop archive.
252
252
  ```
253
253
 
254
+ With `brackets = true`, `init --schema` installs templates with `[REQ-<CAP>-NNN]` and `[SCN-<CAP>-NNN-N]` in the headers. The merged rules show the same form. Task citations, `Refs:` lines, and tags stay bare. After you change the value, run `specctx init --schema` again. It switches each template and rule that you did not edit. It keeps the ones you edited, unless you add `--force`.
255
+
254
256
  Two environment variables change the engine:
255
257
 
256
258
  | Variable | Effect |
@@ -282,20 +284,51 @@ npx spec-ctx lint ste --baseline 0 --disable synonym-rotation README.md
282
284
 
283
285
  The action runs `specctx gate`: `check --strict`, both markdown linters, `openspec validate --all --strict`, and the length check.
284
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
+
285
318
  ## Status
286
319
 
287
- - Published: `spec-ctx@0.2.0` on npm, with tags `v0.2.0` and `v0` and binaries on the GitHub release.
288
- - Archived: 001 to 025, and 027. No change is open. See `CHANGELOG.md` for each release.
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.
289
322
  - A version bump on `main` publishes the next release. See `docs/CONTRIBUTING.md`.
290
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.
291
324
 
292
325
  ## Measured
293
326
 
294
- `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-29.md`. Its total row:
327
+ `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-30.md`. Its total row:
295
328
 
296
329
  | change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
297
330
  | --- | --- | --- | --- | --- | --- |
298
- | total | 0.0327 (n=76) | 0.90 (143/159) | 36 (of 81 reopens) | 98 (n=76) | 0 (0 baselined + 0 new) |
331
+ | total | 0.0228 (n=116) | 0.93 (204/220) | 56 (of 106 reopens) | 61 (n=116) | 0 (0 baselined + 0 new) |
299
332
 
300
333
  - `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better.
301
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 }}
@@ -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, loadConfig } from "../config.js";
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 = loadConfig(root);
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
  }
@@ -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": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLgoKIyMgVGhlIGxvb3AKCjEuIFJ1biBgc3BlY2N0eCBuZXh0IC0tanNvbmAuIFJlYWQgYGFjdGlvbmAsIGBjaGFuZ2VgIChgPGM+YCksIGB0YXNrYCAoYDx0PmApLCBgY29tbWFuZGAsIGByZWFzb25gLCBhbmQgYGh1bWFuYC4KMi4gQWN0IG9uIGBhY3Rpb25gIGFzIHRoZSB0YWJsZSBiZWxvdyBzYXlzLgozLiBSdW4gYHNwZWNjdHggaGFuZG9mZiB3cml0ZWAuCjQuIFJlcGVhdCBmcm9tIHN0ZXAgMSB1bnRpbCBgYWN0aW9uYCBpcyBgc3RvcGAgb3IgYGh1bWFuYCBpcyB0cnVlLgoKIyMgQWN0aW9ucwoKLSBgcGxhbmA6IGZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciB0aGUgY2hhbmdlIGluIGBjb21tYW5kYC4gVGFrZSBldmVyeSBJRCBmcm9tIGBzcGVjY3R4IGlkIG5ldyBSRVEgPENBUD5gIGFuZCBgc3BlY2N0eCBpZCBuZXcgU0NOIDxDQVA+IC0tZm9yIDxSRVE+YC4gR2l2ZSBldmVyeSB0YXNrIGEgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3Bhbi4gVGhlbiBydW4gYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgIGFuZCBgb3BlbnNwZWMgdmFsaWRhdGUgPGM+IC0tc3RyaWN0YC4gRml4IGV2ZXJ5IGZpbmRpbmcuIFN0b3AgYW5kIHNob3cgdGhlIHBsYW4gdG8gdGhlIHVzZXIuIFRoaXMgaXMgdGhlIHBsYW4gc3RvcC4KLSBgY2l0ZWA6IHJ1biBgc3BlY2N0eCBxdWVyeSAtLWNoYW5nZSA8Yz4gLS1raW5kIHJlcWAuIEFkZCB0aGUgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3BhbiB0byB0aGUgdGFzayBsaW5lIG5hbWVkIGluIGB0YXNrYC4gUnVuIGBzcGVjY3R4IGNoZWNrYC4gQ29udGludWUuCi0gYHN0YXJ0YDogcnVuIGBzcGVjY3R4IHRhc2sgc3RhcnQgPHQ+YC4gUmVhZCB0aGUgYnVuZGxlIGl0IHByaW50cy4gQ29udGludWUuCi0gYHdvcmtgOiBpbXBsZW1lbnQgdGhlIHRhc2suIFRhZyBjb2RlIHdpdGggYC8vIFJFUS0uLi5gLiBUYWcgdGVzdHMgd2l0aCBgLy8gQ292ZXJzOiBSRVEtLi4uYC4gVGhlbiBydW4gYHNwZWNjdHggdGFzayBkb25lIDx0PmAuCi0gYGZpeGA6IHJlYWQgYHJlYXNvbmAuIEl0IGhvbGRzIHRoZSB0YWlsIG9mIHRoZSBmYWlsZWQgVmVyaWZ5IG91dHB1dC4gRml4IHRoZSBjYXVzZS4gUnVuIGBzcGVjY3R4IHRhc2sgZG9uZSA8dD5gIGFnYWluLgotIGByZXBhaXJgOiBydW4gYHNwZWNjdHggdGFzayBkb25lIDx0PmAuIEl0IHJ1bnMgVmVyaWZ5IGZvciBhIHRpY2tlZCB0YXNrIHRoYXQgaGFzIG5vIGV2aWRlbmNlLgotIGBnYXRlYDogcnVuIGBzcGVjY3R4IGdhdGUgLS1zdHJpY3RgLiBGaXggZXZlcnkgZmluZGluZy4gUnVuIGl0IGFnYWluIHVudGlsIGl0IGV4aXRzIDAuCi0gYGFyY2hpdmVgOiB3aGVuIGBodW1hbmAgaXMgdHJ1ZSwgc3RvcCBhbmQgYXNrIHRoZSB1c2VyIGluIG9uZSBsaW5lIHdoZXRoZXIgdG8gYXJjaGl2ZSBgPGM+YC4gV2hlbiBgaHVtYW5gIGlzIGZhbHNlLCBmb2xsb3cgdGhlIHVwc3RyZWFtIGBvcGVuc3BlYy1hcmNoaXZlLWNoYW5nZWAgc2tpbGwuIFRoZW4gcnVuIGBvcGVuc3BlYyB2YWxpZGF0ZSAtLWFsbCAtLXN0cmljdGAsIGBzcGVjY3R4IGNoZWNrIC0tc3RyaWN0YCwgYW5kIGBzcGVjY3R4IGhhbmRvZmYgd3JpdGVgLgotIGBhc2tgOiBwcmludCBgcmVhc29uYC4gSXQgaXMgdGhlIG9wZW4gcXVlc3Rpb24uIFN0b3AuIFRoZSB1c2VyIGFuc3dlcnMgd2l0aCBgc3BlY2N0eCBoYW5kb2ZmIHJlc29sdmUgPG4+ICI8YW5zd2VyPiJgLgotIGBzdG9wYDogcHJpbnQgYHJlYXNvbmAuIFN0b3AuCgojIyBSdWxlcwoKLSBOZXZlciB0aWNrIGEgY2hlY2tib3ggYnkgaGFuZC4gYHNwZWNjdHggdGFzayBkb25lYCBpcyB0aGUgb25seSB3YXkgYSB0YXNrIGNsb3Nlcy4KLSBOZXZlciB1c2UgYC0tbWFudWFsYCBvbiBhIHRhc2sgd2l0aCBhIFZlcmlmeSBjb21tYW5kLgotIE5ldmVyIGNhbGwgYC9vcHN4OmFwcGx5YC4gVGhpcyBza2lsbCByZXBsYWNlcyBpdC4KLSBOZXZlciByZWFkIGBvcGVuc3BlYy9zcGVjcy9gIG9yIGBvcGVuc3BlYy9jaGFuZ2VzLyovc3BlY3MvYCBkaXJlY3RseS4gVXNlIHRoZSBidW5kbGUgZnJvbSBgdGFzayBzdGFydGAgb3IgYHNwZWNjdHggYnVuZGxlIDxpZD5gLgotIFN0b3Agb24gYW55IGBhY3Rpb25gIG5vdCBpbiB0aGUgbGlzdCBhYm92ZS4gUHJpbnQgdGhlIG9iamVjdCBhbmQgYXNrIHRoZSB1c2VyLgotIEtlZXAgbm8gc3RhdGUgaW4geW91ciBjb250ZXh0LiBBIG5ldyBzZXNzaW9uIHN0YXJ0cyB0aGUgc2FtZSBsb29wIGFnYWluIHdpdGggc3RlcCAxLgo=",
13
+ "skills/specctx-run/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLiBXaGVuIHRoYXQga2V5IGlzIGFic2VudCwgYG9wZW5zcGVjYCBpcyBgbnB4IC15IEBmaXNzaW9uLWFpL29wZW5zcGVjQGxhdGVzdGAuCgojIyBUaGUgbG9vcAoKMS4gUnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYC4gUmVhZCBgYWN0aW9uYCwgYGNoYW5nZWAgKGA8Yz5gKSwgYHRhc2tgIChgPHQ+YCksIGBjb21tYW5kYCwgYHJlYXNvbmAsIGFuZCBgaHVtYW5gLgoyLiBBY3Qgb24gYGFjdGlvbmAgYXMgdGhlIHRhYmxlIGJlbG93IHNheXMuCjMuIFJ1biBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KNC4gUmVwZWF0IGZyb20gc3RlcCAxIHVudGlsIGBhY3Rpb25gIGlzIGBzdG9wYCBvciBgaHVtYW5gIGlzIHRydWUuCgojIyBBY3Rpb25zCgotIGBwbGFuYDogdGFrZSB0aGUgY2hhbmdlIG5hbWUgZnJvbSB0aGUgcmVxdWVzdCBvZiB0aGUgdXNlciBpbiBrZWJhYiBjYXNlLiBUaGF0IG5hbWUgaXMgYDxjPmAuIEZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciBgPGM+YC4gUmVhZCBleGlzdGluZyByZXF1aXJlbWVudHMgd2l0aCBgc3BlY2N0eCBidW5kbGUgPGlkPmAgb3IgYHNwZWNjdHggcXVlcnlgLCBuZXZlciBgb3BlbnNwZWMgc2hvd2AuIFRha2UgZXZlcnkgSUQgZnJvbSBgc3BlY2N0eCBpZCBuZXcgUkVRIDxDQVA+YCBhbmQgYHNwZWNjdHggaWQgbmV3IFNDTiA8Q0FQPiAtLWZvciA8UkVRPmAuIFdoZW4gdGhlIGNoYW5nZSBuZWVkcyBzZXZlcmFsIElEcyBvZiBvbmUga2luZCwgcnVuIGBzcGVjY3R4IGlkIG5ldyAtLWNvdW50IDxuPmAgb25jZS4gR2l2ZSBldmVyeSB0YXNrIGEgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3Bhbi4gVGhlbiBydW4gYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgIGFuZCBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gVGhlIGdhdGUgcnVucyB0aGUgT3BlblNwZWMgdmFsaWRhdGUgYW5kIHRoZSBzcGVjLWxlbmd0aCBjaGVjay4gRml4IGV2ZXJ5IGZpbmRpbmcuIFRoZW4gcnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYCBhbmQgcnVuIHRoZSBjb21tYW5kIGl0IHByaW50cy4gVGhpcyBpcyB0aGUgcGxhbiBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB3aXRoIGBodW1hbjogdHJ1ZWAgdW50aWwgdGhlIHVzZXIgcmVzb2x2ZXMgdGhlIHF1ZXN0aW9uLgotIGBjaXRlYDogcnVuIGBzcGVjY3R4IHF1ZXJ5IC0tY2hhbmdlIDxjPiAtLWtpbmQgcmVxYC4gQWRkIHRoZSBgW1JFUS0uLi5dYCBjaXRhdGlvbiBhbmQgYSBgVmVyaWZ5OmAgY29kZSBzcGFuIHRvIHRoZSB0YXNrIGxpbmUgbmFtZWQgaW4gYHRhc2tgLiBSdW4gYHNwZWNjdHggY2hlY2tgLiBDb250aW51ZS4KLSBgc3RhcnRgOiBydW4gYHNwZWNjdHggdGFzayBzdGFydCA8dD5gLiBSZWFkIHRoZSBidW5kbGUgaXQgcHJpbnRzLiBDb250aW51ZS4KLSBgd29ya2A6IGltcGxlbWVudCB0aGUgdGFzay4gVGFnIGNvZGUgd2l0aCBgLy8gUkVRLS4uLmAuIFRhZyB0ZXN0cyB3aXRoIGAvLyBDb3ZlcnM6IFJFUS0uLi5gLiBUaGVuIHJ1biBgc3BlY2N0eCB0YXNrIGRvbmUgPHQ+YC4KLSBgZml4YDogcmVhZCBgcmVhc29uYC4gSXQgaG9sZHMgdGhlIHRhaWwgb2YgdGhlIGZhaWxlZCBWZXJpZnkgb3V0cHV0LiBGaXggdGhlIGNhdXNlLiBSdW4gYHNwZWNjdHggdGFzayBkb25lIDx0PmAgYWdhaW4uCi0gYHJlcGFpcmA6IHJ1biB0aGUgY29tbWFuZCBpbiB0aGUgYGNvbW1hbmRgIGZpZWxkLiBJdCBydW5zIFZlcmlmeSBhZ2FpbiBmb3IgYSB0aWNrZWQgdGFzayB3aG9zZSBldmlkZW5jZSBpcyBhYnNlbnQgb3Igc3RhbGUuCi0gYGdhdGVgOiBydW4gYHNwZWNjdHggZ2F0ZSAtLXN0cmljdGAuIEZpeCBldmVyeSBmaW5kaW5nLiBGb3IgYSBgc3RhbGUtZXZpZGVuY2VgIGZpbmRpbmcsIGdvIGJhY2sgdG8gc3RlcCAxLiBgbmV4dGAgcmV0dXJucyBgcmVwYWlyYCBhbmQgaXRzIGNvbW1hbmQgcmVwYWlycyBldmVyeSBzdGFsZSB0YXNrLiBSdW4gdGhlIGdhdGUgYWdhaW4gdW50aWwgaXQgZXhpdHMgMC4KLSBgYXJjaGl2ZWA6IHdoZW4gYGh1bWFuYCBpcyB0cnVlLCBydW4gYHNwZWNjdHggaGFuZG9mZiBhc2sgLS1jaGFuZ2UgPGM+ICJhcmNoaXZlIDxjPj8iYCBhbmQgc3RvcC4gVGhpcyBpcyB0aGUgYXJjaGl2ZSBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB1bnRpbCB0aGUgdXNlciByZXNvbHZlcyB0aGUgcXVlc3Rpb24uIFdoZW4gYGh1bWFuYCBpcyBmYWxzZSwgdGhlIHVzZXIgYXBwcm92ZWQgb3IgYFtsb29wXSBhcmNoaXZlYCBpcyBgYXV0b2AuIFRoZW4gcnVuIGVhY2ggc3RlcCB1bmRlciBgb3BlcmF0aW9ucy5hcmNoaXZlLmd1aWRhbmNlYCBpbiBgb3BlbnNwZWMvY29uZmlnLnlhbWxgLiBUaGVuIHJ1biB0aGUgY29tbWFuZCBpbiB0aGUgYGNvbW1hbmRgIGZpZWxkLiBUaGVuIHJ1biBgb3BlbnNwZWMgdmFsaWRhdGUgLS1hbGwgLS1zdHJpY3RgLCBgc3BlY2N0eCBjaGVjayAtLXN0cmljdGAsIGFuZCBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KLSBgYXNrYDogcHJpbnQgYHJlYXNvbmAuIEl0IGlzIHRoZSBvcGVuIHF1ZXN0aW9uLiBTdG9wLiBUaGUgdXNlciBhbnN3ZXJzIHdpdGggYHNwZWNjdHggaGFuZG9mZiByZXNvbHZlIDxuPiAiPGFuc3dlcj4iYC4KLSBgc3RvcGA6IHByaW50IGByZWFzb25gLiBTdG9wLgoKIyMgUnVsZXMKCi0gVGhpcyBza2lsbCBvdmVycmlkZXMgdHdvIHN0ZXBzIG9mIHRoZSB1cHN0cmVhbSBgb3BlbnNwZWMtcHJvcG9zZWAgc2tpbGwuIEl0IHJlYWRzIHNwZWNzIHdpdGggYHNwZWNjdHggYnVuZGxlYCBvciBgc3BlY2N0eCBxdWVyeWAsIG5vdCBgb3BlbnNwZWMgc2hvd2AuIEl0IG5ldmVyIHJ1bnMgdGhlIGNsb3NpbmcgYC9vcHN4OmFwcGx5YC4KLSBOZXZlciB0aWNrIGEgY2hlY2tib3ggYnkgaGFuZC4gYHNwZWNjdHggdGFzayBkb25lYCBpcyB0aGUgb25seSB3YXkgYSB0YXNrIGNsb3Nlcy4KLSBOZXZlciB1c2UgYC0tbWFudWFsYCBvbiBhIHRhc2sgd2l0aCBhIFZlcmlmeSBjb21tYW5kLgotIE5ldmVyIGNhbGwgYC9vcHN4OmFwcGx5YC4gVGhpcyBza2lsbCByZXBsYWNlcyBpdC4KLSBUbyB3cml0ZSBhIE1PRElGSUVEIGJsb2NrLCBydW4gYHNwZWNjdHggYnVuZGxlIDxSRVE+IC0tYmxvY2tgLiBJdCBwcmludHMgdGhlIGJsb2NrIGFzIGl0IHNpdHMgaW4gdGhlIHNwZWMgZmlsZS4gQSBNT0RJRklFRCBibG9jayBrZWVwcyBldmVyeSBzY2VuYXJpbyB0aXRsZSB1bmNoYW5nZWQuCi0gYGdhdGVgIGNvdW50cyB0aGUgUmVmcyBsaW5lIGluIHRoZSA1MDAgY2hhcmFjdGVycyBvZiBhIHJlcXVpcmVtZW50LgotIFRoZSBDSEFOR0VMT0cgZW50cnkgZ29lcyB1bmRlciBgIyMgVW5yZWxlYXNlZGAsIG5ldmVyIHVuZGVyIGEgcmVsZWFzZWQgdmVyc2lvbi4KLSBBZnRlciBhbiBlZGl0IHVuZGVyIGBza2lsbHMvYCwgcnVuIGBidW4gcnVuIGJ1aWxkICYmIG5vZGUgZGlzdC9jbGkuanMgaW5pdCAtLXNraWxscyAtLWZvcmNlYCB0byByZWZyZXNoIHRoZSBjb3BpZXMuCi0gTmV2ZXIgcmVhZCBgb3BlbnNwZWMvc3BlY3MvYCBvciBgb3BlbnNwZWMvY2hhbmdlcy8qL3NwZWNzL2AgZGlyZWN0bHkuIFVzZSB0aGUgYnVuZGxlIGZyb20gYHRhc2sgc3RhcnRgIG9yIGBzcGVjY3R4IGJ1bmRsZSA8aWQ+YC4KLSBTdG9wIG9uIGFueSBgYWN0aW9uYCBub3QgaW4gdGhlIGxpc3QgYWJvdmUuIFByaW50IHRoZSBvYmplY3QgYW5kIGFzayB0aGUgdXNlci4KLSBLZWVwIG5vIHN0YXRlIGluIHlvdXIgY29udGV4dC4gQSBuZXcgc2Vzc2lvbiBzdGFydHMgdGhlIHNhbWUgbG9vcCBhZ2FpbiB3aXRoIHN0ZXAgMS4K",
14
14
  };
@@ -4,11 +4,14 @@ import { join } from "node:path";
4
4
  import { lintLine, parseLintLine } from "../lint/counts.js";
5
5
  import { cmpStr } from "../util/pystr.js";
6
6
  export const BASELINE_PATH = ".ctx/baseline.json";
7
- export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify", "missing-id"];
7
+ export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify", "weak-verify", "missing-id"];
8
8
  /** REQ-ADOPT-005 a missing-id finding has no ID. Its key carries the file and the header title. */
9
9
  const key = (f) => (f.rule === "missing-id" ? `${f.rule} ${f.file} ${f.id}` : `${f.rule} ${f.id}`);
10
- /** A warning line reads "manual-evidence <task>". */
11
- const warningKey = (w) => w;
10
+ /**
11
+ * A warning line reads "manual-evidence <task>" or "weak-verify <task> <file>:<line>".
12
+ * REQ-ADOPT-005 the key keeps the rule and the task, so a weak-verify warning matches its strict finding (028/D10).
13
+ */
14
+ const warningKey = (w) => w.split(" ").slice(0, 2).join(" ");
12
15
  export function readBaseline(root) {
13
16
  const p = join(root, BASELINE_PATH);
14
17
  if (!existsSync(p))
@@ -6,6 +6,7 @@ import { changeKey } from "../index/scan.js";
6
6
  import { listEvidence } from "../tasks/evidence.js";
7
7
  import { cmpTuple } from "../util/pystr.js";
8
8
  import { evidenceFindings } from "./evidence.js";
9
+ import { isWeakVerify, parseVerify } from "../verify/parse.js";
9
10
  // the first ID of a retired row can be a scenario. Only a REQ or ADR can be reused, but the SCN still counts as the row ID.
10
11
  const RETIRED_RE = /\b(REQ-[A-Z]{2,6}-\d{3}|SCN-[A-Z]{2,6}-\d{3}-\d+|ADR-\d{3})\b/g;
11
12
  const REQ_CAP = /^REQ-([A-Z]{2,6})-/;
@@ -69,6 +70,11 @@ function renamedTargets(root, file) {
69
70
  }
70
71
  return out;
71
72
  }
73
+ /** REQ-CHK-006 the header line of an entry as its file writes it, without the line end. */
74
+ function headerLine(root, e) {
75
+ const line = readFileSync(join(root, e.file), "utf8").split(/\r?\n/)[e.line - 1];
76
+ return (line ?? `### Requirement: ${e.id} ${e.title}`).trimEnd();
77
+ }
72
78
  /**
73
79
  * REQ-CHK-001 retitle-needs-renamed: a MODIFIED or REMOVED header of an active change whose ID exists in main
74
80
  * with another title, and whose delta file has no RENAMED TO line with that header. Upstream OpenSpec matches
@@ -91,10 +97,31 @@ export function retitleFindings(root, entries) {
91
97
  targets.set(e.file, renamedTargets(root, e.file));
92
98
  if (targets.get(e.file).some(([id, title]) => title === e.title && (id === null || id === e.id)))
93
99
  continue;
94
- out.push({ file: e.file, line: e.line, rule: "retitle-needs-renamed", id: e.id, detail: `"${m.title}" -> "${e.title}"` });
100
+ // REQ-CHK-006 the hint copies both header lines as the files write them, bare or in brackets (031/D7)
101
+ const hint = `Add to the delta before the MODIFIED block:\n## RENAMED Requirements\n- FROM: \`${headerLine(root, m)}\`\n- TO: \`${headerLine(root, e)}\``;
102
+ out.push({ file: e.file, line: e.line, rule: "retitle-needs-renamed", id: e.id, detail: `"${m.title}" -> "${e.title}"`, hint });
95
103
  }
96
104
  return out;
97
105
  }
106
+ /**
107
+ * REQ-CHK-005 weak-verify: a task of an active change whose Verify command always exits 0 (028/D2).
108
+ * A task with no Verify command has task-without-verify already, so it gets no second finding.
109
+ */
110
+ export function weakVerifyFindings(entries) {
111
+ const out = [];
112
+ for (const e of entries) {
113
+ if (e.kind !== "task" || e.change === "main" || e.file.includes("/archive/"))
114
+ continue;
115
+ const v = parseVerify(e.title);
116
+ if (v.command !== null && isWeakVerify(v.command))
117
+ out.push({ file: e.file, line: e.line, rule: "weak-verify", id: e.id });
118
+ }
119
+ return out;
120
+ }
121
+ /** REQ-CHK-005 the plain-mode form of weak-verify: one warning "weak-verify <task> <file>:<line>" per task (028/D3). */
122
+ export function weakVerifyWarnings(entries) {
123
+ return weakVerifyFindings(entries).map((f) => `weak-verify ${f.id} ${f.file}:${f.line}`);
124
+ }
98
125
  /** Sorted, unique findings. The scan never reads .ctx/, so evidence adds no index entry. */
99
126
  export function check(root, cfg, data, strict) {
100
127
  const entries = data.entries;
@@ -163,12 +190,36 @@ export function check(root, cfg, data, strict) {
163
190
  if (e.kind === "scn" && !named.has(e.id))
164
191
  findings.push([e.file, e.line, "scenario-untested", e.id]);
165
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
+ }
166
216
  findings.push(...evidenceFindings(root, entries, listEvidence(root)));
167
217
  }
168
218
  const uniq = new Map();
169
219
  for (const f of findings)
170
220
  uniq.set(JSON.stringify(f), f);
171
221
  const plain = [...uniq.values()].map(([file, line, rule, id]) => ({ file, line, rule, id }));
172
- const all = [...plain, ...retitleFindings(root, entries)];
222
+ // REQ-CHK-005 weak-verify is hard only in strict mode. Plain mode prints it as a warning (028/D3).
223
+ const all = [...plain, ...retitleFindings(root, entries), ...(strict ? weakVerifyFindings(entries) : [])];
173
224
  return all.sort((a, b) => cmpTuple([a.file, a.line, a.rule, a.id], [b.file, b.line, b.rule, b.id]));
174
225
  }
@@ -1,7 +1,30 @@
1
1
  // REQ-CHK-003 evidence rules of check --strict.
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileSha256 } from "../util/hash.js";
5
+ import { evidenceRel } from "../tasks/tasks.js";
6
+ /**
7
+ * REQ-CHK-003 REQ-LOOP-008 the stale paths of one evidence file, sorted. A damaged file is stale as a whole and
8
+ * returns its own path. check and next both read this, so the two never differ (031/D5).
9
+ */
10
+ export function staleFiles(root, rel) {
11
+ let files = {};
12
+ try {
13
+ const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
14
+ files = ev.files ?? {};
15
+ }
16
+ catch {
17
+ return [rel];
18
+ }
19
+ return Object.keys(files).filter((p) => fileSha256(join(root, p)) !== files[p]).sort();
20
+ }
21
+ /** REQ-LOOP-008 the first stale path of the evidence of a task, or null when it is fresh or absent (031/D5). */
22
+ export function staleOf(root, task) {
23
+ const rel = evidenceRel({ id: task });
24
+ if (!existsSync(join(root, rel)))
25
+ return null;
26
+ return staleFiles(root, rel)[0] ?? null;
27
+ }
5
28
  export function evidenceFindings(root, entries, evidence) {
6
29
  const out = [];
7
30
  const tasks = new Map();
@@ -27,19 +50,8 @@ export function evidenceFindings(root, entries, evidence) {
27
50
  // archived changes are exempt from stale-evidence
28
51
  if (!active)
29
52
  continue;
30
- let files = {};
31
- try {
32
- const ev = JSON.parse(readFileSync(join(root, rel), "utf8"));
33
- files = ev.files ?? {};
34
- }
35
- catch {
36
- out.push([rel, 1, "stale-evidence", `${id} ${rel}`]);
37
- continue;
38
- }
39
- for (const [path, digest] of Object.entries(files)) {
40
- if (fileSha256(join(root, path)) !== digest)
41
- out.push([rel, 1, "stale-evidence", `${id} ${path}`]);
42
- }
53
+ for (const path of staleFiles(root, rel))
54
+ out.push([rel, 1, "stale-evidence", `${id} ${path}`]);
43
55
  }
44
56
  for (const e of entries) {
45
57
  if (e.kind === "task" && e.status === "done" && !e.file.includes("/archive/") && !have.has(e.id)) {
@@ -9,16 +9,23 @@ 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
- "dangling-ref": { level: "hard", hint: "Fix the ID or remove the link. No entry has this ID." },
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
- "stale-evidence": { level: "hard", hint: "Run specctx task reopen and then specctx task done." },
24
+ // REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
25
+ "stale-evidence": { level: "hard", hint: "Run specctx task reopen <task> <why>, then specctx task done <task>." },
21
26
  "evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
27
+ // REQ-CHK-005 advisory in plain check. check --strict reports it as a hard finding (028/D5).
28
+ "weak-verify": { level: "advisory", hint: "Name a test file or a command whose exit code proves the task." },
22
29
  "manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
23
30
  // REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
24
31
  // REQ-ADOPT-010 FROM copies the main header and TO copies the delta header, each in its own form (027/D4).
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": {},
22
+ "handoff ask": { change: "string", plan: "bool" },
23
23
  "handoff resolve": {},
24
24
  adopt: { "dry-run": "bool" },
25
25
  "baseline write": {},
@@ -10,7 +10,12 @@ async function runCheck(root, cfg, data, strict) {
10
10
  const { applyBaseline } = await import("../baseline/baseline.js");
11
11
  const findings = check(root, cfg, data, strict);
12
12
  let warnings = [];
13
- if (strict) {
13
+ if (!strict) {
14
+ // REQ-CHK-005 plain check prints weak-verify as an advisory warning (028/D3)
15
+ const { weakVerifyWarnings } = await import("../check/check.js");
16
+ warnings = weakVerifyWarnings(data.entries);
17
+ }
18
+ else {
14
19
  const { manualWarnings } = await import("../check/evidence.js");
15
20
  const { listEvidence } = await import("../tasks/evidence.js");
16
21
  warnings = manualWarnings(root, data.entries, listEvidence(root));
@@ -35,16 +40,29 @@ export function checkText(r, tty) {
35
40
  for (const x of r.findings)
36
41
  count.set(x.rule, (count.get(x.rule) ?? 0) + 1);
37
42
  for (const w of r.warnings) {
38
- const [rule, task] = w.split(" ");
43
+ const [rule, task, at] = w.split(" ");
39
44
  count.set(rule, (count.get(rule) ?? 0) + 1);
45
+ // REQ-CHK-005 a weak-verify warning ends with <file>:<line> of the task line (028/D4)
46
+ if (rule === "weak-verify" && at)
47
+ files.add(at.replace(/:\d+$/, ""));
40
48
  // a manual-evidence warning names a task. Its file is the evidence file of the task.
41
- if (task?.includes("/"))
49
+ else if (task?.includes("/"))
42
50
  files.add(`.ctx/evidence/${task}.json`);
43
51
  }
52
+ // REQ-CHK-006 the distinct hints that findings carry, in finding order. A rule with none prints the table hint (031/D6).
53
+ const own = new Map();
54
+ for (const x of r.findings) {
55
+ if (x.hint === undefined)
56
+ continue;
57
+ const l = own.get(x.rule) ?? [];
58
+ if (!l.includes(x.hint))
59
+ l.push(x.hint);
60
+ own.set(x.rule, l);
61
+ }
44
62
  const rules = [...count.keys()].sort();
45
63
  return body
46
64
  + "\n" + paint("dim", `${n} finding${n === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"} (${r.findings.length} hard, ${r.warnings.length} advisory)\n`, tty)
47
- + rules.map((rule) => `${rule} ${count.get(rule)} ${hintOf(rule)}\n`).join("")
65
+ + rules.map((rule) => `${rule} ${count.get(rule)} ${own.get(rule)?.join("\n") ?? hintOf(rule)}\n`).join("")
48
66
  + base;
49
67
  }
50
68
  export async function run(p, io) {
@@ -55,16 +73,8 @@ export async function run(p, io) {
55
73
  return init(p, io);
56
74
  const root = findRoot(io.cwd, f.root);
57
75
  requireOpenSpec(root);
58
- const { loadConfig, ConfigError } = await import("../config.js");
59
- let cfg;
60
- try {
61
- cfg = loadConfig(root);
62
- }
63
- catch (e) {
64
- if (e instanceof ConfigError)
65
- throw new UsageExit(e.message);
66
- throw e;
67
- }
76
+ const { loadConfigOrExit } = await import("./config.js");
77
+ const cfg = loadConfigOrExit(root);
68
78
  const { dumps } = await import("../util/json.js");
69
79
  const print = (v) => io.out(dumps(v) + "\n");
70
80
  if (cmd === "build") {
@@ -129,6 +139,13 @@ export async function run(p, io) {
129
139
  if (cmd === "gate") {
130
140
  if (p.positional.length)
131
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
+ }
132
149
  const { runGate } = await import("./gate.js");
133
150
  const strict = !!f.strict;
134
151
  return runGate(root, {
@@ -182,9 +199,13 @@ export async function run(p, io) {
182
199
  const r = statusReport(root, entries);
183
200
  if (json)
184
201
  print(r);
185
- // REQ-CLI-008 the next: line is dim
186
- else
187
- io.out(statusLines(r).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
202
+ else {
203
+ // REQ-CLI-007 the next: line is the command of the decision of specctx next, so the two never disagree (030/D1)
204
+ const { decideNext } = await import("../loop/next.js");
205
+ const d = decideNext(root, cfg, data);
206
+ // REQ-CLI-008 the next: line is dim
207
+ io.out(statusLines(r, d.command).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
208
+ }
188
209
  return 0;
189
210
  }
190
211
  case "next": {
@@ -240,6 +261,21 @@ export async function run(p, io) {
240
261
  io.err(`unknown id(s): ${unknown.join(", ")}\n`);
241
262
  return 2;
242
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
+ }
243
279
  const b = bundle(root, entries, p.positional, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, all);
244
280
  if (json)
245
281
  print(b);
@@ -269,8 +305,10 @@ export async function run(p, io) {
269
305
  }
270
306
  case "check": {
271
307
  const r = await runCheck(root, cfg, data, !!f.strict);
308
+ // REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
309
+ const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
272
310
  if (json)
273
- print({ baselined: r.baselined, count: r.findings.length, findings: r.findings, warnings: r.warnings });
311
+ print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
274
312
  else
275
313
  io.out(checkText(r, io.tty));
276
314
  return r.findings.length ? 1 : 0;
@@ -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
+ }