spec-ctx 0.2.0 → 0.3.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 +7 -5
- package/dist/assets.generated.js +1 -1
- package/dist/baseline/baseline.js +6 -3
- package/dist/check/check.js +30 -2
- package/dist/check/evidence.js +26 -14
- package/dist/check/hints.js +4 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands.js +32 -8
- package/dist/cli/help.js +2 -1
- package/dist/cli/status.js +6 -15
- package/dist/cli/workflow.js +1 -1
- package/dist/handoff/handoff.js +22 -4
- package/dist/handoff/questions.js +3 -1
- package/dist/init/init.js +90 -14
- package/dist/loop/fails.js +11 -0
- package/dist/loop/next.js +25 -5
- package/dist/stats/stats.js +6 -3
- package/dist/verify/parse.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +4 -4
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
|
|
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 |
|
|
@@ -284,18 +286,18 @@ The action runs `specctx gate`: `check --strict`, both markdown linters, `opensp
|
|
|
284
286
|
|
|
285
287
|
## Status
|
|
286
288
|
|
|
287
|
-
- Published: `spec-ctx@0.
|
|
288
|
-
- Archived: 001 to 025
|
|
289
|
+
- Published: `spec-ctx@0.3.0` on npm, with tags `v0.3.0` and `v0` and binaries on the GitHub release.
|
|
290
|
+
- Archived: 001 to 025 and 027 to 032. No change is open. See `CHANGELOG.md` for each release.
|
|
289
291
|
- A version bump on `main` publishes the next release. See `docs/CONTRIBUTING.md`.
|
|
290
292
|
- 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
293
|
|
|
292
294
|
## Measured
|
|
293
295
|
|
|
294
|
-
`specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-
|
|
296
|
+
`specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-30.md`. Its total row:
|
|
295
297
|
|
|
296
298
|
| change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
|
|
297
299
|
| --- | --- | --- | --- | --- | --- |
|
|
298
|
-
| total | 0.
|
|
300
|
+
| total | 0.0281 (n=93) | 0.91 (166/182) | 43 (of 90 reopens) | 76 (n=93) | 0 (0 baselined + 0 new) |
|
|
299
301
|
|
|
300
302
|
- `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better.
|
|
301
303
|
- `evidence_ratio`: done tasks with a passed Verify command, divided by all done tasks. The rest have a manual note. Higher is better.
|
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": "
|
|
13
|
+
"skills/specctx-run/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLiBXaGVuIHRoYXQga2V5IGlzIGFic2VudCwgYG9wZW5zcGVjYCBpcyBgbnB4IC15IEBmaXNzaW9uLWFpL29wZW5zcGVjQGxhdGVzdGAuCgojIyBUaGUgbG9vcAoKMS4gUnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYC4gUmVhZCBgYWN0aW9uYCwgYGNoYW5nZWAgKGA8Yz5gKSwgYHRhc2tgIChgPHQ+YCksIGBjb21tYW5kYCwgYHJlYXNvbmAsIGFuZCBgaHVtYW5gLgoyLiBBY3Qgb24gYGFjdGlvbmAgYXMgdGhlIHRhYmxlIGJlbG93IHNheXMuCjMuIFJ1biBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KNC4gUmVwZWF0IGZyb20gc3RlcCAxIHVudGlsIGBhY3Rpb25gIGlzIGBzdG9wYCBvciBgaHVtYW5gIGlzIHRydWUuCgojIyBBY3Rpb25zCgotIGBwbGFuYDogdGFrZSB0aGUgY2hhbmdlIG5hbWUgZnJvbSB0aGUgcmVxdWVzdCBvZiB0aGUgdXNlciBpbiBrZWJhYiBjYXNlLiBUaGF0IG5hbWUgaXMgYDxjPmAuIEZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciBgPGM+YC4gUmVhZCBleGlzdGluZyByZXF1aXJlbWVudHMgd2l0aCBgc3BlY2N0eCBidW5kbGUgPGlkPmAgb3IgYHNwZWNjdHggcXVlcnlgLCBuZXZlciBgb3BlbnNwZWMgc2hvd2AuIFRha2UgZXZlcnkgSUQgZnJvbSBgc3BlY2N0eCBpZCBuZXcgUkVRIDxDQVA+YCBhbmQgYHNwZWNjdHggaWQgbmV3IFNDTiA8Q0FQPiAtLWZvciA8UkVRPmAuIFdoZW4gdGhlIGNoYW5nZSBuZWVkcyBzZXZlcmFsIElEcyBvZiBvbmUga2luZCwgcnVuIGBzcGVjY3R4IGlkIG5ldyAtLWNvdW50IDxuPmAgb25jZS4gR2l2ZSBldmVyeSB0YXNrIGEgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3Bhbi4gVGhlbiBydW4gYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgIGFuZCBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gVGhlIGdhdGUgcnVucyB0aGUgT3BlblNwZWMgdmFsaWRhdGUgYW5kIHRoZSBzcGVjLWxlbmd0aCBjaGVjay4gRml4IGV2ZXJ5IGZpbmRpbmcuIFRoZW4gcnVuIGBzcGVjY3R4IGhhbmRvZmYgYXNrIC0tY2hhbmdlIDxjPiAicGxhbiByZWFkeSBmb3IgPGM+LiBBcHByb3ZlIHRvIHN0YXJ0IHRoZSB0YXNrcz8iYC4gVGhpcyBpcyB0aGUgcGxhbiBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB1bnRpbCB0aGUgdXNlciByZXNvbHZlcyB0aGUgcXVlc3Rpb24uCi0gYGNpdGVgOiBydW4gYHNwZWNjdHggcXVlcnkgLS1jaGFuZ2UgPGM+IC0ta2luZCByZXFgLiBBZGQgdGhlIGBbUkVRLS4uLl1gIGNpdGF0aW9uIGFuZCBhIGBWZXJpZnk6YCBjb2RlIHNwYW4gdG8gdGhlIHRhc2sgbGluZSBuYW1lZCBpbiBgdGFza2AuIFJ1biBgc3BlY2N0eCBjaGVja2AuIENvbnRpbnVlLgotIGBzdGFydGA6IHJ1biBgc3BlY2N0eCB0YXNrIHN0YXJ0IDx0PmAuIFJlYWQgdGhlIGJ1bmRsZSBpdCBwcmludHMuIENvbnRpbnVlLgotIGB3b3JrYDogaW1wbGVtZW50IHRoZSB0YXNrLiBUYWcgY29kZSB3aXRoIGAvLyBSRVEtLi4uYC4gVGFnIHRlc3RzIHdpdGggYC8vIENvdmVyczogUkVRLS4uLmAuIFRoZW4gcnVuIGBzcGVjY3R4IHRhc2sgZG9uZSA8dD5gLgotIGBmaXhgOiByZWFkIGByZWFzb25gLiBJdCBob2xkcyB0aGUgdGFpbCBvZiB0aGUgZmFpbGVkIFZlcmlmeSBvdXRwdXQuIEZpeCB0aGUgY2F1c2UuIFJ1biBgc3BlY2N0eCB0YXNrIGRvbmUgPHQ+YCBhZ2Fpbi4KLSBgcmVwYWlyYDogcnVuIHRoZSBjb21tYW5kIGluIHRoZSBgY29tbWFuZGAgZmllbGQuIEl0IHJ1bnMgVmVyaWZ5IGFnYWluIGZvciBhIHRpY2tlZCB0YXNrIHdob3NlIGV2aWRlbmNlIGlzIGFic2VudCBvciBzdGFsZS4KLSBgZ2F0ZWA6IHJ1biBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gRml4IGV2ZXJ5IGZpbmRpbmcuIFJ1biBpdCBhZ2FpbiB1bnRpbCBpdCBleGl0cyAwLgotIGBhcmNoaXZlYDogd2hlbiBgaHVtYW5gIGlzIHRydWUsIHJ1biBgc3BlY2N0eCBoYW5kb2ZmIGFzayAtLWNoYW5nZSA8Yz4gImFyY2hpdmUgPGM+PyJgIGFuZCBzdG9wLiBUaGlzIGlzIHRoZSBhcmNoaXZlIHN0b3AuIGBuZXh0YCByZXR1cm5zIGBhc2tgIHVudGlsIHRoZSB1c2VyIHJlc29sdmVzIHRoZSBxdWVzdGlvbi4gV2hlbiBgaHVtYW5gIGlzIGZhbHNlLCB0aGUgdXNlciBhcHByb3ZlZCBvciBgW2xvb3BdIGFyY2hpdmVgIGlzIGBhdXRvYC4gVGhlbiBydW4gZWFjaCBzdGVwIHVuZGVyIGBvcGVyYXRpb25zLmFyY2hpdmUuZ3VpZGFuY2VgIGluIGBvcGVuc3BlYy9jb25maWcueWFtbGAuIFRoZW4gcnVuIHRoZSBjb21tYW5kIGluIHRoZSBgY29tbWFuZGAgZmllbGQuIFRoZW4gcnVuIGBvcGVuc3BlYyB2YWxpZGF0ZSAtLWFsbCAtLXN0cmljdGAsIGBzcGVjY3R4IGNoZWNrIC0tc3RyaWN0YCwgYW5kIGBzcGVjY3R4IGhhbmRvZmYgd3JpdGVgLgotIGBhc2tgOiBwcmludCBgcmVhc29uYC4gSXQgaXMgdGhlIG9wZW4gcXVlc3Rpb24uIFN0b3AuIFRoZSB1c2VyIGFuc3dlcnMgd2l0aCBgc3BlY2N0eCBoYW5kb2ZmIHJlc29sdmUgPG4+ICI8YW5zd2VyPiJgLgotIGBzdG9wYDogcHJpbnQgYHJlYXNvbmAuIFN0b3AuCgojIyBSdWxlcwoKLSBOZXZlciB0aWNrIGEgY2hlY2tib3ggYnkgaGFuZC4gYHNwZWNjdHggdGFzayBkb25lYCBpcyB0aGUgb25seSB3YXkgYSB0YXNrIGNsb3Nlcy4KLSBOZXZlciB1c2UgYC0tbWFudWFsYCBvbiBhIHRhc2sgd2l0aCBhIFZlcmlmeSBjb21tYW5kLgotIE5ldmVyIGNhbGwgYC9vcHN4OmFwcGx5YC4gVGhpcyBza2lsbCByZXBsYWNlcyBpdC4KLSBOZXZlciByZWFkIGBvcGVuc3BlYy9zcGVjcy9gIG9yIGBvcGVuc3BlYy9jaGFuZ2VzLyovc3BlY3MvYCBkaXJlY3RseS4gVXNlIHRoZSBidW5kbGUgZnJvbSBgdGFzayBzdGFydGAgb3IgYHNwZWNjdHggYnVuZGxlIDxpZD5gLgotIFN0b3Agb24gYW55IGBhY3Rpb25gIG5vdCBpbiB0aGUgbGlzdCBhYm92ZS4gUHJpbnQgdGhlIG9iamVjdCBhbmQgYXNrIHRoZSB1c2VyLgotIEtlZXAgbm8gc3RhdGUgaW4geW91ciBjb250ZXh0LiBBIG5ldyBzZXNzaW9uIHN0YXJ0cyB0aGUgc2FtZSBsb29wIGFnYWluIHdpdGggc3RlcCAxLgo=",
|
|
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
|
-
/**
|
|
11
|
-
|
|
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))
|
package/dist/check/check.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
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 });
|
|
95
118
|
}
|
|
96
119
|
return out;
|
|
97
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;
|
|
@@ -169,6 +196,7 @@ export function check(root, cfg, data, strict) {
|
|
|
169
196
|
for (const f of findings)
|
|
170
197
|
uniq.set(JSON.stringify(f), f);
|
|
171
198
|
const plain = [...uniq.values()].map(([file, line, rule, id]) => ({ file, line, rule, id }));
|
|
172
|
-
|
|
199
|
+
// REQ-CHK-005 weak-verify is hard only in strict mode. Plain mode prints it as a warning (028/D3).
|
|
200
|
+
const all = [...plain, ...retitleFindings(root, entries), ...(strict ? weakVerifyFindings(entries) : [])];
|
|
173
201
|
return all.sort((a, b) => cmpTuple([a.file, a.line, a.rule, a.id], [b.file, b.line, b.rule, b.id]));
|
|
174
202
|
}
|
package/dist/check/evidence.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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)) {
|
package/dist/check/hints.js
CHANGED
|
@@ -17,8 +17,11 @@ export const RULES = {
|
|
|
17
17
|
uncovered: { level: "hard", hint: "Add a test with a Covers tag for the requirement." },
|
|
18
18
|
"scenario-untested": { level: "hard", hint: "Name the scenario ID in a test." },
|
|
19
19
|
"done-without-evidence": { level: "hard", hint: "Run specctx task done. It runs the Verify command and writes the evidence." },
|
|
20
|
-
|
|
20
|
+
// REQ-CHK-006 the hint names the why argument of task reopen (031/D8)
|
|
21
|
+
"stale-evidence": { level: "hard", hint: "Run specctx task reopen <task> <why>, then specctx task done <task>." },
|
|
21
22
|
"evidence-without-done": { level: "hard", hint: "Run specctx task done, or remove the evidence file." },
|
|
23
|
+
// REQ-CHK-005 advisory in plain check. check --strict reports it as a hard finding (028/D5).
|
|
24
|
+
"weak-verify": { level: "advisory", hint: "Name a test file or a command whose exit code proves the task." },
|
|
22
25
|
"manual-evidence": { level: "advisory", hint: "Give the task a Verify command and run specctx task done again." },
|
|
23
26
|
// REQ-CHK-001 the hint is the block to paste. The finding line carries the ID and both titles (021/D3).
|
|
24
27
|
// 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
|
@@ -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" },
|
|
23
23
|
"handoff resolve": {},
|
|
24
24
|
adopt: { "dry-run": "bool" },
|
|
25
25
|
"baseline write": {},
|
package/dist/cli/commands.js
CHANGED
|
@@ -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) {
|
|
@@ -182,9 +200,13 @@ export async function run(p, io) {
|
|
|
182
200
|
const r = statusReport(root, entries);
|
|
183
201
|
if (json)
|
|
184
202
|
print(r);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
else {
|
|
204
|
+
// REQ-CLI-007 the next: line is the command of the decision of specctx next, so the two never disagree (030/D1)
|
|
205
|
+
const { decideNext } = await import("../loop/next.js");
|
|
206
|
+
const d = decideNext(root, cfg, data);
|
|
207
|
+
// REQ-CLI-008 the next: line is dim
|
|
208
|
+
io.out(statusLines(r, d.command).map((l) => (l.startsWith("next: ") ? paint("dim", l, io.tty) : l) + "\n").join(""));
|
|
209
|
+
}
|
|
188
210
|
return 0;
|
|
189
211
|
}
|
|
190
212
|
case "next": {
|
|
@@ -269,8 +291,10 @@ export async function run(p, io) {
|
|
|
269
291
|
}
|
|
270
292
|
case "check": {
|
|
271
293
|
const r = await runCheck(root, cfg, data, !!f.strict);
|
|
294
|
+
// REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
|
|
295
|
+
const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
|
|
272
296
|
if (json)
|
|
273
|
-
print({ baselined: r.baselined, count:
|
|
297
|
+
print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
|
|
274
298
|
else
|
|
275
299
|
io.out(checkText(r, io.tty));
|
|
276
300
|
return r.findings.length ? 1 : 0;
|
package/dist/cli/help.js
CHANGED
|
@@ -114,7 +114,8 @@ export const TABLE = [
|
|
|
114
114
|
{ 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
115
|
{
|
|
116
116
|
name: "handoff ask", group: "Write", args: "<question>", summary: "Add an open question to the handoff.",
|
|
117
|
-
flags: [
|
|
117
|
+
flags: [["--change <name>", "Give the question to this change, not to the current task. The key or the full folder name works."]],
|
|
118
|
+
exit: PLAIN, example: "specctx handoff ask --change 008 \"plan ready for 008-tasks. Approve to start the tasks?\"",
|
|
118
119
|
},
|
|
119
120
|
{
|
|
120
121
|
name: "handoff resolve", group: "Write", args: "<n> <answer>", summary: "Move open question n to the resolved list.",
|
package/dist/cli/status.js
CHANGED
|
@@ -64,7 +64,11 @@ export function statusReport(root, entries) {
|
|
|
64
64
|
open_questions: openQuestions(root),
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* REQ-CLI-007 the status lines. The last line is always `next: ` and the command of the decision
|
|
69
|
+
* of `specctx next`, or `none` when the decision has no command (030/D1, 030/D2).
|
|
70
|
+
*/
|
|
71
|
+
export function statusLines(r, next) {
|
|
68
72
|
const out = r.changes.map((c) => `${c.change} done ${c.done} open ${c.open}`);
|
|
69
73
|
if (!r.changes.length) {
|
|
70
74
|
out.push("no active change");
|
|
@@ -82,19 +86,6 @@ export function statusLines(r) {
|
|
|
82
86
|
for (const q of r.open_questions)
|
|
83
87
|
out.push(`- ${q}`);
|
|
84
88
|
}
|
|
85
|
-
|
|
86
|
-
if (next)
|
|
87
|
-
out.push(`next: ${next}`);
|
|
89
|
+
out.push(`next: ${next ?? "none"}`);
|
|
88
90
|
return out;
|
|
89
91
|
}
|
|
90
|
-
/** REQ-CLI-007 the next step by state: an open task, then a change with every task done, then uncovered REQs. */
|
|
91
|
-
export function nextStep(r) {
|
|
92
|
-
if (r.changes.some((c) => c.open > 0))
|
|
93
|
-
return "specctx task next";
|
|
94
|
-
const ready = r.changes.find((c) => c.done > 0 && c.open === 0);
|
|
95
|
-
if (ready)
|
|
96
|
-
return `archive ${ready.change}`;
|
|
97
|
-
if (r.uncovered > 0)
|
|
98
|
-
return "specctx check";
|
|
99
|
-
return null;
|
|
100
|
-
}
|
package/dist/cli/workflow.js
CHANGED
|
@@ -240,7 +240,7 @@ export async function runWorkflow(c) {
|
|
|
240
240
|
}
|
|
241
241
|
case "handoff ask": {
|
|
242
242
|
const { ask } = await import("../handoff/handoff.js");
|
|
243
|
-
const h = ask(c.root, entries, c.p.positional.join(" "));
|
|
243
|
+
const h = ask(c.root, entries, c.p.positional.join(" "), c.p.flags.change);
|
|
244
244
|
if (c.json)
|
|
245
245
|
print(c, { open_questions: h.open_questions });
|
|
246
246
|
else
|
package/dist/handoff/handoff.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { UsageExit } from "../cli/errors.js";
|
|
5
|
-
import { changeKey } from "../index/scan.js";
|
|
5
|
+
import { changeKey, matchesChange } from "../index/scan.js";
|
|
6
|
+
import { activeFolders } from "../loop/next.js";
|
|
6
7
|
import { listEvidence } from "../tasks/evidence.js";
|
|
7
8
|
import { activeChanges, nextTask } from "../tasks/tasks.js";
|
|
8
9
|
import { cmpStr, splitLines } from "../util/pystr.js";
|
|
@@ -144,13 +145,30 @@ function refresh(root, entries) {
|
|
|
144
145
|
save(root, h);
|
|
145
146
|
return h;
|
|
146
147
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
/**
|
|
149
|
+
* REQ-HND-002 append an open question to questions.json and render the handoff again (022/D6).
|
|
150
|
+
* REQ-LOOP-005 with a change, the question carries the active change folder, so next blocks the change (029/D1).
|
|
151
|
+
*/
|
|
152
|
+
export function ask(root, entries, text, change) {
|
|
153
|
+
let task = currentTask(root);
|
|
154
|
+
let stage;
|
|
155
|
+
if (change !== undefined) {
|
|
156
|
+
const folder = activeFolders(root).find((f) => matchesChange(f, change));
|
|
157
|
+
if (folder === undefined)
|
|
158
|
+
throw new UsageExit(`no active change ${change}`);
|
|
159
|
+
task = folder;
|
|
160
|
+
stage = stageOf(entries, folder);
|
|
161
|
+
}
|
|
162
|
+
const q = addQuestion(root, task, text, stage);
|
|
150
163
|
const h = refresh(root, entries);
|
|
151
164
|
h.asked = q.n;
|
|
152
165
|
return h;
|
|
153
166
|
}
|
|
167
|
+
/** REQ-LOOP-007 archive when the change has a task and every task is [x], plan otherwise. The text plays no part (031/D1). */
|
|
168
|
+
export function stageOf(entries, folder) {
|
|
169
|
+
const tasks = entries.filter((e) => e.kind === "task" && e.change === folder && !e.file.includes("/archive/"));
|
|
170
|
+
return tasks.length > 0 && tasks.every((t) => t.status === "done") ? "archive" : "plan";
|
|
171
|
+
}
|
|
154
172
|
/** REQ-HND-002 resolve question n in questions.json and render the handoff again (022/D6). */
|
|
155
173
|
export function resolve(root, entries, n, answer) {
|
|
156
174
|
resolveQuestion(root, n, answer);
|
|
@@ -33,13 +33,15 @@ export function oneLine(text) {
|
|
|
33
33
|
return text.replace(/\s*\n\s*/g, " ").trim();
|
|
34
34
|
}
|
|
35
35
|
/** REQ-HND-002 append an open question with the next n. Returns the new question. */
|
|
36
|
-
export function addQuestion(root, task, text) {
|
|
36
|
+
export function addQuestion(root, task, text, stage) {
|
|
37
37
|
const q = oneLine(text);
|
|
38
38
|
if (!q)
|
|
39
39
|
throw new UsageExit("handoff ask needs a question");
|
|
40
40
|
const qs = readQuestions(root);
|
|
41
41
|
const n = qs.reduce((m, x) => Math.max(m, x.n), 0) + 1;
|
|
42
42
|
const item = { n, asked_at: isoNow(), task, text: q, status: "open" };
|
|
43
|
+
if (stage !== undefined)
|
|
44
|
+
item.stage = stage;
|
|
43
45
|
qs.push(item);
|
|
44
46
|
writeQuestions(root, qs);
|
|
45
47
|
return item;
|
package/dist/init/init.js
CHANGED
|
@@ -4,7 +4,7 @@ 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 } from "../config.js";
|
|
7
|
+
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT, loadConfig } from "../config.js";
|
|
8
8
|
export const SCHEMA = "spec-driven-ids";
|
|
9
9
|
/** 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
10
|
export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
|
|
@@ -51,13 +51,34 @@ export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
|
51
51
|
text = text.replace(`\nopenspec = "${DEFAULT_OPENSPEC}"\n`, `\nopenspec = ${JSON.stringify(openspec)}\n`);
|
|
52
52
|
return text;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
|
|
56
|
+
* An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
|
|
57
|
+
*/
|
|
58
|
+
export function bracketForm(text) {
|
|
59
|
+
return text.replace(/(### Requirement: |#### Scenario: )((?:REQ|SCN)-[^\s\]"'`]+)/g, "$1[$2]");
|
|
60
|
+
}
|
|
61
|
+
/** REQ-SCH-006 the bytes of one template in the form that `brackets` names (032/D1). */
|
|
62
|
+
export function templateBytes(rel, brackets) {
|
|
63
|
+
const b = asset(rel);
|
|
64
|
+
return brackets ? Buffer.from(bracketForm(b.toString("utf8"))) : b;
|
|
65
|
+
}
|
|
66
|
+
/** REQ-SCH-006 the config fragment in the form that `brackets` names (032/D1). */
|
|
67
|
+
export function fragmentText(brackets) {
|
|
68
|
+
const t = asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8");
|
|
69
|
+
return brackets ? bracketForm(t) : t;
|
|
70
|
+
}
|
|
71
|
+
/** REQ-SCH-006 the value of [ids] brackets, false when spec-ctx.toml is absent (032/D2). */
|
|
72
|
+
export function bracketsOf(root) {
|
|
73
|
+
return existsSync(join(root, CONFIG_FILE)) ? loadConfig(root).ids.brackets : false;
|
|
74
|
+
}
|
|
54
75
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
55
|
-
export function plan(o, root, openspec = DEFAULT_OPENSPEC) {
|
|
76
|
+
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
|
|
56
77
|
const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
|
|
57
78
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
58
79
|
// REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
|
|
59
80
|
if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
|
|
60
|
-
out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`,
|
|
81
|
+
out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`, templateBytes(rel, brackets)]);
|
|
61
82
|
}
|
|
62
83
|
if (o.skills && rel.startsWith("skills/")) {
|
|
63
84
|
for (const d of SKILL_DIRS)
|
|
@@ -268,20 +289,26 @@ export const LEGACY_SCHEMA_NOTE = `note: schema ${SCHEMA} is no longer installed
|
|
|
268
289
|
* REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
|
|
269
290
|
* A key that exists gets the fragment items it lacks (022/D4). The schema key stays as the project wrote it (025/D5).
|
|
270
291
|
*/
|
|
271
|
-
export function mergeConfig(current, fragment) {
|
|
292
|
+
export function mergeConfig(current, fragment, other) {
|
|
272
293
|
let text = current ?? "";
|
|
273
294
|
const added = [];
|
|
274
295
|
const kept = [];
|
|
275
296
|
const merged = [];
|
|
276
297
|
const have = topKeys(text);
|
|
277
|
-
|
|
298
|
+
const blocks = topBlocks(fragment);
|
|
299
|
+
// REQ-SCH-007 an item that holds the other bracket form gets the current form in place (032/D4)
|
|
300
|
+
const swapped = other === undefined ? { text, size: 0, get: (_k) => undefined } : swapItems(text, blocks, topBlocks(other));
|
|
301
|
+
if (swapped.size)
|
|
302
|
+
text = swapped.text;
|
|
303
|
+
for (const [key, block] of blocks) {
|
|
278
304
|
if (have.has(key)) {
|
|
279
305
|
const lines = text.split("\n");
|
|
280
306
|
const n = mergeItems(lines, key, block);
|
|
281
|
-
|
|
307
|
+
const r = swapped.get(key) ?? 0;
|
|
308
|
+
if (n)
|
|
282
309
|
text = lines.join("\n");
|
|
283
|
-
|
|
284
|
-
|
|
310
|
+
if (n || r)
|
|
311
|
+
merged.push(r ? { key, added: n, replaced: r } : { key, added: n });
|
|
285
312
|
else
|
|
286
313
|
kept.push(key);
|
|
287
314
|
continue;
|
|
@@ -296,6 +323,46 @@ export function mergeConfig(current, fragment) {
|
|
|
296
323
|
}
|
|
297
324
|
return { text, added, kept, merged };
|
|
298
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* REQ-SCH-007 replace each project item whose value equals an item of the other form with the line of the current form (032/D4).
|
|
328
|
+
* The two fragments have the same lists in the same order, so items pair by position. The indent of the project line stays.
|
|
329
|
+
* Returns the new text and the count per top key.
|
|
330
|
+
*/
|
|
331
|
+
function swapItems(text, cur, other) {
|
|
332
|
+
const swap = new Map();
|
|
333
|
+
for (const [key, block] of cur) {
|
|
334
|
+
const a = fragmentLists(block);
|
|
335
|
+
const b = fragmentLists(other.get(key) ?? "");
|
|
336
|
+
a.forEach((list, i) => list.items.forEach((item, j) => {
|
|
337
|
+
const o = b[i]?.items[j];
|
|
338
|
+
if (o !== undefined && itemValue(o) !== itemValue(item))
|
|
339
|
+
swap.set(`${key}\0${itemValue(o)}`, item.trim());
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
const counts = new Map();
|
|
343
|
+
if (!swap.size)
|
|
344
|
+
return { text, size: 0, get: (k) => counts.get(k) };
|
|
345
|
+
let top = null;
|
|
346
|
+
const lines = text.split("\n").map((line) => {
|
|
347
|
+
const m = TOP_KEY.exec(line);
|
|
348
|
+
if (m)
|
|
349
|
+
top = m[1];
|
|
350
|
+
if (!top || !/^[ \t]+- /.test(line))
|
|
351
|
+
return line;
|
|
352
|
+
const to = swap.get(`${top}\0${itemValue(line)}`);
|
|
353
|
+
if (to === undefined)
|
|
354
|
+
return line;
|
|
355
|
+
counts.set(top, (counts.get(top) ?? 0) + 1);
|
|
356
|
+
return " ".repeat(indentOf(line)) + to;
|
|
357
|
+
});
|
|
358
|
+
return { text: lines.join("\n"), size: counts.size, get: (k) => counts.get(k) };
|
|
359
|
+
}
|
|
360
|
+
/** REQ-SCH-007 the text of the `merged` count: added, replaced, or both (032/D4). */
|
|
361
|
+
export function mergedCount(x) {
|
|
362
|
+
if (!x.replaced)
|
|
363
|
+
return `${x.added} added`;
|
|
364
|
+
return x.added ? `${x.added} added, ${x.replaced} replaced` : `${x.replaced} replaced`;
|
|
365
|
+
}
|
|
299
366
|
/** REQ-DIST-002 the four lines init owns in .gitignore (017/D2, 022/D5). current.json and gate-log.jsonl are per session. */
|
|
300
367
|
export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md", ".ctx/current.json", ".ctx/gate-log.jsonl"];
|
|
301
368
|
const GITIGNORE = ".gitignore";
|
|
@@ -344,7 +411,11 @@ export function runInit(root, o) {
|
|
|
344
411
|
if (d.note)
|
|
345
412
|
res.notes.push(d.note);
|
|
346
413
|
}
|
|
347
|
-
|
|
414
|
+
// REQ-SCH-006 the form of the templates and the rules follows [ids] brackets (032/D2)
|
|
415
|
+
const brackets = bracketsOf(root);
|
|
416
|
+
const fragment = fragmentText(brackets);
|
|
417
|
+
const otherFragment = fragmentText(!brackets);
|
|
418
|
+
let files = plan(o, root, openspec, brackets);
|
|
348
419
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
349
420
|
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
350
421
|
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
@@ -352,7 +423,7 @@ export function runInit(root, o) {
|
|
|
352
423
|
if (same && ignoreDone && !o.force) {
|
|
353
424
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
354
425
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
355
|
-
const m = o.schema ? mergeConfig(cur,
|
|
426
|
+
const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
|
|
356
427
|
if (!m || (!m.added.length && !m.merged.length)) {
|
|
357
428
|
res.nothing = true;
|
|
358
429
|
if (o.skills && ignoresClaude(root))
|
|
@@ -370,11 +441,16 @@ export function runInit(root, o) {
|
|
|
370
441
|
res.kept.push(CONFIG_FILE);
|
|
371
442
|
}
|
|
372
443
|
if (!o.force) {
|
|
373
|
-
|
|
444
|
+
// REQ-SCH-007 a template that holds the other bracket form is unchanged, so it gets the current form (032/D3)
|
|
445
|
+
const other = new Map(plan(o, root, openspec, !brackets).filter(([rel]) => rel.startsWith(`${TEMPLATES_DIR}/`)));
|
|
446
|
+
const differs = (rel, data) => {
|
|
447
|
+
const have = readFileSync(join(root, rel));
|
|
448
|
+
return !have.equals(data) && !(other.get(rel)?.equals(have) ?? false);
|
|
449
|
+
};
|
|
374
450
|
res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
|
|
375
451
|
for (const rel of res.changed)
|
|
376
452
|
res.kept.push(`${rel} (other content)`);
|
|
377
|
-
files = files.filter(([rel]) => !existsSync(join(root, rel)));
|
|
453
|
+
files = files.filter(([rel, data]) => !existsSync(join(root, rel)) || (!readFileSync(join(root, rel)).equals(data) && !differs(rel, data)));
|
|
378
454
|
}
|
|
379
455
|
for (const [rel, data] of files) {
|
|
380
456
|
const p = join(root, rel);
|
|
@@ -385,7 +461,7 @@ export function runInit(root, o) {
|
|
|
385
461
|
if (o.schema) {
|
|
386
462
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
387
463
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
388
|
-
const m = mergeConfig(cur,
|
|
464
|
+
const m = mergeConfig(cur, fragment, otherFragment);
|
|
389
465
|
if (m.added.length || m.merged.length) {
|
|
390
466
|
mkdirSync(dirname(cfgPath), { recursive: true });
|
|
391
467
|
writeFileSync(cfgPath, m.text);
|
|
@@ -394,7 +470,7 @@ export function runInit(root, o) {
|
|
|
394
470
|
}
|
|
395
471
|
// REQ-SCH-003 one line per key that gained items (022/D4)
|
|
396
472
|
for (const x of m.merged)
|
|
397
|
-
res.merged.push(`openspec/config.yaml ${x.key} (${x
|
|
473
|
+
res.merged.push(`openspec/config.yaml ${x.key} (${mergedCount(x)})`);
|
|
398
474
|
for (const k of m.kept)
|
|
399
475
|
res.kept.push(`openspec/config.yaml ${k}`);
|
|
400
476
|
// REQ-SCH-003 the schema line is the user's. A legacy value gets a note and stays (025/D5)
|
package/dist/loop/fails.js
CHANGED
|
@@ -89,3 +89,14 @@ export function capQuestion(task, attempts, tail) {
|
|
|
89
89
|
export function blockedLine(n) {
|
|
90
90
|
return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:` or `FAIL` after
|
|
94
|
+
* leading spaces, else the last non-empty line, else `no output`. The match is case sensitive.
|
|
95
|
+
*/
|
|
96
|
+
export function failLine(tail) {
|
|
97
|
+
const lines = tail.split("\n").map((l) => l.trimEnd());
|
|
98
|
+
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL)/.test(l)).at(-1);
|
|
99
|
+
if (hit !== undefined)
|
|
100
|
+
return hit.trim();
|
|
101
|
+
return lines.filter((l) => l.trim()).at(-1)?.trim() ?? "no output";
|
|
102
|
+
}
|
package/dist/loop/next.js
CHANGED
|
@@ -3,20 +3,22 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { applyBaseline } from "../baseline/baseline.js";
|
|
5
5
|
import { check } from "../check/check.js";
|
|
6
|
+
import { staleOf } from "../check/evidence.js";
|
|
6
7
|
import { readQuestions } from "../handoff/questions.js";
|
|
7
8
|
import { changeKey, matchesChange } from "../index/scan.js";
|
|
8
9
|
import { listEvidence } from "../tasks/evidence.js";
|
|
9
10
|
import { hasEvidence, nextTask, tasksOf } from "../tasks/tasks.js";
|
|
10
11
|
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
11
|
-
import { capQuestion, failsInWindow, openQuestionFor } from "./fails.js";
|
|
12
|
+
import { capQuestion, failLine, failsInWindow, openQuestionFor } from "./fails.js";
|
|
12
13
|
import { openspecCommand, openspecStatus } from "./openspec.js";
|
|
13
14
|
/** The closed action list. A skill stops on any value outside it. */
|
|
14
15
|
export const ACTIONS = ["plan", "cite", "start", "work", "fix", "repair", "gate", "archive", "ask", "stop"];
|
|
15
16
|
/** The keys of the object, sorted. Every state prints all of them. */
|
|
16
17
|
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task"];
|
|
17
18
|
/** The rules of check whose finding means a task or a header lacks its citation (024/D2 step 4). */
|
|
18
|
-
|
|
19
|
-
const
|
|
19
|
+
// REQ-LOOP-004 a weak Verify command is a citation gap too (028/D7)
|
|
20
|
+
const CITE_RULES = new Set(["missing-id", "task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
21
|
+
const TASK_RULES = new Set(["task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
20
22
|
/** The active change folders under openspec/changes/, sorted. The file system decides, so a change with no tasks.md counts. */
|
|
21
23
|
export function activeFolders(root) {
|
|
22
24
|
const dir = join(root, "openspec", "changes");
|
|
@@ -138,7 +140,8 @@ export function decideNext(root, cfg, data, want) {
|
|
|
138
140
|
if (current) {
|
|
139
141
|
const fails = failsInWindow(root, current.id, qs);
|
|
140
142
|
const last = fails.at(-1);
|
|
141
|
-
|
|
143
|
+
// 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;
|
|
142
145
|
if (fails.length >= cfg.loop.max_attempts && !openQuestionFor(root, current.id, qs)) {
|
|
143
146
|
const q = capQuestion(current.id, fails.length, last?.file.stderr_tail ?? "");
|
|
144
147
|
return done({ action: "ask", attempt: fails.length, command: `specctx handoff ask "${q}"`, human: true, reason, state: "attempts_exhausted", task: current.id });
|
|
@@ -147,6 +150,15 @@ export function decideNext(root, cfg, data, want) {
|
|
|
147
150
|
return done({ action: "fix", attempt: fails.length, command: `specctx task done ${current.id}`, reason, state: "task_failed", task: current.id });
|
|
148
151
|
return done({ action: "work", command: `specctx task done ${current.id}`, state: "task_started", task: current.id });
|
|
149
152
|
}
|
|
153
|
+
// REQ-LOOP-008 step 6b: the earliest done task whose evidence went stale gets the repair run, after the current task (031/D4, 031/D5)
|
|
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
|
+
continue;
|
|
160
|
+
return done({ action: "repair", command: `specctx task reopen ${t.id} "stale evidence: ${path}" && specctx task done ${t.id}`, reason: `stale-evidence ${t.id} ${path}`, state: "evidence_stale", task: t.id });
|
|
161
|
+
}
|
|
150
162
|
// step 7: the next open task
|
|
151
163
|
const open = nextTask(root, entries, folder);
|
|
152
164
|
if (open)
|
|
@@ -157,7 +169,15 @@ export function decideNext(root, cfg, data, want) {
|
|
|
157
169
|
return done({ action: "gate", command: "specctx gate --strict", state: "gate_due" });
|
|
158
170
|
}
|
|
159
171
|
// step 9: archive, with the human stop from [loop] archive
|
|
160
|
-
|
|
172
|
+
const archive = `${openspec} archive ${folder} -y`;
|
|
173
|
+
if (cfg.loop.archive !== "ask")
|
|
174
|
+
return done({ action: "archive", command: archive, human: false, state: "gate_passed" });
|
|
175
|
+
// REQ-LOOP-007 a resolved archive question after the newest evidence is the approval. The stage field decides, not the text (031/D2).
|
|
176
|
+
const newest = newestEvidenceAt(root, key);
|
|
177
|
+
const approved = qs.filter((q) => q.status === "resolved" && q.task === folder && q.stage === "archive" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, newest) > 0).at(-1);
|
|
178
|
+
if (approved)
|
|
179
|
+
return done({ action: "archive", command: archive, human: false, reason: `archive approved in question ${approved.n}`, state: "gate_passed" });
|
|
180
|
+
return done({ action: "archive", command: `specctx handoff ask --change ${folder} "archive ${folder}?"`, human: true, state: "gate_passed" });
|
|
161
181
|
}
|
|
162
182
|
/** REQ-LOOP-001 REQ-CLI-007 the human form: action first, one line per key, next last (024/D1). Strict mode. */
|
|
163
183
|
export function renderNext(d) {
|
package/dist/stats/stats.js
CHANGED
|
@@ -5,7 +5,10 @@ import { readBaseline } from "../baseline/baseline.js";
|
|
|
5
5
|
import { changeKey, matchesChange } from "../index/scan.js";
|
|
6
6
|
import { isFailFile } from "../tasks/evidence.js";
|
|
7
7
|
import { cmpStr, cpLen } from "../util/pystr.js";
|
|
8
|
-
|
|
8
|
+
// REQ-STAT-004 the two Verify rules count as debt too, the same rules the baseline can hold (029/D5)
|
|
9
|
+
export const DEBT_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-verify", "weak-verify"];
|
|
10
|
+
/** The debt rules whose ID is a task. A change row counts them. */
|
|
11
|
+
const TASK_DEBT_RULES = ["done-without-evidence", "manual-evidence", "task-without-verify", "weak-verify"];
|
|
9
12
|
const NA = (n = 0) => ({ value: null, n, text: "n/a" });
|
|
10
13
|
function readEvidence(root) {
|
|
11
14
|
const dir = join(root, ".ctx/evidence");
|
|
@@ -120,11 +123,11 @@ export function computeStats(root, entries, strictFindings, warnings, only) {
|
|
|
120
123
|
];
|
|
121
124
|
const fresh = [...new Set(current)].filter((k) => !base.has(k));
|
|
122
125
|
const debtAll = { value: base.size + fresh.length, n: base.size + fresh.length, text: `${base.size + fresh.length} (${base.size} baselined + ${fresh.length} new)` };
|
|
123
|
-
// a change row counts the task scoped debt:
|
|
126
|
+
// a change row counts the task scoped debt: no evidence, manual evidence, no Verify, and a weak Verify (029/D5)
|
|
124
127
|
const taskDebt = (change) => {
|
|
125
128
|
const keys = [...base, ...fresh].filter((k) => {
|
|
126
129
|
const [rule, id] = k.split(" ");
|
|
127
|
-
return (rule
|
|
130
|
+
return TASK_DEBT_RULES.includes(rule ?? "") && nameOf(id ?? "") === change;
|
|
128
131
|
});
|
|
129
132
|
return { value: keys.length, n: keys.length, text: String(keys.length) };
|
|
130
133
|
};
|
package/dist/verify/parse.js
CHANGED
|
@@ -56,3 +56,9 @@ export function parseVerify(taskLine) {
|
|
|
56
56
|
}
|
|
57
57
|
return { kind: "command", command: spans.join(" && ") };
|
|
58
58
|
}
|
|
59
|
+
// REQ-CHK-005 a Verify command that always exits 0: `true`, `:`, `exit 0`, or one `echo` with no `;`, `&`, `|` (028/D1)
|
|
60
|
+
const WEAK_VERIFY = /^(?:true|:|exit\s+0|echo(?:\s[^;&|\n]*)?)$/;
|
|
61
|
+
/** REQ-CHK-005 true when the command, trimmed, is one of the weak forms. */
|
|
62
|
+
export function isWeakVerify(command) {
|
|
63
|
+
return WEAK_VERIFY.test(command.trim());
|
|
64
|
+
}
|
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.3.0";
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ description: Run the spec-ctx loop in an OpenSpec project. Use on "run", "contin
|
|
|
5
5
|
|
|
6
6
|
# specctx-run
|
|
7
7
|
|
|
8
|
-
Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the command in `[gate] openspec` of `spec-ctx.toml`.
|
|
8
|
+
Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the command in `[gate] openspec` of `spec-ctx.toml`. When that key is absent, `openspec` is `npx -y @fission-ai/openspec@latest`.
|
|
9
9
|
|
|
10
10
|
## The loop
|
|
11
11
|
|
|
@@ -16,14 +16,14 @@ Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the comm
|
|
|
16
16
|
|
|
17
17
|
## Actions
|
|
18
18
|
|
|
19
|
-
- `plan`:
|
|
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 handoff ask --change <c> "plan ready for <c>. Approve to start the tasks?"`. This is the plan stop. `next` returns `ask` 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
|
-
- `repair`: run
|
|
24
|
+
- `repair`: run the command in the `command` field. It runs Verify again for a ticked task whose evidence is absent or stale.
|
|
25
25
|
- `gate`: run `specctx gate --strict`. Fix every finding. Run it again until it exits 0.
|
|
26
|
-
- `archive`: when `human` is true,
|
|
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
|
|