spec-ctx 0.4.1 → 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.
@@ -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
  };
@@ -122,6 +122,65 @@ export function weakVerifyFindings(entries) {
122
122
  export function weakVerifyWarnings(entries) {
123
123
  return weakVerifyFindings(entries).map((f) => `weak-verify ${f.id} ${f.file}:${f.line}`);
124
124
  }
125
+ /**
126
+ * REQ-CHK-010 REQ-CHK-008 REQ-CHK-009 one predicate for the delta test rule and the deferred notice (052/D1).
127
+ * A gap is a delta requirement or scenario of an active change that no test names. The rule reports the gaps of
128
+ * a change that has a task and no open task. The notice lists the gaps that an open task or a missing task hides.
129
+ */
130
+ export function deltaGaps(entries, data) {
131
+ const covered = new Set(entries.filter((e) => e.kind === "test").flatMap((e) => e.refs));
132
+ const named = new Set(data.scn_named ?? []);
133
+ // an ID that a REMOVED block of an active change names waits for archive, so it needs no test
134
+ const pending = new Set();
135
+ for (const e of entries) {
136
+ if (e.status !== "removed")
137
+ continue;
138
+ pending.add(e.id);
139
+ for (const sc of e.scenarios ?? [])
140
+ pending.add(sc);
141
+ }
142
+ const out = new Map();
143
+ const slot = (change) => {
144
+ let g = out.get(change);
145
+ if (!g)
146
+ out.set(change, (g = { open: 0, all: 0, gaps: [] }));
147
+ return g;
148
+ };
149
+ for (const e of entries) {
150
+ if (e.kind !== "task" || e.change === "main" || e.file.includes("/archive/"))
151
+ continue;
152
+ const g = slot(e.change);
153
+ g.all++;
154
+ if (e.status !== "done")
155
+ g.open++;
156
+ }
157
+ const seen = new Set();
158
+ for (const e of entries) {
159
+ if ((e.kind !== "scn" && e.kind !== "req") || e.change === "main" || (e.status !== "active" && e.status !== "modified"))
160
+ continue;
161
+ if (pending.has(e.id))
162
+ continue;
163
+ const rule = e.kind === "req" ? (covered.has(e.id) ? null : "uncovered") : named.has(e.id) ? null : "scenario-untested";
164
+ if (!rule)
165
+ continue;
166
+ const gap = [e.file, e.line, rule, e.id];
167
+ const key = e.change + JSON.stringify(gap);
168
+ if (seen.has(key))
169
+ continue;
170
+ seen.add(key);
171
+ slot(e.change).gaps.push(gap);
172
+ }
173
+ return out;
174
+ }
175
+ export function deferredOf(entries, data) {
176
+ const out = [];
177
+ for (const [change, g] of deltaGaps(entries, data)) {
178
+ // 052/D1 to include a change that has no task, the condition is `g.all === 0 || g.open > 0`
179
+ if ((g.all === 0 || g.open > 0) && g.gaps.length)
180
+ out.push({ change, untested: g.gaps.length, open: g.open });
181
+ }
182
+ return out.sort((x, y) => cmpTuple([x.change], [y.change]));
183
+ }
125
184
  /** Sorted, unique findings. The scan never reads .ctx/, so evidence adds no index entry. */
126
185
  export function check(root, cfg, data, strict) {
127
186
  const entries = data.entries;
@@ -190,29 +249,10 @@ export function check(root, cfg, data, strict) {
190
249
  if (e.kind === "scn" && !named.has(e.id))
191
250
  findings.push([e.file, e.line, "scenario-untested", e.id]);
192
251
  }
193
- // REQ-CHK-008 a delta scenario needs a test once its change has no open task (038/D1, D2)
194
- const tasksOf = new Map();
195
- for (const e of entries) {
196
- if (e.kind !== "task" || e.change === "main" || e.file.includes("/archive/"))
197
- continue;
198
- const t = tasksOf.get(e.change) ?? { open: 0, all: 0 };
199
- t.all++;
200
- if (e.status !== "done")
201
- t.open++;
202
- tasksOf.set(e.change, t);
203
- }
204
- for (const e of entries) {
205
- if ((e.kind !== "scn" && e.kind !== "req") || e.change === "main" || (e.status !== "active" && e.status !== "modified"))
206
- continue;
207
- const t = tasksOf.get(e.change);
208
- if (!t || t.open > 0 || pending.has(e.id))
209
- continue;
210
- // REQ-CHK-009 a delta requirement needs a Covers tag on the same terms (039/D2)
211
- if (e.kind === "req" && !covered.has(e.id))
212
- findings.push([e.file, e.line, "uncovered", e.id]);
213
- if (e.kind === "scn" && !named.has(e.id))
214
- findings.push([e.file, e.line, "scenario-untested", e.id]);
215
- }
252
+ // REQ-CHK-008 REQ-CHK-009 a delta item needs a test once its change has a task and no open task (038/D1, 038/D2, 039/D2)
253
+ for (const g of deltaGaps(entries, data).values())
254
+ if (g.open === 0 && g.all > 0)
255
+ findings.push(...g.gaps);
216
256
  findings.push(...evidenceFindings(root, entries, listEvidence(root)));
217
257
  }
218
258
  const uniq = new Map();
package/dist/cli/args.js CHANGED
@@ -10,6 +10,7 @@ export const COMMAND_FLAGS = {
10
10
  bundle: { budget: "int", depth: "int", all: "bool", block: "bool" },
11
11
  context: { budget: "int", depth: "int" },
12
12
  check: { strict: "bool" },
13
+ report: {},
13
14
  stats: { change: "string", write: "bool" },
14
15
  gate: { strict: "bool", markdown: "string", rerun: "bool", base: "string" },
15
16
  "id new": { for: "string", count: "int" },
@@ -6,6 +6,7 @@ import { defaults } from "../config.js";
6
6
  import { hintOf } from "../check/hints.js";
7
7
  import { paint } from "./color.js";
8
8
  import { runCommand } from "../runcmd.js";
9
+ import { note } from "../journal/note.js";
9
10
  /** check with warnings and the baseline filter. Strict mode adds the strict rules and the warnings. */
10
11
  async function runCheck(root, cfg, data, strict) {
11
12
  const { check } = await import("../check/check.js");
@@ -28,12 +29,13 @@ async function runCheck(root, cfg, data, strict) {
28
29
  * REQ-CHK-004 the findings, then a blank line, the summary line, and one line per rule with its hint.
29
30
  * A warning counts as advisory. The `baselined` line stays last (REQ-CHK-002).
30
31
  */
31
- export function checkText(r, tty, run = "specctx") {
32
+ export function checkText(r, tty, run = "specctx", deferred = []) {
32
33
  // REQ-CLI-008 every finding of check is hard, so every finding line is red. stale-evidence is one of them.
33
34
  // REQ-CHK-001 a finding with a detail, such as a retitle, carries it after the ID (021/D2)
34
35
  const body = r.findings.map((x) => paint("fail", `${x.file}:${x.line} ${x.rule} ${x.id}${x.detail ? ` ${x.detail}` : ""}\n`, tty)).join("")
35
36
  + r.warnings.map((w) => `warning: ${w}\n`).join("");
36
- const base = r.baselined ? `baselined: ${r.baselined}\n` : "";
37
+ // REQ-CHK-010 a deferred line is not a finding. It comes before the baselined line, which stays last (052/D3).
38
+ const base = deferred.map((d) => `deferred: ${d.change} ${d.untested} untested delta item${d.untested === 1 ? "" : "s"}, ${d.open} open task${d.open === 1 ? "" : "s"}\n`).join("") + (r.baselined ? `baselined: ${r.baselined}\n` : "");
37
39
  const n = r.findings.length + r.warnings.length;
38
40
  if (!n)
39
41
  return paint("dim", "0 findings\n", tty) + base;
@@ -75,8 +77,24 @@ export async function run(p, io) {
75
77
  return init(p, io);
76
78
  const root = findRoot(io.cwd, f.root);
77
79
  requireOpenSpec(root);
80
+ // REQ-JRN-001 the root is known now, so the bin entry may write the journal
81
+ note(io, { root });
82
+ // REQ-JRN-004 report runs before the config load, so a broken config still reports
83
+ if (cmd === "report") {
84
+ if (p.positional.length)
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");
89
+ const { buildReport } = await import("../journal/report.js");
90
+ const { VERSION } = await import("../version.js");
91
+ io.out(buildReport(root, new Date(), { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }).text);
92
+ return 0;
93
+ }
78
94
  const { loadConfigOrExit } = await import("./config.js");
79
95
  const cfg = loadConfigOrExit(root);
96
+ // REQ-JRN-006 the exit 3 line names the report command with this prefix
97
+ note(io, { run: cfg.cli.run });
80
98
  const { dumps } = await import("../util/json.js");
81
99
  const print = (v) => io.out(dumps(v) + "\n");
82
100
  if (cmd === "build") {
@@ -287,6 +305,9 @@ export async function run(p, io) {
287
305
  const { bundle } = await import("../graph/bundle.js");
288
306
  const { resolveInputs } = await import("../graph/input.js");
289
307
  const ids = canon(entries);
308
+ // REQ-QRY-018 a negative budget is a usage error (050/D11)
309
+ if (f.budget !== undefined && f.budget < 0)
310
+ throw new UsageExit("--budget must be 0 or more");
290
311
  // REQ-QRY-005 an input that is no known ID is a path in the repository
291
312
  const { wanted, paths, errors, taskId } = resolveInputs(root, io.cwd, entries, p.positional);
292
313
  if (errors.length) {
@@ -302,8 +323,11 @@ export async function run(p, io) {
302
323
  }
303
324
  const { reqBlocks } = await import("../graph/bundle.js");
304
325
  const text = reqBlocks(root, p.positional.map((i) => ids.get(i)));
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);
305
329
  if (json)
306
- print({ text, omitted: [] });
330
+ print({ text, omitted: [], body_omitted: [], chars: n, budget: null, complete: true, used: n, blocks: [] });
307
331
  else
308
332
  io.out(text);
309
333
  return 0;
@@ -311,6 +335,12 @@ export async function run(p, io) {
311
335
  // 047/D5 a task input passes the entry that findTask picks, so an active task wins over an archived one
312
336
  const task = taskId === undefined ? undefined : (await import("../tasks/tasks.js")).findTask(root, entries, taskId).entry;
313
337
  const b = bundle(root, entries, wanted, f.budget ?? cfg.bundle.budget, f.depth ?? cfg.bundle.depth, { all, task });
338
+ // REQ-JRN-002 only context emits the block signals. The sink is set only for the bin entry.
339
+ if (cmd === "context" && io.journal) {
340
+ const { contextSignals } = await import("../journal/signals.js");
341
+ for (const signal of contextSignals(entries, b))
342
+ note(io, { signal });
343
+ }
314
344
  if (json)
315
345
  print(b);
316
346
  else if (b.text === "" && wanted.every((w) => paths.has(w)))
@@ -343,10 +373,12 @@ export async function run(p, io) {
343
373
  const r = await runCheck(root, cfg, data, !!f.strict);
344
374
  // REQ-CHK-004 REQ-CHK-006 the JSON keeps its shape. A finding hint is for the human form only (031/D6).
345
375
  const findings = r.findings.map(({ hint: _hint, ...rest }) => rest);
376
+ // REQ-CHK-010 only this case computes the deferred notice. runCheck and gate stay as they are (052/D5).
377
+ const deferred = f.strict ? (await import("../check/check.js")).deferredOf(entries, data) : [];
346
378
  if (json)
347
- print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings });
379
+ print({ baselined: r.baselined, count: findings.length, findings, warnings: r.warnings, ...(f.strict ? { deferred } : {}) });
348
380
  else
349
- io.out(checkText(r, io.tty, cfg.cli.run));
381
+ io.out(checkText(r, io.tty, cfg.cli.run, deferred));
350
382
  return r.findings.length ? 1 : 0;
351
383
  }
352
384
  }
@@ -369,18 +401,23 @@ async function init(p, io) {
369
401
  return defaults().cli.run;
370
402
  }
371
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` : "";
372
408
  if (p.flags.json)
373
- 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");
374
410
  else if (r.nothing)
375
- 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));
376
412
  else {
377
413
  io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
378
414
  io.out(r.merged.map((m) => `merged ${m}\n`).join(""));
379
415
  io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
380
416
  io.out(r.notes.map((n) => `${n}\n`).join(""));
417
+ io.out(summary);
381
418
  // REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
382
419
  const again = [runCommand(run, "init"), all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
383
- 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));
384
421
  }
385
422
  return 0;
386
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>", "Stop at n characters. 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>", "Stop at n characters. 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."],
@@ -90,6 +90,11 @@ export const TABLE = [
90
90
  flags: [["--change <name>", "Show only this change."], ["--write", "Also write docs/metrics/<date>.md."]],
91
91
  exit: PLAIN, example: "specctx stats --write",
92
92
  },
93
+ {
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
+ flags: [],
96
+ exit: [OK, ["2", "A usage error. The command has no JSON output."], INTERNAL], example: "specctx report > report.md",
97
+ },
93
98
  {
94
99
  name: "lint tells", group: "Read", args: "<file...>", summary: "Find the AI writing tells in markdown files. Read stdin when no file is given.",
95
100
  flags: LINT_FLAGS, exit: LINT_EXIT, example: "specctx lint tells --baseline 0 README.md",
@@ -101,8 +106,8 @@ export const TABLE = [
101
106
  // Write
102
107
  { name: "build", group: "Write", args: "", summary: "Write the index to .ctx/index.json.", flags: [], exit: PLAIN, example: "specctx build" },
103
108
  {
104
- name: "id new", group: "Write", args: "<kind> <code>", summary: "Print the next free ID. The kind is REQ, SCN, ADR, or DEC.",
105
- 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`."]],
106
111
  exit: [OK, ["2", "A usage error or an unknown code."], INTERNAL], example: "specctx id new REQ IDX",
107
112
  },
108
113
  {
@@ -206,7 +211,9 @@ export const HELP = overview();
206
211
  /** REQ-CLI-006 the page of one command: Usage, Flags, Exit codes, Example. */
207
212
  export function commandPage(c) {
208
213
  const lint = c.name.startsWith("lint ");
209
- 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];
210
217
  const out = [
211
218
  `specctx ${c.name}: ${c.summary}`,
212
219
  "",
@@ -171,10 +171,13 @@ export async function runWorkflow(c) {
171
171
  // REQ-LOOP-002 the fail file is the retry record. At the cap, one question stops the loop (024/D4, 024/D5).
172
172
  const { writeFail, openQuestionFor, capQuestion, blockedLine } = await import("../loop/fails.js");
173
173
  const { addQuestion } = await import("../handoff/questions.js");
174
- const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, outTail);
174
+ // REQ-TASK-008 the fail file and the cap question hold no machine path. The printed tail stays as it is (056/D5).
175
+ const { redactPaths } = await import("../util/redact.js");
176
+ const kept = redactPaths(outTail, c.root);
177
+ const fail = writeFail(c.root, t.id, { command: t.verify.command, exit: r.timedOut ? null : r.exit }, kept);
175
178
  let question = openQuestionFor(c.root, t.id)?.n ?? null;
176
179
  if (question === null && fail.file.attempt >= c.cfg.loop.max_attempts) {
177
- question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, outTail), "cap").n;
180
+ question = addQuestion(c.root, t.id, capQuestion(t.id, fail.file.attempt, kept), "cap").n;
178
181
  }
179
182
  if (c.json)
180
183
  print(c, { attempt: fail.file.attempt, command: t.verify.command, error: msg, exit: r.exit, fail: fail.rel, question, tail: outTail, task: t.id });
@@ -196,8 +199,10 @@ export async function runWorkflow(c) {
196
199
  // the Verify command can change tagged files, so the digests come from the tree after the run
197
200
  const { loadIndex } = await import("../index/build.js");
198
201
  const after = loadIndex(c.root, c.cfg).data.entries;
202
+ // REQ-TASK-008 the stderr tail holds no machine path. verify.command stays as the task line has it (056/D5, 056/D6).
203
+ const { redactPaths } = await import("../util/redact.js");
199
204
  const rel = writeEvidence(c.root, t, after, {
200
- command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: tail(r.stderr.toString("utf8")),
205
+ command: t.verify.command, exit: 0, stdout_sha256: sha256(r.stdout), stderr_tail: redactPaths(tail(r.stderr.toString("utf8")), c.root),
201
206
  }, "command", undefined, t.verify.cwd);
202
207
  setBox(c.root, t, true);
203
208
  clearCurrent(c.root, t.id);
package/dist/cli.js CHANGED
@@ -1,14 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  // REQ-CLI-001 bin entry. tests call main() from src/main.ts in process.
3
+ // REQ-JRN-001 this entry writes the run journal after main() returns. main() only fills the sink (057/D1).
3
4
  import { main } from "./main.js";
5
+ import { VERSION } from "./version.js";
4
6
  process.stdout.on("error", (e) => {
5
7
  if (e.code === "EPIPE")
6
8
  process.exit(0);
7
9
  });
8
- const code = await main(process.argv.slice(2), {
9
- cwd: process.cwd(),
10
- out: (s) => process.stdout.write(s),
11
- err: (s) => process.stderr.write(s),
12
- tty: process.stdout.isTTY === true,
13
- });
10
+ const argv = process.argv.slice(2);
11
+ const sink = { signals: new Set() };
12
+ const t0 = performance.now();
13
+ let code;
14
+ let thrown;
15
+ let failed = false;
16
+ try {
17
+ code = await main(argv, {
18
+ cwd: process.cwd(),
19
+ out: (s) => process.stdout.write(s),
20
+ err: (s) => process.stderr.write(s),
21
+ tty: process.stdout.isTTY === true,
22
+ journal: sink,
23
+ });
24
+ }
25
+ catch (e) {
26
+ failed = true;
27
+ thrown = e;
28
+ code = 3;
29
+ try {
30
+ const { errInfo } = await import("./journal/note.js");
31
+ sink.err = errInfo(e);
32
+ }
33
+ catch {
34
+ // no journal detail
35
+ }
36
+ }
37
+ try {
38
+ // REQ-JRN-001 a usage error before the root check keeps no root. Only the "?" command with no --root looks for one (057/D2).
39
+ if (sink.root === undefined && sink.cmd === "?" && !argv.some((a) => a === "--root" || a.startsWith("--root="))) {
40
+ const { findRoot } = await import("./cli/root.js");
41
+ sink.root = findRoot(process.cwd());
42
+ }
43
+ if (sink.root !== undefined && process.env.SPECCTX_JOURNAL !== "0") {
44
+ const { appendRun } = await import("./journal/journal.js");
45
+ appendRun(sink, { exit: code, ms: performance.now() - t0, version: VERSION });
46
+ }
47
+ }
48
+ catch {
49
+ // the journal never changes the exit code or the output
50
+ }
51
+ if (failed)
52
+ throw thrown;
14
53
  process.exitCode = code;