spec-ctx 0.5.0 → 0.6.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
@@ -116,7 +116,7 @@ You see: one change, two tasks done, one open, and the hint.
116
116
 
117
117
  ### 3. What is next
118
118
 
119
- `task next` picks the first open task. It prints the requirements the task cites and its Verify command, the shell command that proves the task is done.
119
+ `task next` picks the first open task. It prints the requirements the task cites and its Verify command, the shell command that proves the task works.
120
120
 
121
121
  ![Task add-mfa/1.3 with its requirement and Verify command](https://raw.githubusercontent.com/sonqh/spec-ctx/main/docs/demo/clips/3-next.svg)
122
122
 
@@ -132,7 +132,7 @@ You see: 711 characters, 3.6 percent of the tree. The agent reads this instead o
132
132
 
133
133
  ### 5. Done means Verify passed
134
134
 
135
- `task done` runs the Verify command. On exit 1 the box stays open. On exit 0 the box flips to `[x]` and an evidence file records the files that were checked.
135
+ `task done` runs the Verify command. On exit 1 the box stays open. On exit 0 the box flips to `[x]` and an evidence file records the files that it checked.
136
136
 
137
137
  ![A refused done, the edit, and a passing done](https://raw.githubusercontent.com/sonqh/spec-ctx/main/docs/demo/clips/5-done.svg)
138
138
 
@@ -165,6 +165,16 @@ npx -y spec-ctx task done <task> # runs that command. A pass ticks the bo
165
165
  - [`docs/getting-started.md`](https://github.com/sonqh/spec-ctx/blob/main/docs/getting-started.md) walks the fresh path with real output.
166
166
  - [`docs/reference.md`](https://github.com/sonqh/spec-ctx/blob/main/docs/reference.md) holds the install options, the path for an existing project, every command, and the config file.
167
167
 
168
+ ## Try it on your own OpenSpec repo
169
+
170
+ ```bash
171
+ npx -y spec-ctx init --all # config, ID templates, rules, and the agent skill
172
+ npx -y spec-ctx adopt # gives an ID to every header without one
173
+ npx -y spec-ctx context <task> # the specs, code, and tests that this task needs
174
+ ```
175
+
176
+ `init` prints the next command, from the state of your specs. To gate the old debt later, run `npx -y spec-ctx baseline write`, commit it with a subject that starts with `baseline:`, and run `npx -y spec-ctx gate --strict`.
177
+
168
178
  ## How it finds the context
169
179
 
170
180
  ```mermaid
@@ -178,7 +188,7 @@ graph LR
178
188
 
179
189
  The ID is the stable key. A reword of a header does not change it. The index is the graph of IDs: tasks, requirements, scenarios, decisions, code tags, and test tags. A decision record joins the graph through its `Refs:` line.
180
190
 
181
- `specctx context <task|ID|path|change>` is the one command to run before you edit. It walks the edges inside a character budget and prints one provenance line for each block. When a block does not fit, a `truncated:` line names it. `related` prints each neighbor and the reason for the link:
191
+ `specctx context <task|ID|path|change>` is the one command to run before you edit. It walks the edges inside a character budget and prints one provenance line for each block. Each entry prints its card first. A card carries its trust labels: the provenance of the link (`declared`, `tagged`, `structural`, or `verified`) and, for a task or a requirement, the evidence state (`fresh`, `stale since <path>`, `no evidence`, `verified`, `unverified`). A card ends with `| body omitted` when its body does not fit, and `bundle <ID>` gets the body. A `truncated:` line names an entry that has no card. A task that cites no requirement gets a `note:` line. A task also gets one card for each file that its line names, with the reason `named by <task>`. `related` prints each neighbor and the reason for the link:
182
192
 
183
193
  ```text
184
194
  $ specctx related REQ-UA-004
@@ -225,17 +235,17 @@ Tests and offline machines set `SPECCTX_OPENSPEC` to the command that runs OpenS
225
235
 
226
236
  ## Status
227
237
 
228
- - Published: `spec-ctx@0.5.0` on npm, with binaries on the GitHub release. See `CHANGELOG.md` for each release.
229
- - Archived: 001 to 025, 027 to 034, 036 to 053, and 055 to 057. No change is open.
230
- - Upstream: the ID scheme is proposed in [OpenSpec#1967](https://github.com/Fission-AI/OpenSpec/issues/1967#issuecomment-6414314730). The schema bundle is in the community table of [OpenSpec#2157](https://github.com/Fission-AI/OpenSpec/issues/2157).
238
+ - Published: `spec-ctx@0.6.0` on npm, with binaries on the GitHub release. See `CHANGELOG.md` for each release.
239
+ - Archived: 001 to 025, 027 to 034, and 036 to 063. No change is open.
240
+ - Upstream: the ID scheme has a proposal in [OpenSpec#1967](https://github.com/Fission-AI/OpenSpec/issues/1967#issuecomment-6414314730). The schema bundle is in the community table of [OpenSpec#2157](https://github.com/Fission-AI/OpenSpec/issues/2157).
231
241
 
232
242
  ## Measured
233
243
 
234
- `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-11-05.md`. Its total row:
244
+ `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-11-07.md`. Its total row:
235
245
 
236
246
  | change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
237
247
  | --- | --- | --- | --- | --- | --- |
238
- | total | 0.0164 (n=154) | 0.94 (257/273) | 65 (of 122 reopens) | 61 (n=154) | 0 (0 baselined + 0 new) |
248
+ | total | 0.0146 (n=168) | 0.95 (286/302) | 82 (of 151 reopens) | 54 (n=168) | 0 (0 baselined + 0 new) |
239
249
 
240
250
  - `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better. Change 047 on 2026-10-30 made the bundle hold more, so older values do not compare.
241
251
  - `evidence_ratio`: done tasks with a passed Verify command, divided by all done tasks. Higher is better.
@@ -84,20 +84,40 @@ function parse(root, rel, cap) {
84
84
  });
85
85
  return { rel, cap, lines, blocks, renames };
86
86
  }
87
- /** REQ-ADOPT-004 candidate codes in order: the base code, then two and three letters of the first segment, then a suffix letter. */
88
- export function codeCandidates(path) {
87
+ /** REQ-ADOPT-012 the family code of a path with two or more segments: three letters of the first, three of the rest. */
88
+ export function familyCode(path) {
89
+ const segs = path.split(/[/-]/).map((x) => x.toUpperCase().replace(/[^A-Z]/g, "")).filter(Boolean);
90
+ if (segs.length < 2)
91
+ return null;
92
+ const code = segs[0].slice(0, 3) + segs.slice(1).join("").slice(0, 3);
93
+ return /^[A-Z]{2,6}$/.test(code) ? code : null;
94
+ }
95
+ /** REQ-ADOPT-012 the first segment of a path, the key of its family */
96
+ export function familySegment(path) {
97
+ return path.split(/[/-]/)[0] ?? path;
98
+ }
99
+ /** REQ-ADOPT-012 the segments shared by three or more paths */
100
+ export function familiesOf(paths) {
101
+ const count = new Map();
102
+ for (const p of paths)
103
+ count.set(familySegment(p), (count.get(familySegment(p)) ?? 0) + 1);
104
+ return new Set([...count].filter(([, n]) => n >= 3).map(([s]) => s));
105
+ }
106
+ /** REQ-ADOPT-004 candidate codes in order: the base code, then two and three letters of the first segment, then a suffix letter. REQ-ADOPT-012 a family member gets its family code first. */
107
+ export function codeCandidates(path, family = false) {
89
108
  const segs = path.split(/[/-]/).map((x) => x.toUpperCase().replace(/[^A-Z]/g, "")).filter(Boolean);
90
109
  if (!segs.length)
91
110
  return [];
92
111
  const rest = segs.slice(1).map((x) => x[0]).join("");
93
112
  const base = segs.length === 1 ? segs[0].slice(0, 3) : segs.map((x) => x[0]).join("").slice(0, 6);
94
- const out = [base, (segs[0].slice(0, 2) + rest).slice(0, 6), (segs[0].slice(0, 3) + rest).slice(0, 6)];
113
+ const fam = family ? familyCode(path) : null;
114
+ const out = [...(fam ? [fam] : []), base, (segs[0].slice(0, 2) + rest).slice(0, 6), (segs[0].slice(0, 3) + rest).slice(0, 6)];
95
115
  for (let c = 66; c <= 90; c++)
96
116
  out.push((base.slice(0, 5) + String.fromCharCode(c)));
97
117
  return [...new Set(out.filter((x) => /^[A-Z]{2,6}$/.test(x)))];
98
118
  }
99
- export function deriveCode(path, taken = new Set()) {
100
- const code = codeCandidates(path).find((c) => !taken.has(c));
119
+ export function deriveCode(path, taken = new Set(), family = false) {
120
+ const code = codeCandidates(path, family).find((c) => !taken.has(c));
101
121
  if (!code)
102
122
  throw new UsageExit(`no free capability code for ${path}. Add one under [ids.paths] in spec-ctx.toml`);
103
123
  return code;
@@ -125,6 +145,8 @@ export function planAdopt(root, cfg) {
125
145
  // on a collision the shorter path keeps the base code: user-auth keeps UA, upload-api takes UPA
126
146
  const capList = [...new Set(files.map((f) => f.cap))].sort((a, b) => a.length - b.length || cmpStr(a, b));
127
147
  const taken = new Set([...cfg.ids.caps, ...codeOfPath.values()]);
148
+ // REQ-ADOPT-012 a family is three or more paths with one first segment (063/D1)
149
+ const families = familiesOf(capList);
128
150
  const caps = [];
129
151
  for (const cap of capList) {
130
152
  let code = codeOfPath.get(cap);
@@ -135,7 +157,7 @@ export function planAdopt(root, cfg) {
135
157
  code = HAS_REQ.exec(existing.id)[1];
136
158
  }
137
159
  if (!code) {
138
- code = deriveCode(cap, taken);
160
+ code = deriveCode(cap, taken, families.has(familySegment(cap)));
139
161
  derived = true;
140
162
  taken.add(code);
141
163
  }
@@ -275,13 +297,24 @@ export function planAdopt(root, cfg) {
275
297
  }
276
298
  }
277
299
  edits.sort((a, b) => cmpStr(a.file, b.file) || a.line - b.line);
278
- return { edits, caps, unmatched };
300
+ // a family whose segment has no letters gives no code, so it is not listed
301
+ const familyList = [...families]
302
+ .sort(cmpStr)
303
+ .map((segment) => ({
304
+ segment,
305
+ prefix: segment.toUpperCase().replace(/[^A-Z]/g, "").slice(0, 3),
306
+ count: capList.filter((c) => familySegment(c) === segment).length,
307
+ }))
308
+ .filter((f) => f.prefix.length >= 2);
309
+ return { edits, caps, families: familyList, unmatched };
279
310
  }
280
311
  export function planLines(p) {
281
312
  const out = p.edits.map((e) => `${e.file}:${e.line} ${e.old} -> ${e.new}`);
282
313
  const derived = p.caps.filter((c) => c.derived);
283
314
  if (derived.length)
284
315
  out.push(...derived.map((c) => `cap: ${c.code} ${c.path}`));
316
+ // REQ-ADOPT-012 one line per family, after the cap lines (063/D5)
317
+ out.push(...p.families.map((f) => `family: ${f.segment} prefix ${f.prefix} ${f.count} codes`));
285
318
  for (const u of p.unmatched)
286
319
  out.push(`unmatched: ${u.file}:${u.line} ${u.header}`);
287
320
  return out;
@@ -2,7 +2,7 @@
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 } from "../config.js";
5
+ import { CONFIG_FILE, ConfigError } from "../config.js";
6
6
  import { loadConfigOrExit } from "../cli/config.js";
7
7
  import { runCommand } from "../runcmd.js";
8
8
  import { dumps } from "../util/json.js";
@@ -44,6 +44,12 @@ export function reportText(plan) {
44
44
  out.push("## Capability codes", "", "Rename a code in `spec-ctx.toml` before the IDs spread.", "", "| Code | Path | Source |", "| --- | --- | --- |");
45
45
  for (const c of plan.caps)
46
46
  out.push(`| ${c.code} | ${c.path} | ${c.derived ? "derived" : "existing"} |`);
47
+ // REQ-ADOPT-012 the families and their prefix (063/D5)
48
+ if (plan.families.length) {
49
+ out.push("", "Families of three or more paths with one first segment take a family code first. The prefix is the first three letters of that segment.", "");
50
+ for (const f of plan.families)
51
+ out.push(`- \`${f.segment}\`: prefix \`${f.prefix}\`, ${f.count} codes`);
52
+ }
47
53
  out.push("", "## Headers", "", "| File | Line | Old header | New header |", "| --- | --- | --- | --- |");
48
54
  const cell = (s) => "`" + s.replace(/\|/g, "\\|").replace(/`/g, "'") + "`";
49
55
  for (const e of plan.edits)
@@ -58,6 +64,20 @@ export function reportText(plan) {
58
64
  }
59
65
  return out.join("\n") + "\n";
60
66
  }
67
+ /** REQ-ADOPT-011 the command after adopt. The codes in spec-ctx.toml changed, so the config loads again. */
68
+ async function nextAfterAdopt(root, run) {
69
+ const { loadIndex } = await import("../index/build.js");
70
+ const { nextTask } = await import("../tasks/tasks.js");
71
+ try {
72
+ const task = nextTask(root, loadIndex(root, loadConfigOrExit(root)).data.entries);
73
+ return runCommand(run, task ? `context ${task.id}` : "status");
74
+ }
75
+ catch (e) {
76
+ if (e instanceof UsageExit || e instanceof ConfigError)
77
+ return runCommand(run, "status");
78
+ throw e;
79
+ }
80
+ }
61
81
  export async function applyAdopt(root, cfg, plan, io, json) {
62
82
  const cfgPath = join(root, CONFIG_FILE);
63
83
  const cfgBefore = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
@@ -115,7 +135,7 @@ export async function applyAdopt(root, cfg, plan, io, json) {
115
135
  const files = new Set(plan.edits.map((e) => e.file)).size;
116
136
  const report = plan.edits.length ? REPORT_FILE : null;
117
137
  if (json)
118
- io.out(dumps({ adopted: plan.edits.length, caps: plan.caps, files, report, unmatched: plan.unmatched }) + "\n");
138
+ io.out(dumps({ adopted: plan.edits.length, caps: plan.caps, families: plan.families, files, report, unmatched: plan.unmatched }) + "\n");
119
139
  else {
120
140
  io.out(plan.unmatched.map((u) => `unmatched: ${u.file}:${u.line} ${u.header}\n`).join(""));
121
141
  // REQ-CLI-007 only the codes in spec-ctx.toml changed: no header, no report
@@ -123,8 +143,11 @@ export async function applyAdopt(root, cfg, plan, io, json) {
123
143
  io.out(`wrote the codes to ${CONFIG_FILE}\nnothing to adopt\n`);
124
144
  else {
125
145
  io.out(`adopted ${plan.edits.length} header(s) in ${files} file(s). Report: ${REPORT_FILE}\n`);
126
- // REQ-ADOPT-002 adopt writes no baseline. The next step is a separate commit (044/D4, ADR-006 item 6).
127
- io.out(`Commit the baseline with a subject that starts with baseline:.\nnext: ${runCommand(cfg.cli.run, "baseline write")}\n`);
146
+ // REQ-ADOPT-002 adopt writes no baseline. The gate step is a later, separate commit (044/D4, ADR-006 item 6).
147
+ // REQ-ADOPT-011 the last line names the context of the task that nextTask picks, or status
148
+ const run = cfg.cli.run;
149
+ io.out(`To gate the old debt later, run ${runCommand(run, "baseline write")} and commit it with a subject that starts with baseline:\n`);
150
+ io.out(`next: ${await nextAfterAdopt(root, run)}\n`);
128
151
  }
129
152
  }
130
153
  return 0;
@@ -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+YCBhbmQgYHNwZWNjdHggaWQgbmV3IFNDTiA8Q0FQPiAtLWZvciA8UkVRPmAuIFdoZW4gdGhlIGNoYW5nZSBuZWVkcyBzZXZlcmFsIElEcyBvZiBvbmUga2luZCwgcnVuIGBzcGVjY3R4IGlkIG5ldyAtLWNvdW50IDxuPmAgb25jZS4gR2l2ZSBldmVyeSB0YXNrIGEgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3Bhbi4gVGhlbiBydW4gYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgIGFuZCBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gVGhlIGdhdGUgcnVucyB0aGUgT3BlblNwZWMgdmFsaWRhdGUgYW5kIHRoZSBzcGVjLWxlbmd0aCBjaGVjay4gRml4IGV2ZXJ5IGZpbmRpbmcuIFRoZW4gcnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYCBhbmQgcnVuIHRoZSBjb21tYW5kIGl0IHByaW50cy4gVGhpcyBpcyB0aGUgcGxhbiBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB3aXRoIGBodW1hbjogdHJ1ZWAgdW50aWwgdGhlIGh1bWFuIGFwcHJvdmVzIHRoZSBxdWVzdGlvbi4gWW91IG5ldmVyIHJlc29sdmUgYSBwbGFuIHF1ZXN0aW9uIHlvdXJzZWxmLgotIGBjaXRlYDogcnVuIGBzcGVjY3R4IHF1ZXJ5IC0tY2hhbmdlIDxjPiAtLWtpbmQgcmVxYC4gQWRkIHRoZSBgW1JFUS0uLi5dYCBjaXRhdGlvbiBhbmQgYSBgVmVyaWZ5OmAgY29kZSBzcGFuIHRvIHRoZSB0YXNrIGxpbmUgbmFtZWQgaW4gYHRhc2tgLiBSdW4gYHNwZWNjdHggY2hlY2tgLiBDb250aW51ZS4KLSBgc3RhcnRgOiBydW4gYHNwZWNjdHggdGFzayBzdGFydCA8dD5gLiBSZWFkIHRoZSBidW5kbGUgaXQgcHJpbnRzLiBDb250aW51ZS4KLSBgd29ya2A6IGltcGxlbWVudCB0aGUgdGFzay4gVGFnIGNvZGUgd2l0aCBgLy8gUkVRLS4uLmAuIFRhZyB0ZXN0cyB3aXRoIGAvLyBDb3ZlcnM6IFJFUS0uLi5gLiBUaGVuIHJ1biBgc3BlY2N0eCB0YXNrIGRvbmUgPHQ+YC4KLSBgZml4YDogcmVhZCBgcmVhc29uYC4gSXQgaG9sZHMgdGhlIHRhaWwgb2YgdGhlIGZhaWxlZCBWZXJpZnkgb3V0cHV0LiBGaXggdGhlIGNhdXNlLiBSdW4gYHNwZWNjdHggdGFzayBkb25lIDx0PmAgYWdhaW4uCi0gYHJlcGFpcmA6IHJ1biB0aGUgY29tbWFuZCBpbiB0aGUgYGNvbW1hbmRgIGZpZWxkLiBJdCBydW5zIFZlcmlmeSBhZ2FpbiBmb3IgYSB0aWNrZWQgdGFzayB3aG9zZSBldmlkZW5jZSBpcyBhYnNlbnQgb3Igc3RhbGUuCi0gYGdhdGVgOiBydW4gYHNwZWNjdHggZ2F0ZSAtLXN0cmljdGAuIEZpeCBldmVyeSBmaW5kaW5nLiBGb3IgYSBgc3RhbGUtZXZpZGVuY2VgIGZpbmRpbmcsIGdvIGJhY2sgdG8gc3RlcCAxLiBgbmV4dGAgcmV0dXJucyBgcmVwYWlyYCBhbmQgaXRzIGNvbW1hbmQgcmVwYWlycyBldmVyeSBzdGFsZSB0YXNrLiBSdW4gdGhlIGdhdGUgYWdhaW4gdW50aWwgaXQgZXhpdHMgMC4KLSBgYXJjaGl2ZWA6IHdoZW4gYGh1bWFuYCBpcyB0cnVlLCBydW4gYHNwZWNjdHggaGFuZG9mZiBhc2sgLS1jaGFuZ2UgPGM+ICJhcmNoaXZlIDxjPj8iYCBhbmQgc3RvcC4gVGhpcyBpcyB0aGUgYXJjaGl2ZSBzdG9wLiBgbmV4dGAgcmV0dXJucyBgYXNrYCB1bnRpbCB0aGUgaHVtYW4gYXBwcm92ZXMgdGhlIHF1ZXN0aW9uLiBZb3UgbmV2ZXIgcmVzb2x2ZSBhbiBhcmNoaXZlIHF1ZXN0aW9uIHlvdXJzZWxmLiBBZnRlciBhIHJlamVjdGlvbiwgYG5leHRgIHJldHVybnMgYGFyY2hpdmVgIHdpdGggYGh1bWFuOiB0cnVlYCBhZ2Fpbi4gV2hlbiBgaHVtYW5gIGlzIGZhbHNlLCB0aGUgdXNlciBhcHByb3ZlZCBvciBgW2xvb3BdIGFyY2hpdmVgIGlzIGBhdXRvYC4gVGhlbiBydW4gZWFjaCBzdGVwIHVuZGVyIGBvcGVyYXRpb25zLmFyY2hpdmUuZ3VpZGFuY2VgIGluIGBvcGVuc3BlYy9jb25maWcueWFtbGAuIFRoZW4gcnVuIHRoZSBjb21tYW5kIGluIHRoZSBgY29tbWFuZGAgZmllbGQuIFRoZW4gcnVuIGBvcGVuc3BlYyB2YWxpZGF0ZSAtLWFsbCAtLXN0cmljdGAsIGBzcGVjY3R4IGNoZWNrIC0tc3RyaWN0YCwgYW5kIGBzcGVjY3R4IGhhbmRvZmYgd3JpdGVgLgotIGBhc2tgOiBwcmludCBgcmVhc29uYC4gSXQgaXMgdGhlIG9wZW4gcXVlc3Rpb24uIFN0b3AuIFlvdSBuZXZlciByZXNvbHZlIGEgcGxhbiBvciBhcmNoaXZlIHF1ZXN0aW9uIHlvdXJzZWxmLiBUaGUgaHVtYW4gZG9lcywgd2l0aCBgc3BlY2N0eCBoYW5kb2ZmIHJlc29sdmUgPG4+IC0tYXBwcm92ZSAiPGFuc3dlcj4iYCBvciBgLS1yZWplY3QgIjxhbnN3ZXI+ImAuIEEgcmVqZWN0aW9uIGtlZXBzIHRoZSBzdG9wLiBBbnkgb3RoZXIgcXVlc3Rpb24gdGFrZXMgYHNwZWNjdHggaGFuZG9mZiByZXNvbHZlIDxuPiAiPGFuc3dlcj4iYC4KLSBgc3RvcGA6IHByaW50IGByZWFzb25gLiBTdG9wLgoKIyMgUnVsZXMKCi0gVGhpcyBza2lsbCBvdmVycmlkZXMgdHdvIHN0ZXBzIG9mIHRoZSB1cHN0cmVhbSBgb3BlbnNwZWMtcHJvcG9zZWAgc2tpbGwuIEl0IHJlYWRzIHNwZWNzIHdpdGggYHNwZWNjdHggYnVuZGxlYCBvciBgc3BlY2N0eCBxdWVyeWAsIG5vdCBgb3BlbnNwZWMgc2hvd2AuIEl0IG5ldmVyIHJ1bnMgdGhlIGNsb3NpbmcgYC9vcHN4OmFwcGx5YC4KLSBOZXZlciB0aWNrIGEgY2hlY2tib3ggYnkgaGFuZC4gYHNwZWNjdHggdGFzayBkb25lYCBpcyB0aGUgb25seSB3YXkgYSB0YXNrIGNsb3Nlcy4KLSBOZXZlciB1c2UgYC0tbWFudWFsYCBvbiBhIHRhc2sgd2l0aCBhIFZlcmlmeSBjb21tYW5kLgotIE5ldmVyIGNhbGwgYC9vcHN4OmFwcGx5YC4gVGhpcyBza2lsbCByZXBsYWNlcyBpdC4KLSBUbyB3cml0ZSBhIE1PRElGSUVEIGJsb2NrLCBydW4gYHNwZWNjdHggYnVuZGxlIDxSRVE+IC0tYmxvY2tgLiBJdCBwcmludHMgdGhlIGJsb2NrIGFzIGl0IHNpdHMgaW4gdGhlIHNwZWMgZmlsZS4gQSBNT0RJRklFRCBibG9jayBrZWVwcyBldmVyeSBzY2VuYXJpbyB0aXRsZSB1bmNoYW5nZWQuCi0gYGdhdGVgIGNvdW50cyB0aGUgUmVmcyBsaW5lIGluIHRoZSA1MDAgY2hhcmFjdGVycyBvZiBhIHJlcXVpcmVtZW50LgotIFRoZSBDSEFOR0VMT0cgZW50cnkgZ29lcyB1bmRlciBgIyMgVW5yZWxlYXNlZGAsIG5ldmVyIHVuZGVyIGEgcmVsZWFzZWQgdmVyc2lvbi4KLSBBZnRlciBhbiBlZGl0IHVuZGVyIGBza2lsbHMvYCwgcnVuIGBidW4gcnVuIGJ1aWxkICYmIG5vZGUgZGlzdC9jbGkuanMgaW5pdCAtLXNraWxscyAtLWZvcmNlYCB0byByZWZyZXNoIHRoZSBjb3BpZXMuCi0gTmV2ZXIgcmVhZCBgb3BlbnNwZWMvc3BlY3MvYCBvciBgb3BlbnNwZWMvY2hhbmdlcy8qL3NwZWNzL2AgZGlyZWN0bHkuIFVzZSB0aGUgYnVuZGxlIGZyb20gYHRhc2sgc3RhcnRgIG9yIGBzcGVjY3R4IGJ1bmRsZSA8aWQ+YC4KLSBTdG9wIG9uIGFueSBgYWN0aW9uYCBub3QgaW4gdGhlIGxpc3QgYWJvdmUuIFByaW50IHRoZSBvYmplY3QgYW5kIGFzayB0aGUgdXNlci4KLSBLZWVwIG5vIHN0YXRlIGluIHlvdXIgY29udGV4dC4gQSBuZXcgc2Vzc2lvbiBzdGFydHMgdGhlIHNhbWUgbG9vcCBhZ2FpbiB3aXRoIHN0ZXAgMS4K",
13
+ "skills/specctx-run/SKILL.md": "LS0tCm5hbWU6IHNwZWNjdHgtcnVuCmRlc2NyaXB0aW9uOiBSdW4gdGhlIHNwZWMtY3R4IGxvb3AgaW4gYW4gT3BlblNwZWMgcHJvamVjdC4gVXNlIG9uICJydW4iLCAiY29udGludWUiLCAibmV4dCB0YXNrIiwgImJ1aWxkIHRoaXMiLCAiY2Fycnkgb24iLCBhbmQgb24gYSBmaXJzdCBtZXNzYWdlIHRoYXQgZGVzY3JpYmVzIGEgY2hhbmdlIHRvIGJ1aWxkLiBUaGUgZW5naW5lIHBpY2tzIHRoZSBuZXh0IGFjdGlvbi4gVGhlIHNraWxsIGFjdHMgb24gaXQgdW50aWwgdGhlIGNoYW5nZSByZWFjaGVzIHRoZSBhcmNoaXZlIG9yIGEgaHVtYW4gbXVzdCBkZWNpZGUuCi0tLQoKIyBzcGVjY3R4LXJ1bgoKUnVuIGBzcGVjY3R4YCBhcyBgbnB4IHNwZWMtY3R4YCB3aGVuIGl0IGlzIG5vdCBpbnN0YWxsZWQuIGBvcGVuc3BlY2AgaXMgdGhlIGNvbW1hbmQgaW4gYFtnYXRlXSBvcGVuc3BlY2Agb2YgYHNwZWMtY3R4LnRvbWxgLiBXaGVuIHRoYXQga2V5IGlzIGFic2VudCwgYG9wZW5zcGVjYCBpcyBgbnB4IC15IEBmaXNzaW9uLWFpL29wZW5zcGVjQGxhdGVzdGAuCgojIyBUaGUgbG9vcAoKMS4gUnVuIGBzcGVjY3R4IG5leHQgLS1qc29uYC4gUmVhZCBgYWN0aW9uYCwgYGNoYW5nZWAgKGA8Yz5gKSwgYHRhc2tgIChgPHQ+YCksIGBjb21tYW5kYCwgYHJlYXNvbmAsIGFuZCBgaHVtYW5gLgoyLiBBY3Qgb24gYGFjdGlvbmAgYXMgdGhlIHRhYmxlIGJlbG93IHNheXMuCjMuIFJ1biBgc3BlY2N0eCBoYW5kb2ZmIHdyaXRlYC4KNC4gUmVwZWF0IGZyb20gc3RlcCAxIHVudGlsIGBhY3Rpb25gIGlzIGBzdG9wYCBvciBgaHVtYW5gIGlzIHRydWUuCgojIyBBY3Rpb25zCgotIGBwbGFuYDogdGFrZSB0aGUgY2hhbmdlIG5hbWUgZnJvbSB0aGUgcmVxdWVzdCBvZiB0aGUgdXNlciBpbiBrZWJhYiBjYXNlLiBUaGF0IG5hbWUgaXMgYDxjPmAuIEZvbGxvdyB0aGUgdXBzdHJlYW0gYG9wZW5zcGVjLXByb3Bvc2VgIHNraWxsIGZvciBgPGM+YC4gUmVhZCBleGlzdGluZyByZXF1aXJlbWVudHMgd2l0aCBgc3BlY2N0eCBidW5kbGUgPGlkPmAgb3IgYHNwZWNjdHggcXVlcnlgLCBuZXZlciBgb3BlbnNwZWMgc2hvd2AuIFRha2UgZXZlcnkgSUQgZnJvbSBgc3BlY2N0eCBpZCBuZXcgUkVRIDxDQVA+YCBhbmQgYHNwZWNjdHggaWQgbmV3IFNDTiA8Q0FQPiAtLWZvciA8UkVRPmAuIGBpZCBuZXdgIHJlc2VydmVzIG5vdGhpbmcuIEEgc2Vjb25kIGNhbGwgYmVmb3JlIGBzcGVjY3R4IGJ1aWxkYCByZXR1cm5zIHRoZSBzYW1lIElELiBXaGVuIHRoZSBjaGFuZ2UgbmVlZHMgc2V2ZXJhbCBJRHMgb2Ygb25lIGtpbmQsIHJ1biBgc3BlY2N0eCBpZCBuZXcgLS1jb3VudCA8bj5gIG9uY2UuIEdpdmUgZXZlcnkgdGFzayBhIGBbUkVRLS4uLl1gIGNpdGF0aW9uIGFuZCBhIGBWZXJpZnk6YCBjb2RlIHNwYW4uIFRoZW4gcnVuIGBzcGVjY3R4IGNoZWNrIC0tc3RyaWN0YCBhbmQgYHNwZWNjdHggZ2F0ZSAtLXN0cmljdGAuIFRoZSBnYXRlIHJ1bnMgdGhlIE9wZW5TcGVjIHZhbGlkYXRlIGFuZCB0aGUgc3BlYy1sZW5ndGggY2hlY2suIEZpeCBldmVyeSBmaW5kaW5nLiBUaGVuIHJ1biBgc3BlY2N0eCBuZXh0IC0tanNvbmAgYW5kIHJ1biB0aGUgY29tbWFuZCBpdCBwcmludHMuIFRoaXMgaXMgdGhlIHBsYW4gc3RvcC4gYG5leHRgIHJldHVybnMgYGFza2Agd2l0aCBgaHVtYW46IHRydWVgIHVudGlsIHRoZSBodW1hbiBhcHByb3ZlcyB0aGUgcXVlc3Rpb24uIFlvdSBuZXZlciByZXNvbHZlIGEgcGxhbiBxdWVzdGlvbiB5b3Vyc2VsZi4KLSBgY2l0ZWA6IHJ1biBgc3BlY2N0eCBxdWVyeSAtLWNoYW5nZSA8Yz4gLS1raW5kIHJlcWAuIEFkZCB0aGUgYFtSRVEtLi4uXWAgY2l0YXRpb24gYW5kIGEgYFZlcmlmeTpgIGNvZGUgc3BhbiB0byB0aGUgdGFzayBsaW5lIG5hbWVkIGluIGB0YXNrYC4gUnVuIGBzcGVjY3R4IGNoZWNrYC4gQ29udGludWUuCi0gYHN0YXJ0YDogcnVuIGBzcGVjY3R4IHRhc2sgc3RhcnQgPHQ+YC4gUmVhZCB0aGUgYnVuZGxlIGl0IHByaW50cy4gRWFjaCBjYXJkIGVuZHMgd2l0aCB0cnVzdCBsYWJlbHMuIEFjdCBvbiBhIGxpbmUgbWFya2VkIGB2ZXJpZmllZGAgb3IgYGZyZXNoYC4gUmVyZWFkIGEgbGluZSBtYXJrZWQgYHN0YWxlIHNpbmNlYCwgYHVudmVyaWZpZWRgLCBvciBgbm8gZXZpZGVuY2VgIHdpdGggYHNwZWNjdHggYnVuZGxlIDxJRD5gIGJlZm9yZSB5b3UgYnVpbGQgb24gaXQuIENvbnRpbnVlLgotIGB3b3JrYDogaW1wbGVtZW50IHRoZSB0YXNrLiBUYWcgY29kZSB3aXRoIGAvLyBSRVEtLi4uYC4gVGFnIHRlc3RzIHdpdGggYC8vIENvdmVyczogUkVRLS4uLmAuIFRoZW4gcnVuIGBzcGVjY3R4IHRhc2sgZG9uZSA8dD5gLgotIGBmaXhgOiByZWFkIGByZWFzb25gLiBJdCBob2xkcyB0aGUgdGFpbCBvZiB0aGUgZmFpbGVkIFZlcmlmeSBvdXRwdXQuIEZpeCB0aGUgY2F1c2UuIFJ1biBgc3BlY2N0eCB0YXNrIGRvbmUgPHQ+YCBhZ2Fpbi4KLSBgcmVwYWlyYDogcnVuIHRoZSBjb21tYW5kIGluIHRoZSBgY29tbWFuZGAgZmllbGQuIEl0IHJ1bnMgVmVyaWZ5IGFnYWluIGZvciBhIHRpY2tlZCB0YXNrIHdob3NlIGV2aWRlbmNlIGlzIGFic2VudCBvciBzdGFsZS4KLSBgZ2F0ZWA6IHJ1biBgc3BlY2N0eCBnYXRlIC0tc3RyaWN0YC4gRml4IGV2ZXJ5IGZpbmRpbmcuIEZvciBhIGBzdGFsZS1ldmlkZW5jZWAgZmluZGluZywgZ28gYmFjayB0byBzdGVwIDEuIGBuZXh0YCByZXR1cm5zIGByZXBhaXJgIGFuZCBpdHMgY29tbWFuZCByZXBhaXJzIGV2ZXJ5IHN0YWxlIHRhc2suIFJ1biB0aGUgZ2F0ZSBhZ2FpbiB1bnRpbCBpdCBleGl0cyAwLgotIGBhcmNoaXZlYDogd2hlbiBgaHVtYW5gIGlzIHRydWUsIHJ1biBgc3BlY2N0eCBoYW5kb2ZmIGFzayAtLWNoYW5nZSA8Yz4gImFyY2hpdmUgPGM+PyJgIGFuZCBzdG9wLiBUaGlzIGlzIHRoZSBhcmNoaXZlIHN0b3AuIGBuZXh0YCByZXR1cm5zIGBhc2tgIHVudGlsIHRoZSBodW1hbiBhcHByb3ZlcyB0aGUgcXVlc3Rpb24uIFlvdSBuZXZlciByZXNvbHZlIGFuIGFyY2hpdmUgcXVlc3Rpb24geW91cnNlbGYuIEFmdGVyIGEgcmVqZWN0aW9uLCBgbmV4dGAgcmV0dXJucyBgYXJjaGl2ZWAgd2l0aCBgaHVtYW46IHRydWVgIGFnYWluLiBXaGVuIGBodW1hbmAgaXMgZmFsc2UsIHRoZSB1c2VyIGFwcHJvdmVkIG9yIGBbbG9vcF0gYXJjaGl2ZWAgaXMgYGF1dG9gLiBUaGVuIHJ1biBlYWNoIHN0ZXAgdW5kZXIgYG9wZXJhdGlvbnMuYXJjaGl2ZS5ndWlkYW5jZWAgaW4gYG9wZW5zcGVjL2NvbmZpZy55YW1sYC4gVGhlbiBydW4gdGhlIGNvbW1hbmQgaW4gdGhlIGBjb21tYW5kYCBmaWVsZC4gVGhlbiBydW4gYG9wZW5zcGVjIHZhbGlkYXRlIC0tYWxsIC0tc3RyaWN0YCwgYHNwZWNjdHggY2hlY2sgLS1zdHJpY3RgLCBhbmQgYHNwZWNjdHggaGFuZG9mZiB3cml0ZWAuCi0gYGFza2A6IHByaW50IGByZWFzb25gLiBJdCBpcyB0aGUgb3BlbiBxdWVzdGlvbi4gU3RvcC4gWW91IG5ldmVyIHJlc29sdmUgYSBwbGFuIG9yIGFyY2hpdmUgcXVlc3Rpb24geW91cnNlbGYuIFRoZSBodW1hbiBkb2VzLCB3aXRoIGBzcGVjY3R4IGhhbmRvZmYgcmVzb2x2ZSA8bj4gLS1hcHByb3ZlICI8YW5zd2VyPiJgIG9yIGAtLXJlamVjdCAiPGFuc3dlcj4iYC4gQSByZWplY3Rpb24ga2VlcHMgdGhlIHN0b3AuIEFueSBvdGhlciBxdWVzdGlvbiB0YWtlcyBgc3BlY2N0eCBoYW5kb2ZmIHJlc29sdmUgPG4+ICI8YW5zd2VyPiJgLgotIGBzdG9wYDogcHJpbnQgYHJlYXNvbmAuIFN0b3AuCgojIyBSdWxlcwoKLSBUaGlzIHNraWxsIG92ZXJyaWRlcyB0d28gc3RlcHMgb2YgdGhlIHVwc3RyZWFtIGBvcGVuc3BlYy1wcm9wb3NlYCBza2lsbC4gSXQgcmVhZHMgc3BlY3Mgd2l0aCBgc3BlY2N0eCBidW5kbGVgIG9yIGBzcGVjY3R4IHF1ZXJ5YCwgbm90IGBvcGVuc3BlYyBzaG93YC4gSXQgbmV2ZXIgcnVucyB0aGUgY2xvc2luZyBgL29wc3g6YXBwbHlgLgotIE5ldmVyIHRpY2sgYSBjaGVja2JveCBieSBoYW5kLiBgc3BlY2N0eCB0YXNrIGRvbmVgIGlzIHRoZSBvbmx5IHdheSBhIHRhc2sgY2xvc2VzLgotIE5ldmVyIHVzZSBgLS1tYW51YWxgIG9uIGEgdGFzayB3aXRoIGEgVmVyaWZ5IGNvbW1hbmQuCi0gTmV2ZXIgY2FsbCBgL29wc3g6YXBwbHlgLiBUaGlzIHNraWxsIHJlcGxhY2VzIGl0LgotIFRvIHdyaXRlIGEgTU9ESUZJRUQgYmxvY2ssIHJ1biBgc3BlY2N0eCBidW5kbGUgPFJFUT4gLS1ibG9ja2AuIEl0IHByaW50cyB0aGUgYmxvY2sgYXMgaXQgc2l0cyBpbiB0aGUgc3BlYyBmaWxlLiBBIE1PRElGSUVEIGJsb2NrIGtlZXBzIGV2ZXJ5IHNjZW5hcmlvIHRpdGxlIHVuY2hhbmdlZC4KLSBgZ2F0ZWAgY291bnRzIHRoZSBSZWZzIGxpbmUgaW4gdGhlIDUwMCBjaGFyYWN0ZXJzIG9mIGEgcmVxdWlyZW1lbnQuCi0gVGhlIENIQU5HRUxPRyBlbnRyeSBnb2VzIHVuZGVyIGAjIyBVbnJlbGVhc2VkYCwgbmV2ZXIgdW5kZXIgYSByZWxlYXNlZCB2ZXJzaW9uLgotIEFmdGVyIGFuIGVkaXQgdW5kZXIgYHNraWxscy9gLCBydW4gYGJ1biBydW4gYnVpbGQgJiYgbm9kZSBkaXN0L2NsaS5qcyBpbml0IC0tc2tpbGxzIC0tZm9yY2VgIHRvIHJlZnJlc2ggdGhlIGNvcGllcy4KLSBOZXZlciByZWFkIGBvcGVuc3BlYy9zcGVjcy9gIG9yIGBvcGVuc3BlYy9jaGFuZ2VzLyovc3BlY3MvYCBkaXJlY3RseS4gVXNlIHRoZSBidW5kbGUgZnJvbSBgdGFzayBzdGFydGAgb3IgYHNwZWNjdHggYnVuZGxlIDxpZD5gLgotIFN0b3Agb24gYW55IGBhY3Rpb25gIG5vdCBpbiB0aGUgbGlzdCBhYm92ZS4gUHJpbnQgdGhlIG9iamVjdCBhbmQgYXNrIHRoZSB1c2VyLgotIEtlZXAgbm8gc3RhdGUgaW4geW91ciBjb250ZXh0LiBBIG5ldyBzZXNzaW9uIHN0YXJ0cyB0aGUgc2FtZSBsb29wIGFnYWluIHdpdGggc3RlcCAxLgo=",
14
14
  };
@@ -83,6 +83,9 @@ export async function run(p, io) {
83
83
  if (cmd === "report") {
84
84
  if (p.positional.length)
85
85
  throw new UsageExit("report takes no arguments");
86
+ // REQ-JRN-008 report prints Markdown only
87
+ if (json)
88
+ throw new UsageExit("report takes no --json flag");
86
89
  const { buildReport } = await import("../journal/report.js");
87
90
  const { VERSION } = await import("../version.js");
88
91
  io.out(buildReport(root, new Date(), { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }).text);
@@ -321,8 +324,10 @@ export async function run(p, io) {
321
324
  const { reqBlocks } = await import("../graph/bundle.js");
322
325
  const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
323
326
  // REQ-QRY-019 block mode has no budget, so budget is null and the text is complete (050/D6)
327
+ // REQ-QRY-026 block mode has no budget, so used equals chars and no block is listed
328
+ const n = (await import("../util/pystr.js")).cpLen(text);
324
329
  if (json)
325
- print({ text, omitted: [], chars: (await import("../util/pystr.js")).cpLen(text), budget: null, complete: true });
330
+ print({ text, omitted: [], body_omitted: [], chars: n, budget: null, complete: true, used: n, blocks: [] });
326
331
  else
327
332
  io.out(text);
328
333
  return 0;
@@ -396,18 +401,23 @@ async function init(p, io) {
396
401
  return defaults().cli.run;
397
402
  }
398
403
  })();
404
+ // REQ-DIST-017 the next command comes from the state of the specs
405
+ const { detectNext } = await import("../init/next.js");
406
+ const d = detectNext(root, run);
407
+ const summary = d.summary ? `${d.summary}\n` : "";
399
408
  if (p.flags.json)
400
- io.out(JSON.stringify(r, null, 2) + "\n");
409
+ io.out(JSON.stringify({ ...r, needs_id: d.needs_id, next: d.next }, null, 2) + "\n");
401
410
  else if (r.nothing)
402
- io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", `next: ${runCommand(run, "status")}\n`, io.tty));
411
+ io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + summary + paint("dim", `next: ${d.next}\n`, io.tty));
403
412
  else {
404
413
  io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
405
414
  io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
406
415
  io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
407
416
  io.out(r.notes.map((n) => `${n}\n`).join(""));
417
+ io.out(summary);
408
418
  // REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
409
419
  const again = [runCommand(run, "init"), all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
410
- io.out(paint("dim", `next: ${r.changed.length ? again : runCommand(run, "status")}\n`, io.tty));
420
+ io.out(paint("dim", `next: ${r.changed.length ? again : d.next}\n`, io.tty));
411
421
  }
412
422
  return 0;
413
423
  }
package/dist/cli/gate.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
4
4
  import { join, resolve, sep } from "node:path";
5
+ import { childEnv } from "../util/env.js";
5
6
  import { anyMatch, globRe } from "../util/glob.js";
6
7
  import { cmpStr } from "../util/pystr.js";
7
8
  const SKIP = new Set(["node_modules", ".git", ".ctx", "dist", "build"]);
@@ -93,7 +94,7 @@ async function gateSteps(root, o, io, ran) {
93
94
  io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
94
95
  ran.push("validate");
95
96
  // gate 4: openspec validate with the command the caller resolved (018/D2)
96
- const v = spawnSync("/bin/sh", ["-c", `${o.openspec} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8" });
97
+ const v = spawnSync("/bin/sh", ["-c", `${o.openspec} validate --all${o.strict ? " --strict" : ""} --no-interactive`], { cwd: root, encoding: "utf8", env: childEnv() /* REQ-DIST-016 */ });
97
98
  if (v.status !== 0) {
98
99
  io.out("validate: fail\n" + (v.stdout ?? "") + (v.stderr ?? ""));
99
100
  return "validate";
package/dist/cli/help.js CHANGED
@@ -40,7 +40,7 @@ export const TABLE = [
40
40
  {
41
41
  name: "context", group: "Read", args: "<task|ID|path|change>", summary: "Print what an agent needs before it edits a task, an ID, a file, or a change.",
42
42
  flags: [
43
- ["--budget <n>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. The default is 8000."],
43
+ ["--budget <n>", "Keep the blocks to n characters. Each entry prints its card first. A card ends with its trust labels: the provenance and the evidence state. A card can end with | body omitted. Run bundle with its ID to get the body. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line names the entries that have no card. It is not in this limit. The default is 8000."],
44
44
  ["--depth <n>", "Follow links n steps. The default is 1."],
45
45
  ],
46
46
  exit: [OK, ["2", "A usage error, an unknown ID, or a bad path. It takes one input. Use `bundle` for several inputs or for `--block`."], INTERNAL], example: "specctx context 045/2.1",
@@ -53,7 +53,7 @@ export const TABLE = [
53
53
  {
54
54
  name: "bundle", group: "Read", args: "<ID|path|change...>", summary: "Print the spec text for the IDs, files, or changes and their links.",
55
55
  flags: [
56
- ["--budget <n>", "Keep the blocks to n characters. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line is not in this limit. The default is 8000."],
56
+ ["--budget <n>", "Keep the blocks to n characters. Each entry prints its card first. A card ends with its trust labels: the provenance and the evidence state. A card can end with | body omitted. Run bundle with its ID to get the body. A requested block always prints, also when it is longer. Then the over budget line gives the size. The truncated line names the entries that have no card. It is not in this limit. The default is 8000."],
57
57
  ["--depth <n>", "Follow links n steps. The default is 1."],
58
58
  ["--all", "Show all copies of an ID."],
59
59
  ["--block", "Print only the requirement block, as it sits in the spec file. Each ID must be a requirement."],
@@ -93,7 +93,7 @@ export const TABLE = [
93
93
  {
94
94
  name: "report", group: "Read", args: "", summary: "Print a bug report from the run journal in .ctx/local. Review it, then file it with `gh issue create`. Write nothing.",
95
95
  flags: [],
96
- exit: [OK, ["2", "A usage error."], INTERNAL], example: "specctx report > report.md",
96
+ exit: [OK, ["2", "A usage error. The command has no JSON output."], INTERNAL], example: "specctx report > report.md",
97
97
  },
98
98
  {
99
99
  name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
@@ -106,8 +106,8 @@ export const TABLE = [
106
106
  // Write
107
107
  { name: "build", group: "Write", args: "", summary: "Write the index to .ctx/index.json.", flags: [], exit: PLAIN, example: "specctx build" },
108
108
  {
109
- name: "id new", group: "Write", args: "<kind> <code>", summary: "Print the next free ID. The kind is REQ, SCN, ADR, or DEC.",
110
- flags: [["--for <REQ-ID>", "Give the requirement of a new scenario."], ["--count <n>", "Print n IDs in sequence."]],
109
+ name: "id new", group: "Write", args: "<kind> <code>", summary: "Print the next free ID. The kind is REQ, SCN, ADR, or DEC. A second call before `build` prints the same ID.",
110
+ flags: [["--for <REQ-ID>", "Give the requirement of a new scenario."], ["--count <n>", "Print n IDs in sequence. Use it to get several IDs before `build`."]],
111
111
  exit: [OK, ["2", "A usage error or an unknown code."], INTERNAL], example: "specctx id new REQ IDX",
112
112
  },
113
113
  {
@@ -211,7 +211,9 @@ export const HELP = overview();
211
211
  /** REQ-CLI-006 the page of one command: Usage, Flags, Exit codes, Example. */
212
212
  export function commandPage(c) {
213
213
  const lint = c.name.startsWith("lint ");
214
- const flags = lint ? c.flags : [...c.flags, ...GLOBAL];
214
+ // REQ-JRN-008 the report page lists no --json, because report has no JSON output
215
+ const global = c.name === "report" ? GLOBAL.filter(([f]) => f !== "--json") : GLOBAL;
216
+ const flags = lint ? c.flags : [...c.flags, ...global];
215
217
  const out = [
216
218
  `specctx ${c.name}: ${c.summary}`,
217
219
  "",
@@ -1,7 +1,9 @@
1
1
  // REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
2
- import { join } from "node:path";
2
+ import { statSync } from "node:fs";
3
+ import { isAbsolute, join, normalize } from "node:path";
3
4
  import { canon, poolOf } from "../index/canonical.js";
4
5
  import { blockRange, readText } from "../index/scan.js";
6
+ import { Trust, provenanceClass, taggedReq } from "./trust.js";
5
7
  import { cmpStr, cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
6
8
  import { KIND_RANK, mainVersion, walkFound } from "./related.js";
7
9
  export class Lines {
@@ -42,8 +44,8 @@ function rstrip(s) {
42
44
  const t = pyStrip("x" + s);
43
45
  return t.slice(1);
44
46
  }
45
- export function provenance(e, reason) {
46
- return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
47
+ export function provenance(e, reason, trust = "") {
48
+ return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}${trust}\n`;
47
49
  }
48
50
  /** REQ-JRN-002 the ID of a provenance line, or null. It anchors on the start of `provenance()` (057/D3). */
49
51
  export function parseProvenance(line) {
@@ -194,34 +196,145 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
194
196
  blocks.push([m, `main version of ${r2}`, cls]);
195
197
  }
196
198
  const files = new Lines(root);
197
- const texts = blocks.map(([e, reason]) => {
199
+ // REQ-QRY-020 one state per block: 0 none, 1 card, 2 full. The fill sets it and the output reads it in print order.
200
+ // REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust columns of each card (062/D1 to D5)
201
+ const trust = new Trust(root, entries);
202
+ const labels = (e, reason) => {
203
+ let prov = provenanceClass(reason);
204
+ if (prov === "tagged" && (e.kind === "code" || e.kind === "test")) {
205
+ const r = taggedReq(reason);
206
+ if (r && trust.lineVerified(e.file, r))
207
+ prov = "verified";
208
+ }
209
+ let ev = null;
210
+ if (e.kind === "task")
211
+ ev = trust.taskState(e.id);
212
+ else if (e.kind === "req")
213
+ ev = trust.reqState(e.id);
214
+ return { provenance: prov, evidence: ev };
215
+ };
216
+ const blk = blocks.map(([e, reason, cls]) => {
198
217
  const body = blockText(e, files);
199
- // a blank line closes a block with a body. Code and test paths stay one line each.
200
- return provenance(e, reason) + body + (body ? "\n" : "");
218
+ const l = labels(e, reason);
219
+ const suffix = ` | ${l.provenance}` + (l.evidence ? ` | ${l.evidence}` : "");
220
+ return { id: e.id, head: provenance(e, reason, suffix), body: body ? body + "\n" : "", cls, st: 0,
221
+ info: { id: e.id, file: e.file, line: e.line, status: e.status, reason, ...l } };
201
222
  });
202
- // REQ-QRY-018 the blocks of the first loop always stay: each requested block and the main version of a requested ID (050/D2, 050/D3)
203
- const keep = blocks.map((_, k) => k < pinned);
223
+ // REQ-QRY-021 the files that a requested task names, as class 1 cards after the code and test lines (060/D4)
224
+ const named = [];
225
+ if (depth >= 1) {
226
+ const taken = new Set(blocks.filter(([e]) => e.kind === "code" || e.kind === "test").map(([e]) => e.file));
227
+ for (const t of wanted) {
228
+ const te = ids.get(t);
229
+ if (te?.kind !== "task")
230
+ continue;
231
+ for (const path of te.paths ?? []) {
232
+ if (taken.has(path) || !isFile(root, path))
233
+ continue;
234
+ taken.add(path);
235
+ named.push({ id: path, head: `-- ${path} | ${path} | active | named by ${t} | declared\n`, body: "", cls: 1, st: 0,
236
+ info: { id: path, file: path, line: 0, status: "active", reason: `named by ${t}`, provenance: "declared", evidence: null } });
237
+ }
238
+ }
239
+ }
240
+ // the pinned blocks can hold a change, whose rank is above test, so the search starts after them
241
+ let at = blocks.findIndex(([e], k) => k >= pinned && (KIND_RANK[e.kind] ?? 9) > KIND_RANK.test);
242
+ if (at < 0)
243
+ at = blk.length;
244
+ blk.splice(Math.max(at, pinned), 0, ...named);
245
+ // REQ-QRY-018 the pinned blocks always stay, with their bodies (050/D2, 050/D3)
246
+ for (let k = 0; k < pinned; k++)
247
+ blk[k].st = 2;
204
248
  let used = 0;
205
249
  for (let k = 0; k < pinned; k++)
206
- used += cpLen(texts[k]);
207
- // fill the budget class by class, in print order inside a class, and skip a block that does not fit
208
- for (let cls = 0; cls <= 3; cls++) {
209
- for (let k = pinned; k < blocks.length; k++) {
210
- if (blocks[k][2] !== cls)
250
+ used += cpLen(render(blk[k]));
251
+ const cost = (b, st) => cpLen(renderAs(b, st));
252
+ // fill in five steps, in print order inside each: cards of class 0, cards of class 1, bodies of class 0, cards of class 2, bodies of class 2
253
+ const cards = (cls) => {
254
+ for (let k = pinned; k < blk.length; k++) {
255
+ const b = blk[k];
256
+ if (b.cls !== cls || b.st)
211
257
  continue;
212
- const n = cpLen(texts[k]);
258
+ const st = b.body ? 1 : 2;
259
+ const n = cost(b, st);
213
260
  if (used + n > budget)
214
261
  continue;
215
- keep[k] = true;
262
+ b.st = st;
216
263
  used += n;
217
264
  }
265
+ };
266
+ const bodies = (cls) => {
267
+ for (let k = pinned; k < blk.length; k++) {
268
+ const b = blk[k];
269
+ if (b.cls !== cls || b.st !== 1)
270
+ continue;
271
+ const d = cost(b, 2) - cost(b, 1);
272
+ if (used + d > budget)
273
+ continue;
274
+ b.st = 2;
275
+ used += d;
276
+ }
277
+ };
278
+ cards(0);
279
+ cards(1);
280
+ bodies(0);
281
+ cards(2);
282
+ bodies(2);
283
+ // class 3 fills as a whole block
284
+ for (let k = pinned; k < blk.length; k++) {
285
+ const b = blk[k];
286
+ if (b.cls !== 3)
287
+ continue;
288
+ const n = cost(b, 2);
289
+ if (used + n > budget)
290
+ continue;
291
+ b.st = 2;
292
+ used += n;
218
293
  }
219
- const omitted = blocks.filter((_, k) => !keep[k]).map(([e]) => e.id);
220
- let text = texts.filter((_, k) => keep[k]).join("");
294
+ const omitted = blk.filter((b) => !b.st).map((b) => b.id);
295
+ const bodyOmitted = blk.filter((b) => b.st === 1).map((b) => b.id);
296
+ // REQ-QRY-022 the note of the first requested task with no requirement that the index holds. It prints after the pinned blocks.
297
+ let note = "";
298
+ if (depth >= 1) {
299
+ const t = wanted.find((w) => {
300
+ const te = ids.get(w);
301
+ return te?.kind === "task" && !te.refs.some((x) => ids.get(x)?.kind === "req");
302
+ });
303
+ if (t)
304
+ note = `note: ${t} cites no requirement that the index holds. The blocks come from its change and the paths it names. Add a [REQ-...] citation.\n`;
305
+ }
306
+ const out = blk.map((b) => render(b));
307
+ let text = out.slice(0, pinned).join("") + note + out.slice(pinned).join("");
308
+ // the budget counts the blocks, so the figure of the over budget line is their length
309
+ used = out.reduce((n, x) => n + cpLen(x), 0);
221
310
  if (omitted.length)
222
311
  text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
223
312
  // REQ-QRY-018 the over budget line goes last, so the 0.4.1 output stays a prefix (050/D4)
224
313
  if (used > budget)
225
314
  text += `over budget: ${used} of ${budget} characters\n`;
226
- return { text, omitted, chars: cpLen(text), budget, complete: omitted.length === 0 };
315
+ const printed = blk.filter((b) => b.st).map((b) => b.info);
316
+ return { text, omitted, body_omitted: bodyOmitted, chars: cpLen(text), budget, complete: omitted.length === 0 && bodyOmitted.length === 0, used, blocks: printed };
317
+ }
318
+ const MARKER = " | body omitted";
319
+ function renderAs(b, st) {
320
+ if (st === 0)
321
+ return "";
322
+ if (st === 1 && b.body)
323
+ return b.head.slice(0, -1) + MARKER + "\n";
324
+ return b.head + b.body;
325
+ }
326
+ function render(b) {
327
+ return renderAs(b, b.st);
328
+ }
329
+ /** REQ-QRY-021 true when the path is a file under the root. */
330
+ function isFile(root, path) {
331
+ const n = normalize(path);
332
+ if (isAbsolute(n) || n.startsWith(".."))
333
+ return false;
334
+ try {
335
+ return statSync(join(root, n)).isFile();
336
+ }
337
+ catch {
338
+ return false;
339
+ }
227
340
  }
@@ -24,7 +24,8 @@ export function fileEdges(entries, path) {
24
24
  for (const r of e.refs)
25
25
  push(g, r, `${verb}, tag at ${e.file}:${e.line}`);
26
26
  }
27
- const mentions = entries.filter((e) => e.kind === "task" && e.canonical && (e.paths ?? []).includes(path));
27
+ // REQ-QRY-021 an archived task names paths too, but it is no mention
28
+ const mentions = entries.filter((e) => e.kind === "task" && e.canonical && !e.file.includes("/archive/") && (e.paths ?? []).includes(path));
28
29
  for (const e of mentions)
29
30
  push(2, e.id, "mentions, in task line");
30
31
  return groups.flatMap((g) => g.sort((a, b) => cmpStr(a[0], b[0])));
@@ -8,7 +8,7 @@ import { structuralEdges, structure } from "./links.js";
8
8
  export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5, cap: 6, change: 7 };
9
9
  export const REASONS = [
10
10
  "scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers", "mentions",
11
- "delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of",
11
+ "delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of", "named by",
12
12
  ];
13
13
  /** The walk behind `related` and `bundle`. The map keeps the insertion order and the depth of each entry (046/D7). */
14
14
  export function walkFound(entries, start, depth, all = false) {
@@ -0,0 +1,114 @@
1
+ // REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust labels of a card: the provenance class and the evidence state.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { staleOf } from "../check/evidence.js";
5
+ import { evidenceRel } from "../tasks/tasks.js";
6
+ import { fileSha256 } from "../util/hash.js";
7
+ const TAGGED = ["implements", "covers", "tag at"];
8
+ const STRUCTURAL = ["delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of"];
9
+ /** REQ-QRY-023 the class of a reason, by its prefix (062/D1). An unknown prefix is declared. */
10
+ export function provenanceClass(reason) {
11
+ if (TAGGED.some((p) => reason.startsWith(p)))
12
+ return "tagged";
13
+ if (STRUCTURAL.some((p) => reason.startsWith(p)))
14
+ return "structural";
15
+ return "declared";
16
+ }
17
+ /**
18
+ * The trust state of one bundle run. It reads each evidence file at most once and hashes each file at most once
19
+ * (062/D3). It writes nothing.
20
+ */
21
+ export class Trust {
22
+ tasks = new Map();
23
+ citing = new Map();
24
+ states = new Map();
25
+ evidence = new Map();
26
+ hashes = new Map();
27
+ root;
28
+ constructor(root, entries) {
29
+ this.root = root;
30
+ for (const e of entries) {
31
+ if (e.kind !== "task")
32
+ continue;
33
+ // an active copy wins over an archived one, as check does
34
+ const cur = this.tasks.get(e.id);
35
+ if (!cur || (cur.file.includes("/archive/") && !e.file.includes("/archive/")))
36
+ this.tasks.set(e.id, e);
37
+ }
38
+ for (const t of this.tasks.values()) {
39
+ for (const r of t.refs) {
40
+ const l = this.citing.get(r) ?? [];
41
+ l.push(t.id);
42
+ this.citing.set(r, l);
43
+ }
44
+ }
45
+ }
46
+ readEvidence(task) {
47
+ if (this.evidence.has(task))
48
+ return this.evidence.get(task);
49
+ const p = join(this.root, evidenceRel({ id: task }));
50
+ let ev = null;
51
+ if (existsSync(p)) {
52
+ try {
53
+ const j = JSON.parse(readFileSync(p, "utf8"));
54
+ ev = { files: j.files ?? {} };
55
+ }
56
+ catch {
57
+ ev = null;
58
+ }
59
+ }
60
+ this.evidence.set(task, ev);
61
+ return ev;
62
+ }
63
+ hash(path) {
64
+ if (!this.hashes.has(path))
65
+ this.hashes.set(path, fileSha256(join(this.root, path)));
66
+ return this.hashes.get(path);
67
+ }
68
+ /** REQ-QRY-024 fresh, stale since the first stale path, or no evidence (062/D2). Never the word current. */
69
+ taskState(id) {
70
+ if (this.states.has(id))
71
+ return this.states.get(id);
72
+ const t = this.tasks.get(id);
73
+ let s = "no evidence";
74
+ if (t && t.status === "done" && existsSync(join(this.root, evidenceRel({ id })))) {
75
+ // a damaged file is stale as a whole, as check reports it, so the decision rests on the file and not on the parse
76
+ if (t.file.includes("/archive/"))
77
+ s = "fresh";
78
+ else {
79
+ const stale = staleOf(this.root, id);
80
+ s = stale ? `stale since ${stale}` : "fresh";
81
+ }
82
+ }
83
+ this.states.set(id, s);
84
+ return s;
85
+ }
86
+ /** REQ-QRY-025 verified when one citing task is fresh, with an early stop (062/D3) */
87
+ reqState(id) {
88
+ for (const t of this.citing.get(id) ?? [])
89
+ if (this.taskState(t) === "fresh")
90
+ return "verified";
91
+ return "unverified";
92
+ }
93
+ /**
94
+ * REQ-QRY-023 a tagged line is verified when a done task that cites its requirement lists the file in its
95
+ * evidence with the current hash (062/D1a). An archived task counts too, so a changed line never reads verified.
96
+ */
97
+ lineVerified(file, req) {
98
+ for (const t of this.citing.get(req) ?? []) {
99
+ const task = this.tasks.get(t);
100
+ if (!task || task.status !== "done")
101
+ continue;
102
+ const ev = this.readEvidence(t);
103
+ const want = ev?.files[file];
104
+ if (want && this.hash(file) === want)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ }
110
+ /** REQ-QRY-023 the requirement that a tagged reason names, or null */
111
+ export function taggedReq(reason) {
112
+ const m = /^(?:implements|covers) (\S+)/.exec(reason);
113
+ return m ? m[1] : null;
114
+ }
@@ -309,8 +309,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
309
309
  id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
310
310
  status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
311
311
  });
312
- // REQ-IDX-009 an active task records the paths that its line names in backticks
313
- const paths = status === "archived" ? [] : taskPaths(body);
312
+ // REQ-IDX-009 a task of any change records the paths that its line names in backticks
313
+ const paths = taskPaths(body);
314
314
  if (paths.length)
315
315
  t.paths = paths;
316
316
  entries.push(t);
package/dist/init/init.js CHANGED
@@ -6,6 +6,7 @@ import { ASSETS } from "../assets.generated.js";
6
6
  import { UsageExit } from "../cli/errors.js";
7
7
  import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
8
8
  import { loadConfigOrExit } from "../cli/config.js";
9
+ import { childEnv } from "../util/env.js";
9
10
  import { VERSION } from "../version.js";
10
11
  export const SCHEMA = "spec-driven-ids";
11
12
  /** 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). */
@@ -31,7 +32,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
31
32
  if (env !== undefined && env.trim())
32
33
  return { command: env };
33
34
  const [cmd, ...args] = DEFAULT_OPENSPEC.split(" ");
34
- const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] });
35
+ const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"], env: childEnv() /* REQ-DIST-016 */ });
35
36
  const m = r.status === 0 && !r.error ? /\d+\.\d+\.\d+/.exec(r.stdout ?? "") : null;
36
37
  if (!m)
37
38
  return { command: DEFAULT_OPENSPEC, note: OPENSPEC_NOTE };
@@ -402,17 +403,24 @@ export function writeGitignore(root) {
402
403
  writeFileSync(p, text);
403
404
  return add.length;
404
405
  }
405
- /** REQ-DIST-002 true when .gitignore has a line that ignores the whole .claude directory (017/D3). */
406
- export function ignoresClaude(root) {
406
+ /** REQ-DIST-018 the agent folders that .gitignore ignores as a whole, with or without a slash. */
407
+ export function ignoredAgentDirs(root) {
407
408
  const p = join(root, GITIGNORE);
408
409
  if (!existsSync(p))
409
- return false;
410
+ return [];
410
411
  const lines = ignoreLines(readFileSync(p, "utf8"));
411
- return lines.has(".claude") || lines.has(".claude/");
412
+ return [".claude", ".agents"].filter((d) => lines.has(d) || lines.has(`${d}/`));
413
+ }
414
+ /** REQ-DIST-018 one note for the ignored agent folders, or null when none is ignored. */
415
+ export function ignoredNote(root) {
416
+ const dirs = ignoredAgentDirs(root).map((d) => `${d}/`);
417
+ if (dirs.length === 0)
418
+ return null;
419
+ const names = dirs.join(" and ");
420
+ return `note: .gitignore ignores ${names}. The skills under ${names} stay out of git.`;
412
421
  }
413
422
  /** REQ-SCH-003 where the templates with the ID placeholders are. Printed after the schema step (018/D5, 025/D4). */
414
423
  export const TEMPLATES_NOTE = `templates: ${TEMPLATES_DIR}/ (spec.md, tasks.md hold the ID placeholders)`;
415
- export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
416
424
  export function runInit(root, o) {
417
425
  const res = { written: [], kept: [], changed: [], merged: [], notes: [] };
418
426
  // REQ-DIST-002 the detection runs only when init writes spec-ctx.toml. An existing file stays (022/D2).
@@ -440,8 +448,9 @@ export function runInit(root, o) {
440
448
  const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
441
449
  if (!m || (!m.added.length && !m.merged.length)) {
442
450
  res.nothing = true;
443
- if (o.skills && ignoresClaude(root))
444
- res.notes.push(CLAUDE_NOTE);
451
+ const ign = ignoredNote(root); // REQ-DIST-018
452
+ if (ign)
453
+ res.notes.push(ign);
445
454
  if (o.schema && hasLegacySchema(cur))
446
455
  res.notes.push(LEGACY_SCHEMA_NOTE);
447
456
  if (o.schema)
@@ -499,9 +508,10 @@ export function runInit(root, o) {
499
508
  else
500
509
  res.kept.push(GITIGNORE);
501
510
  }
502
- // REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
503
- if (o.skills && ignoresClaude(root))
504
- res.notes.push(CLAUDE_NOTE);
511
+ // REQ-DIST-018 skills under an ignored agent folder never reach git (017/D3)
512
+ const ign = ignoredNote(root);
513
+ if (ign)
514
+ res.notes.push(ign);
505
515
  // REQ-SCH-003 the templates line comes last, right before next: (018/D5)
506
516
  if (o.schema)
507
517
  res.notes.push(TEMPLATES_NOTE);
@@ -0,0 +1,50 @@
1
+ // REQ-DIST-017 what init says to do next, from the state of the specs.
2
+ import { UsageExit } from "../cli/errors.js";
3
+ import { loadConfigOrExit } from "../cli/config.js";
4
+ import { ConfigError } from "../config.js";
5
+ import { planAdopt } from "../adopt/plan.js";
6
+ import { loadIndex } from "../index/build.js";
7
+ import { nextTask } from "../tasks/tasks.js";
8
+ import { runCommand } from "../runcmd.js";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ /** true when .gitignore keeps .ctx/index.json out of git, or the index is already there */
12
+ function indexIsSafe(root) {
13
+ if (existsSync(join(root, ".ctx/index.json")))
14
+ return true;
15
+ const g = join(root, ".gitignore");
16
+ if (!existsSync(g))
17
+ return false;
18
+ return readFileSync(g, "utf8").split("\n").map((l) => l.trim()).includes(".ctx/index.json");
19
+ }
20
+ /**
21
+ * Read the specs with the adopt planner. The planner writes nothing. When no header needs an ID,
22
+ * loadIndex may write .ctx/index.json, only when .gitignore already lists it or it exists (061/D1). Only a usage or config error gives
23
+ * the status fallback. Any other throw propagates.
24
+ */
25
+ export function detectNext(root, run) {
26
+ const status = { needs_id: 0, summary: null, next: runCommand(run, "status") };
27
+ try {
28
+ const cfg = loadConfigOrExit(root);
29
+ const plan = planAdopt(root, cfg);
30
+ if (plan.edits.length > 0) {
31
+ const files = new Set(plan.edits.map((e) => e.file)).size;
32
+ const caps = plan.caps.filter((c) => c.derived).length;
33
+ return {
34
+ needs_id: plan.edits.length,
35
+ summary: `specs: ${plan.edits.length} headers in ${files} files need an ID (${caps} capability codes). adopt --dry-run shows each one.`,
36
+ next: runCommand(run, "adopt"),
37
+ };
38
+ }
39
+ // a skills refresh must not leave an untracked index behind (REQ-DIST-013)
40
+ if (!indexIsSafe(root))
41
+ return status;
42
+ const task = nextTask(root, loadIndex(root, cfg).data.entries);
43
+ return task ? { ...status, next: runCommand(run, `context ${task.id}`) } : status;
44
+ }
45
+ catch (e) {
46
+ if (e instanceof UsageExit || e instanceof ConfigError)
47
+ return status;
48
+ throw e;
49
+ }
50
+ }
@@ -1,5 +1,5 @@
1
1
  // REQ-JRN-001 REQ-JRN-002 the sink that main() fills. It has no fs import, so a --version run loads no write code (057/D1).
2
- import { dirname, relative, resolve } from "node:path";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  /** REQ-JRN-002 the closed set of signals. The line version `v` versions it. */
5
5
  export const SIGNALS = ["init-ok", "context-has-code-or-test", "empty-context", "truncated-context", "usage-error", "internal-error"];
@@ -43,6 +43,9 @@ export function errInfo(e, pkgRoot = PACKAGE_ROOT) {
43
43
  catch {
44
44
  continue;
45
45
  }
46
+ // REQ-JRN-007 a frame like node:internal/... or <anonymous> is no path in the package
47
+ if (!isAbsolute(file))
48
+ continue;
46
49
  const rel = relative(pkgRoot, file);
47
50
  if (rel === "" || rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules") || resolve(rel) === rel)
48
51
  continue;
@@ -1,5 +1,6 @@
1
1
  // REQ-LOOP-001 the one OpenSpec call of next: status --change <c> --json, two keys read (024/D3, ADR-006 item 2).
2
2
  import { spawnSync } from "node:child_process";
3
+ import { childEnv } from "../util/env.js";
3
4
  const TIMEOUT_MS = 60_000;
4
5
  /** REQ-LOOP-001 the command that gate and next run for OpenSpec. The env var wins over [gate] openspec (018/D2). */
5
6
  export function openspecCommand(configured) {
@@ -19,7 +20,7 @@ function errorMessage(stdout) {
19
20
  }
20
21
  /** Run `<openspec> status --change <folder> --json` in the root. A failure of any kind is a reason, never a throw. */
21
22
  export function openspecStatus(root, openspec, folder) {
22
- const r = spawnSync("/bin/sh", ["-c", `${openspec} status --change ${folder} --json`], { cwd: root, encoding: "utf8", timeout: TIMEOUT_MS });
23
+ const r = spawnSync("/bin/sh", ["-c", `${openspec} status --change ${folder} --json`], { cwd: root, encoding: "utf8", timeout: TIMEOUT_MS, env: childEnv() /* REQ-DIST-016 */ });
23
24
  if (r.error && r.error.code === "ETIMEDOUT") {
24
25
  return { ok: false, reason: `openspec status timed out after ${TIMEOUT_MS / 1000}s` };
25
26
  }
@@ -0,0 +1,10 @@
1
+ // REQ-DIST-016 a child that may run npx gets the environment without the two variables of a nested npx.
2
+ /** npm sets the first under `npx --package`. A leaked value makes a nested npx run the wrong command. */
3
+ export const NESTED_NPX_VARS = ["npm_config_package", "npm_config_call"];
4
+ /** A copy of `base` without the nested npx variables. It never writes to `base`. */
5
+ export function childEnv(base = process.env) {
6
+ const copy = { ...base };
7
+ for (const k of NESTED_NPX_VARS)
8
+ delete copy[k];
9
+ return copy;
10
+ }
@@ -1,5 +1,6 @@
1
1
  // REQ-VER-001 REQ-VER-003 run a Verify command with sh -c in the project root, with a timeout.
2
2
  import { spawn } from "node:child_process";
3
+ import { childEnv } from "../util/env.js";
3
4
  export const TAIL_LINES = 20;
4
5
  export function tail(text, n = TAIL_LINES) {
5
6
  const lines = text.replace(/\n$/, "").split("\n");
@@ -9,7 +10,7 @@ export function tail(text, n = TAIL_LINES) {
9
10
  export function runVerify(command, cwd, timeoutS, env) {
10
11
  return new Promise((resolve, reject) => {
11
12
  // a new process group, so the timeout stops the children of sh too
12
- const child = spawn("sh", ["-c", command], { cwd, detached: true, ...(env ? { env } : {}), stdio: ["ignore", "pipe", "pipe"] });
13
+ const child = spawn("sh", ["-c", command], { cwd, detached: true, env: childEnv(env), /* REQ-DIST-016 */ stdio: ["ignore", "pipe", "pipe"] });
13
14
  const out = [];
14
15
  const err = [];
15
16
  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.5.0";
2
+ export const VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-ctx",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic context engine and task workflow for OpenSpec projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,9 +16,9 @@ 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 next --json` and run the command it prints. This is the plan stop. `next` returns `ask` with `human: true` until the human approves the question. You never resolve a plan question yourself.
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>`. `id new` reserves nothing. A second call before `specctx build` returns the same ID. 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 human approves the question. You never resolve a plan question yourself.
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
- - `start`: run `specctx task start <t>`. Read the bundle it prints. Continue.
21
+ - `start`: run `specctx task start <t>`. Read the bundle it prints. Each card ends with trust labels. Act on a line marked `verified` or `fresh`. Reread a line marked `stale since`, `unverified`, or `no evidence` with `specctx bundle <ID>` before you build on it. 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.