@blamejs/exceptd-skills 0.13.25 → 0.13.27

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/CHANGELOG.md CHANGED
@@ -1,12 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.27 — 2026-05-20
4
+
5
+ `ci` next-step guidance now names the specific detected playbook(s) and surfaces pending jurisdiction obligations at the summary level — matching the regulatory-clock visibility a single `run` already gives.
6
+
7
+ ### Features
8
+
9
+ - **`ci` FAIL Next steps names the actual detected playbook id.** Multi-playbook ci runs now print `Next steps (review the N detected finding(s) in <playbook-id>, ...)` with the real ids, and the `exceptd run <id> --format markdown` / `--format csaf-2.0` commands underneath use that id directly. Previously the output substituted a `<playbook>` placeholder that operators had to manually resolve against the per-playbook table above.
10
+ - **`ci` surfaces pending jurisdiction obligations across all detected playbooks.** When at least one playbook lands `classification=detected`, the ci summary now prints `Pending jurisdiction obligations across detected playbook(s) (N) — clock starts on operator action:` grouped by `clock_start_event` — the same shape `run` emits, but aggregated across every playbook in the ci session. Operators gating a release no longer have to re-run each detected playbook individually to see the regulatory landscape.
11
+
12
+ ## 0.13.26 — 2026-05-20
13
+
14
+ Internal hygiene. No operator-visible behavior change.
15
+
16
+ ### Internal
17
+
18
+ - Stripped phase-residue version tags (`// v0.13.22 B5:`, `Pre-v0.13.23 the renderer ...`, `-v0_13_2X.test.js`) from comments and test filenames added in the v0.13.22–0.13.25 series. Authoritative version surfaces (`package.json` / `manifest.json` / `CHANGELOG.md ## X.Y.Z` headings / git tags / the CLI `version` verb) carry the version identifier; nowhere else.
19
+ - New predeploy gate `Version-tag drift (no new phase residue)` (`scripts/check-version-tags.js`) refuses new `// v0.X.Y` / `Pre-v0.X.Y` comments and `*-v0_X_Y.test.js` filenames outside the authoritative surfaces. Baseline snapshot at `tests/.version-tag-baseline.json` captures pre-existing drift; refresh after an organic cleanup with `npm run check-version-tags:update`.
20
+ - Gate count is now 17 (was 16).
21
+
3
22
  ## 0.13.25 — 2026-05-20
4
23
 
5
24
  Detected runs now surface pending jurisdiction obligations alongside the started ones — operators see the regulatory clock landscape at the same moment they see the finding, not after they remember to inspect the JSON.
6
25
 
7
26
  ### Features
8
27
 
9
- - **`run` human renderer surfaces pending jurisdiction obligations on detected runs.** Pre-v0.13.25 only `clock_started_at != null` obligations rendered; pending ones (waiting on `detect_confirmed` / `analyze_complete` / etc.) were invisible at the terminal even though the engine carried them in `phases.close.notification_actions`. Now a detected verdict prints `Pending jurisdiction obligations (N) — clock starts on operator action:` grouped by `clock_start_event`, then a `→ next: exceptd run <pb> --format csaf-2.0` pointer for the draft advisory + notification bodies. The grouping collapses one row per regulation into one row per clock-start event so the operator sees what action to take, not a wall of regulations.
28
+ - **`run` human renderer surfaces pending jurisdiction obligations on detected runs.** Detected verdicts now print `Pending jurisdiction obligations (N) — clock starts on operator action:` grouped by `clock_start_event`, then a `→ next: exceptd run <pb> --format csaf-2.0` pointer for the draft advisory + notification bodies. Previously, only obligations whose `clock_started_at` was non-null surfaced at the terminal — pending ones (waiting on `detect_confirmed` / `analyze_complete` / etc.) were invisible even though the engine carried them in `phases.close.notification_actions`. Grouping by clock-start event collapses one row per regulation into one row per action the operator must take.
10
29
 
11
30
  ## 0.13.24 — 2026-05-20
12
31
 
@@ -19,14 +38,14 @@ Detected runs now surface pending jurisdiction obligations alongside the started
19
38
 
20
39
  ## 0.13.23 — 2026-05-19
21
40
 
22
- Continuation of the v0.13.22 UX pass: stage-by-stage next-step guidance so an operator (or an AI walking the workflow cold) never has to ask "what do I do now?"
41
+ Stage-by-stage next-step guidance so an operator (or an AI walking the workflow cold) never has to ask "what do I do now?"
23
42
 
24
43
  ### Features
25
44
 
26
- - **`ci` human renderer emits a "Next steps" block per verdict.** BLOCKED → one `exceptd lint <playbook> -` command per blocked playbook plus the `--evidence <file>` re-run. NO_EVIDENCE → lint the first playbook + `ci --evidence-dir <dir>`. FAIL/detected → `run <playbook> --format markdown` / `--format csaf-2.0`. CLOCK_STARTED → `--format csaf-2.0` for the advisory draft. Pre-0.13.23 a blocked or no-evidence run printed only the reason — operators saw *why* they were stuck without the concrete command to unblock.
27
- - **`run` verdict line surfaces evidence_completeness.** Every successful run now shows `evidence: complete (13/13 indicators evaluated)` (or `partial` / `missing`) under the classification line. Distinguishes "ran every indicator and found nothing" from "couldn't evaluate, no evidence supplied" — pre-0.13.23 both states printed identically. When evidence is partial or missing, a `→ next: exceptd lint <playbook> -` pointer is appended.
28
- - **`run` attestation persistence is now visible.** Successful runs print `Attestation written: <full path>` followed by `exceptd attest verify <session-id>` and `exceptd attest diff <session-id>` so the operator knows where the JSON lives and how to verify or diff it. The persisted file path is also hoisted to the result envelope as `attestation_path`. Pre-0.13.23 the attestation went to `~/.exceptd/attestations/<repo>@<branch>/<session-id>/attestation.json` with zero indication in any output.
29
- - **`run` remediation prose matches the verdict.** Pre-0.13.23 every classification printed `Recommended remediation: <id>` — misleading on `not_detected` and `inconclusive` runs, where there is nothing to remediate. Now non-detect runs print `Remediation path (informational — verdict=<x>, no action required now): <id>`; detected runs are unchanged.
45
+ - **`ci` human renderer emits a "Next steps" block per verdict.** BLOCKED → one `exceptd lint <playbook> -` command per blocked playbook plus the `--evidence <file>` re-run. NO_EVIDENCE → lint the first playbook + `ci --evidence-dir <dir>`. FAIL/detected → `run <playbook> --format markdown` / `--format csaf-2.0`. CLOCK_STARTED → `--format csaf-2.0` for the advisory draft. Previously, a blocked or no-evidence run printed only the reason — operators saw *why* they were stuck without the concrete command to unblock.
46
+ - **`run` verdict line surfaces evidence_completeness.** Every successful run now shows `evidence: complete (13/13 indicators evaluated)` (or `partial` / `missing`) under the classification line. Distinguishes "ran every indicator and found nothing" from "couldn't evaluate, no evidence supplied" — previously, both states printed identically. When evidence is partial or missing, a `→ next: exceptd lint <playbook> -` pointer is appended.
47
+ - **`run` attestation persistence is now visible.** Successful runs print `Attestation written: <full path>` followed by `exceptd attest verify <session-id>` and `exceptd attest diff <session-id>` so the operator knows where the JSON lives and how to verify or diff it. The persisted file path is also hoisted to the result envelope as `attestation_path`. Previously, the attestation went to `~/.exceptd/attestations/<repo>@<branch>/<session-id>/attestation.json` with zero indication in any output.
48
+ - **`run` remediation prose matches the verdict.** Non-detect runs now print `Remediation path (informational — verdict=<x>, no action required now): <id>` instead of the unconditional `Recommended remediation: <id>` that previously fired on every classification — misleading on `not_detected` and `inconclusive` runs, where there is nothing to remediate. Detected runs unchanged.
30
49
 
31
50
  ### Bugs
32
51
 
@@ -39,7 +58,7 @@ Continuation of the v0.13.22 UX pass: stage-by-stage next-step guidance so an op
39
58
 
40
59
  ### Features
41
60
 
42
- - **Human-readable `ci` output by default.** Pre-0.13.22 the default `ci` output was 1000+ lines of indented JSON on every run. Now the default is a one-screen digest: verdict line, per-playbook table (id / verdict / rwep / evidence-completeness / top-finding), session-level warnings, scope-selection rules, framework gap rollup, and fail reasons. Pass `--json` or `--pretty` to get the structured body for automation.
61
+ - **Human-readable `ci` output by default.** The default `ci` output is now a one-screen digest: verdict line, per-playbook table (id / verdict / rwep / evidence-completeness / top-finding), session-level warnings, scope-selection rules, framework gap rollup, and fail reasons. Previously, the default was 1000+ lines of indented JSON on every run. Pass `--json` or `--pretty` to get the structured body for automation.
43
62
  - **Per-result hoisted summary fields.** Every `run()` result now carries `verdict`, `rwep_score`, `top_finding`, `summary_line`, and `evidence_completeness` (one of `complete` / `partial` / `missing` / `unknown` / `not-evaluated`) at the top level. Machine-readable consumers no longer walk `phases.analyze.rwep.adjusted` and `phases.detect.classification` separately to extract the headline numbers.
44
63
  - **`indicators_evaluated` + `indicators_known` per result.** Surface how many of the playbook's known indicators were actually exercised by the operator's evidence, so a result that returns `verdict=inconclusive` with `indicators_evaluated=0` is distinguishable from one that evaluated every indicator and found no hits.
45
64
  - **Session-level warning de-duplication.** `ci` runs that span N playbooks no longer emit the same `bundle_publisher_unclaimed` warning N times. The summary now carries `runtime_warnings` and `runtime_warnings_count` with one entry per unique (kind, reason) across the session.
package/bin/exceptd.js CHANGED
@@ -2289,14 +2289,12 @@ function cmdLint(runner, args, runOpts, pretty) {
2289
2289
  });
2290
2290
  }
2291
2291
  } else {
2292
- // v0.13.23 — nested submission with artifacts but no signal_overrides
2293
- // also lands every indicator on inconclusive. The flat-shape branch
2294
- // already surfaced this; the nested-shape silence was the workflow gap
2295
- // operators hit: they fill in lint's per-artifact guidance, run, and
2296
- // get an opaque "every indicator inconclusive" result. Surface the
2297
- // signal_overrides shape explicitly so the operator/AI knows the
2298
- // next step is to set `signal_overrides[<indicator-id>] = "hit" |
2299
- // "miss" | "inconclusive"` per indicator they investigated.
2292
+ // Nested submission with artifacts but no signal_overrides lands
2293
+ // every indicator on inconclusive same trapdoor the flat-shape
2294
+ // branch above surfaces. Detect() needs signal_overrides (or a
2295
+ // verdict override) to drive an indicator hit/miss; artifact
2296
+ // presence alone is not enough. Surface the JSON shape explicitly
2297
+ // so the operator/AI knows what to populate.
2300
2298
  const verdictClass = submission.verdict?.classification;
2301
2299
  const verdictWillDrive = verdictClass === "clean" || verdictClass === "not_detected" || verdictClass === "detected" || verdictClass === "inconclusive";
2302
2300
  const normalizedHasOverrides = Object.keys(normalized.signal_overrides || {}).length > 0;
@@ -2949,9 +2947,9 @@ function cmdRun(runner, args, runOpts, pretty) {
2949
2947
  result.prior_session_id = persistResult.prior_session_id;
2950
2948
  result.overwrote_at = persistResult.overwrote_at;
2951
2949
  }
2952
- // v0.13.23 — surface the attestation file path so the human renderer
2953
- // can echo it and the next-step guidance (attest verify <sid>) lands
2954
- // on an artifact the operator can actually find.
2950
+ // Surface the persisted file path on the result so the human
2951
+ // renderer can echo it and the attest verify / attest diff hint
2952
+ // lands on an artifact the operator can actually find.
2955
2953
  if (persistResult.attestation_path) {
2956
2954
  result.attestation_path = persistResult.attestation_path;
2957
2955
  }
@@ -3178,12 +3176,11 @@ function cmdRun(runner, args, runOpts, pretty) {
3178
3176
  const top = rwep?.threshold?.escalate ?? "n/a";
3179
3177
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
3180
3178
  lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
3181
- // v0.13.23 — surface evidence_completeness on the verdict line so
3182
- // operators distinguish "ran every indicator and found nothing"
3183
- // (evidence=complete) from "couldn't evaluate, no evidence supplied"
3184
- // (evidence=missing). Pre-v0.13.23 the two states printed identically
3185
- // a not_detected run with zero evidence looked the same as one
3186
- // with a fully-populated submission.
3179
+ // Surface evidence_completeness on the verdict line so operators
3180
+ // distinguish "ran every indicator and found nothing"
3181
+ // (evidence=complete) from "couldn't evaluate, no evidence
3182
+ // supplied" (evidence=missing) without this they look identical
3183
+ // at the terminal.
3187
3184
  if (obj.evidence_completeness && obj.indicators_known != null) {
3188
3185
  const ev = obj.evidence_completeness;
3189
3186
  const ke = obj.indicators_evaluated ?? 0;
@@ -3231,13 +3228,13 @@ function cmdRun(runner, args, runOpts, pretty) {
3231
3228
  lines.push(`\nIndicators that fired (${hits.length}):`);
3232
3229
  for (const i of hits.slice(0, 8)) lines.push(` ${i.id} (${i.confidence}${i.deterministic ? "/deterministic" : ""})`);
3233
3230
  }
3234
- // v0.13.23 — selected_remediation is informational on non-detect
3235
- // runs (validate() always picks the highest-priority remediation
3236
- // path as a "what would you do IF you found something" anchor).
3237
- // Rendering it as "Recommended remediation:" on a not_detected /
3238
- // inconclusive verdict misleads operators into thinking action is
3239
- // required. Tag it conditionally so the operator-facing prose
3240
- // matches the verdict.
3231
+ // selected_remediation is informational on non-detect runs:
3232
+ // validate() always picks the highest-priority remediation path
3233
+ // as a "what you'd do IF you found something" anchor, even when
3234
+ // classification is not_detected / inconclusive. Tag the prose
3235
+ // conditionally so the label matches the verdict — labeling it
3236
+ // "Recommended remediation:" on a not_detected run misleads
3237
+ // operators into thinking action is required.
3241
3238
  const rem = obj.phases?.validate?.selected_remediation;
3242
3239
  if (rem) {
3243
3240
  if (cls === "detected") {
@@ -3247,12 +3244,11 @@ function cmdRun(runner, args, runOpts, pretty) {
3247
3244
  }
3248
3245
  lines.push(` ${rem.description?.slice(0, 200) || ""}`);
3249
3246
  }
3250
- // v0.13.25 — surface BOTH started and pending notification clocks.
3251
- // Pre-v0.13.25 the renderer hid pending obligations on detected
3252
- // runs, so an operator finishing a detected scan never saw that
3253
- // jurisdiction X / regulation Y was waiting on `detect_confirmed`
3254
- // or `analyze_complete` to start a 24h / 72h / 720h clock. That
3255
- // misses the entire reason regulators care about this tool.
3247
+ // Surface BOTH started and pending notification clocks on detected
3248
+ // runs. The detection IS the regulatory event for the obligations
3249
+ // exceptd tracks pending obligations waiting on detect_confirmed
3250
+ // / analyze_complete are exactly what the operator needs to see
3251
+ // before taking the action that starts the clock.
3256
3252
  const allNotif = obj.phases?.close?.notification_actions || [];
3257
3253
  const startedNotif = allNotif.filter(n => n.clock_started_at);
3258
3254
  const pendingNotif = allNotif.filter(n => !n.clock_started_at);
@@ -3278,12 +3274,11 @@ function cmdRun(runner, args, runOpts, pretty) {
3278
3274
  const feeds = obj.phases?.close?.feeds_into || [];
3279
3275
  if (feeds.length) lines.push(`\nNext playbooks suggested: ${feeds.join(", ")}`);
3280
3276
 
3281
- // v0.13.23 — tell the operator WHERE the attestation went and HOW
3282
- // to verify/diff it. Pre-v0.13.23 a successful run silently wrote
3277
+ // Tell the operator WHERE the attestation went and HOW to verify
3278
+ // / diff it. Without this, the attestation goes to
3283
3279
  // ~/.exceptd/attestations/<repo>@<branch>/<sid>/attestation.json
3284
- // with zero indication to the operator. The next-time-they-asked-
3285
- // about-this-run lookup ("attest verify <sid>") then failed with
3286
- // "no session dir" because they were in a different cwd.
3280
+ // and a follow-up `attest verify <sid>` from a different cwd
3281
+ // fails with "no session dir" because the lookup is cwd-tagged.
3287
3282
  if (obj.attestation_path) {
3288
3283
  lines.push(`\nAttestation written: ${obj.attestation_path}`);
3289
3284
  lines.push(` exceptd attest verify ${obj.session_id} # tamper check`);
@@ -3845,11 +3840,9 @@ function persistAttestation(args) {
3845
3840
 
3846
3841
  try {
3847
3842
  writeAttestation(null, null, "wx");
3848
- // v0.13.23 — surface the absolute path so the caller can echo it
3849
- // in the human renderer. Pre-v0.13.23 the attestation was silently
3850
- // written to ~/.exceptd/attestations/<repo>@<branch>/<session-id>/
3851
- // and the operator had no way to find it short of grepping the
3852
- // filesystem.
3843
+ // Return the absolute path so the caller can echo it in the
3844
+ // human renderer operators need to know where the file went
3845
+ // for follow-up `attest verify` / `attest diff` calls.
3853
3846
  return { ok: true, prior_session_id: null, overwrote_at: null, attestation_path: filePath };
3854
3847
  } catch (eExcl) {
3855
3848
  if (eExcl.code !== "EEXIST") throw eExcl;
@@ -4620,10 +4613,9 @@ function cmdReattest(runner, args, runOpts, pretty) {
4620
4613
  // on-disk artifact without re-deriving the filename.
4621
4614
  replay_persisted: replayPersisted,
4622
4615
  }, pretty, (obj) => {
4623
- // v0.13.24 — human renderer for `attest diff` (reattest path).
4624
- // Pre-v0.13.24 the only output was JSON, so an operator asking "did
4625
- // anything change since the last run?" had to parse the envelope to
4626
- // get the one-line answer they wanted.
4616
+ // Human renderer for `attest diff` (reattest path) — one-screen
4617
+ // answer to "did anything change since the last run?" so the
4618
+ // operator doesn't have to parse the JSON envelope.
4627
4619
  const lines = [];
4628
4620
  lines.push(`attest diff: ${obj.session_id} (${obj.playbook_id})`);
4629
4621
  const icon = obj.status === "unchanged" ? "[ok]" : "[i DRIFTED]";
@@ -4962,10 +4954,9 @@ function cmdAttest(runner, args, runOpts, pretty) {
4962
4954
  body.replay_tamper = true;
4963
4955
  body.warnings = ["one or more replay records failed Ed25519 verification — audit-trail corruption suspected, regenerate via reattest"];
4964
4956
  }
4965
- // v0.13.24 — human renderer for `attest verify`. Pre-v0.13.24 the
4966
- // only output was a JSON envelope, even at the terminal. The whole
4967
- // point of `attest verify` is "did anyone tamper with my evidence
4968
- // since I ran it?" — that question deserves a one-line answer.
4957
+ // Human renderer for `attest verify` — one-line answer to "did
4958
+ // anyone tamper with my evidence since I ran it?" so the operator
4959
+ // doesn't have to parse the JSON envelope.
4969
4960
  emit(body, pretty, (obj) => {
4970
4961
  const lines = [];
4971
4962
  lines.push(`attest verify: ${obj.session_id}`);
@@ -6989,12 +6980,12 @@ function cmdCi(runner, args, runOpts, pretty) {
6989
6980
  clock_started_reasons: clockStartedReasons,
6990
6981
  };
6991
6982
 
6992
- // v0.13.22 B5: Each `run()` call independently surfaces session-level
6993
- // runtime conditions (e.g. bundle_publisher_unclaimed) into its own
6994
- // phases.analyze.runtime_errors. On a ci run that spans 9 playbooks, the
6995
- // operator saw the same warning 9 times. Dedupe across results by
6996
- // (kind, reason) so a session-level condition surfaces once at the ci
6997
- // summary level operators read one row, not N copies.
6983
+ // Each `run()` call independently surfaces session-level runtime
6984
+ // conditions (e.g. bundle_publisher_unclaimed) into its own
6985
+ // phases.analyze.runtime_errors. On a ci run spanning N playbooks
6986
+ // the same warning would otherwise appear N times. Dedupe across
6987
+ // results by (kind, reason) so session-level conditions surface
6988
+ // once at the summary level, not once per playbook.
6998
6989
  const warningSeen = new Set();
6999
6990
  const runtimeWarningsDedup = [];
7000
6991
  for (const r of results) {
@@ -7013,11 +7004,11 @@ function cmdCi(runner, args, runOpts, pretty) {
7013
7004
  summary.runtime_warnings = runtimeWarningsDedup;
7014
7005
  summary.runtime_warnings_count = runtimeWarningsDedup.length;
7015
7006
 
7016
- // v0.13.22 B8 (transparency): document why each playbook was selected.
7017
- // --scope <s> always adds cross-cutting; --scope code on a repo with a
7018
- // lockfile also adds sbom. The selection rule was buried in code; surface
7019
- // it in the summary so operators reading the JSON / pretty trailer can
7020
- // see what was scoped vs. auto-included.
7007
+ // Document why each playbook was selected. --scope <s> always adds
7008
+ // cross-cutting; --scope code on a repo with a lockfile also adds
7009
+ // sbom. The selection rule is otherwise buried in code; surface it
7010
+ // in the summary so operators reading the output can see what was
7011
+ // scoped vs. auto-included.
7021
7012
  if (scope) {
7022
7013
  summary.scope_request = scope;
7023
7014
  summary.scope_inclusion_rules = [
@@ -7071,16 +7062,14 @@ function cmdCi(runner, args, runOpts, pretty) {
7071
7062
  );
7072
7063
  return;
7073
7064
  } else {
7074
- // v0.13.22 B3+B4+B6: human renderer for `ci` default output. Pre-0.13.22
7075
- // the only output was indented JSON (or compact JSON when not TTY)
7076
- // operators running `exceptd ci` at the terminal saw 1000+ lines of JSON
7077
- // for a 9-playbook scan and had to grep for the verdict by hand.
7078
- //
7079
- // Renderer shape (one screen for a typical 9-playbook scope):
7080
- // - one verdict line (PASS/FAIL/BLOCKED + counts)
7081
- // - per-playbook row: id | verdict | rwep | evidence | top_finding
7082
- // - deduped session-level runtime warnings (B5)
7083
- // - scope inclusion rules (B8 transparency) when --scope was used
7065
+ // Human renderer for `ci` default output. Shape (one screen for a
7066
+ // typical 9-playbook scope):
7067
+ // - verdict line (PASS / FAIL / BLOCKED / CLOCK_STARTED / NO_EVIDENCE + counts)
7068
+ // - per-playbook table (id | verdict | rwep | evidence | top_finding)
7069
+ // - session-level runtime warnings (deduped by kind+reason)
7070
+ // - scope inclusion rules when --scope was used
7071
+ // - jurisdiction-clock + framework-gap rollup
7072
+ // - fail reasons + per-verdict next-step block
7084
7073
  // - footer pointing at --json / --format for the structured body
7085
7074
  emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty, (obj) => {
7086
7075
  const s = obj.summary;
@@ -7168,9 +7157,9 @@ function cmdCi(runner, args, runOpts, pretty) {
7168
7157
  for (const r of s.fail_reasons) lines.push(` - ${r}`);
7169
7158
  }
7170
7159
 
7171
- // v0.13.23 — Next-step guidance. An operator (or an AI walking the
7172
- // workflow cold) reading the ci output should never have to ask
7173
- // "what do I do now?" The verdict dictates the next move:
7160
+ // Next-step guidance, keyed on verdict. An operator reading ci
7161
+ // output should never have to ask "what do I do now?" — the
7162
+ // verdict dictates the next move:
7174
7163
  // BLOCKED → operator must supply evidence asserting the
7175
7164
  // halted preconditions; `exceptd lint <pb> -`
7176
7165
  // emits the exact JSON paths to fill in.
@@ -7194,17 +7183,54 @@ function cmdCi(runner, args, runOpts, pretty) {
7194
7183
  lines.push(lintCmd(firstId));
7195
7184
  lines.push(` exceptd ci --scope <type> --evidence-dir <dir> # gate again with real submissions`);
7196
7185
  } else if (s.verdict === "FAIL") {
7197
- // codex P2 (PR #63): FAIL can fire in two distinct shapes
7186
+ // FAIL fires in two distinct shapes:
7198
7187
  // (a) at least one playbook classification=detected → s.detected > 0
7199
7188
  // (b) inconclusive playbook(s) whose rwep_delta (operator
7200
7189
  // evidence) crossed the cap → s.detected stays at 0
7201
- // Pre-fix this branch was keyed on `s.detected > 0`, so the
7202
- // shape-(b) FAIL exited with no "Next steps" footer at all,
7203
- // contradicting the v0.13.23 stage-by-stage promise.
7190
+ // Both shapes need actionable Next-step guidance; key on the
7191
+ // shape, not on `s.detected > 0` alone.
7204
7192
  if (s.detected > 0) {
7205
- lines.push(`\nNext steps (review the ${s.detected} detected finding(s)):`);
7206
- lines.push(` exceptd run <playbook> --format markdown # operator-readable digest`);
7207
- lines.push(` exceptd run <playbook> --format csaf-2.0 # advisory bundle for downstream`);
7193
+ // Name the specific detected playbook ids so the operator
7194
+ // can copy-paste rather than substitute `<playbook>`. When
7195
+ // multiple playbooks land detected, emit one row per id
7196
+ // for each format so operators don't miss follow-up for the
7197
+ // playbooks beyond detectedIds[0].
7198
+ const detectedIds = (obj.results || [])
7199
+ .filter(r => r && r.ok !== false && r.phases?.detect?.classification === "detected")
7200
+ .map(r => r.playbook_id)
7201
+ .filter(Boolean);
7202
+ const ids = detectedIds.length ? detectedIds : ["<playbook>"];
7203
+ lines.push(`\nNext steps (review the ${s.detected} detected finding(s) in ${detectedIds.join(", ") || "<playbook>"}):`);
7204
+ for (const id of ids) {
7205
+ lines.push(` exceptd run ${id} --format markdown # operator-readable digest`);
7206
+ }
7207
+ for (const id of ids) {
7208
+ lines.push(` exceptd run ${id} --format csaf-2.0 # advisory bundle for downstream`);
7209
+ }
7210
+
7211
+ // Surface pending jurisdiction obligations across all
7212
+ // detected playbooks at the ci summary level — operators
7213
+ // running ci to gate a PR / a release deserve the same
7214
+ // regulatory-clock visibility a single `run` would give them.
7215
+ const pendingByEvent = {};
7216
+ let pendingTotal = 0;
7217
+ for (const r of obj.results || []) {
7218
+ if (r?.phases?.detect?.classification !== "detected") continue;
7219
+ const notif = r?.phases?.close?.notification_actions || [];
7220
+ for (const n of notif) {
7221
+ if (n.clock_started_at) continue;
7222
+ const ev = n.clock_start_event || "unspecified";
7223
+ if (!pendingByEvent[ev]) pendingByEvent[ev] = new Set();
7224
+ pendingByEvent[ev].add(`${n.jurisdiction || "?"}/${n.regulation || "?"} (${n.window_hours || "?"}h)`);
7225
+ pendingTotal++;
7226
+ }
7227
+ }
7228
+ if (pendingTotal > 0) {
7229
+ lines.push(`\nPending jurisdiction obligations across detected playbook(s) (${pendingTotal}) — clock starts on operator action:`);
7230
+ for (const [ev, refs] of Object.entries(pendingByEvent)) {
7231
+ lines.push(` on ${ev}: ${[...refs].join(", ")}`);
7232
+ }
7233
+ }
7208
7234
  } else {
7209
7235
  // Operator evidence pushed RWEP across --max-rwep cap on an
7210
7236
  // otherwise-inconclusive run. The fix is to review which
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-20T00:48:54.230Z",
3
+ "generated_at": "2026-05-20T03:47:07.431Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "56140e0d1624f8c83333f27759e21815daf57a8face69d7aff3eb88d087f27b1",
7
+ "manifest.json": "9e9f77b63bb9cf8a34e235fe103e3e71efa558f2802fd563f73b18dc8f516d99",
8
8
  "data/atlas-ttps.json": "d296c1d3e71807c9279b731f047e57796e85137f186586743a8cdad214b408f9",
9
9
  "data/attack-techniques.json": "49b6010b317edd219def135171ea8f3b1bbf1e00e9c5a08bf7237215ff54e2c3",
10
10
  "data/cve-catalog.json": "a09c83af3f9679a7ea73935726a1ff9de2cab94b4ab6321fc017fc147747d7c3",
@@ -3058,13 +3058,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3058
3058
  }
3059
3059
  const pre = preflight(playbook, { ...runOpts, precondition_checks: mergedPCs });
3060
3060
  if (!pre.ok) {
3061
- // v0.13.22 (B7 fix): blocked results MUST carry playbook_id +
3062
- // directive_id at the result root. Pre-v0.13.22 a `ci --scope code`
3063
- // run produced 9 results where the 3 blocked rows had no
3064
- // playbook_id, so operators iterating results[] couldn't tell
3065
- // which playbook produced which preflight failure without joining
3066
- // against playbooks_run[] by index. Also include `verdict:"blocked"`
3067
- // and a one-line `summary_line` for B2 + B6 flat-result-shape fixes.
3061
+ // Blocked results MUST carry playbook_id + directive_id at the
3062
+ // result root, the same as successful results. Without this,
3063
+ // consumers iterating results[] can't identify which playbook
3064
+ // produced a preflight failure without joining against
3065
+ // playbooks_run[] by array index. `verdict:"blocked"` +
3066
+ // `summary_line` keep the flat result-envelope shape consistent
3067
+ // across both branches.
3068
3068
  const summaryLine = `${playbookId}: blocked at preflight (${pre.blocked_by || 'unknown'}) — ${pre.reason || ''}`.slice(0, 240);
3069
3069
  return {
3070
3070
  ok: false,
@@ -3226,18 +3226,16 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3226
3226
  }))
3227
3227
  .digest('hex');
3228
3228
 
3229
- // v0.13.22 — hoist the operator-facing summary to the result root
3230
- // (B2). Pre-v0.13.22 a `ci` consumer had to spelunk into
3231
- // phases.detect.classification + phases.analyze.rwep.adjusted to
3232
- // find the answer; the most common ops question ("did this
3233
- // playbook detect anything?") needs a one-line answer at the top.
3229
+ // Hoist the operator-facing summary to the result root so a `ci`
3230
+ // consumer does not have to walk into phases.detect.classification
3231
+ // + phases.analyze.rwep.adjusted separately to answer the most
3232
+ // common ops question ("did this playbook detect anything?").
3234
3233
  //
3235
3234
  // Verdict source: phases.detect.classification is the canonical
3236
- // verdict signal — one of detected / not_detected / inconclusive /
3237
- // pending / skipped. validate() does NOT carry a `verdict` field;
3238
- // an earlier draft of this hoist read phases.validate.verdict and
3239
- // therefore degraded every non-blocked result to "inconclusive"
3240
- // (codex P1 on PR #62).
3235
+ // signal — one of detected / not_detected / inconclusive / pending
3236
+ // / skipped. validate() does NOT carry a `verdict` field, so
3237
+ // sourcing the hoist from phases.validate.verdict would degrade
3238
+ // every non-blocked result to the fallback "inconclusive".
3241
3239
  const verdict = (phases.detect && phases.detect.classification) || 'inconclusive';
3242
3240
  const rwepScore = phases.analyze && phases.analyze.rwep && typeof phases.analyze.rwep.adjusted === 'number'
3243
3241
  ? phases.analyze.rwep.adjusted : null;
@@ -3253,10 +3251,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3253
3251
  && phases.detect.classification !== 'pending') {
3254
3252
  topFinding = phases.detect.classification;
3255
3253
  }
3256
- // Evidence completeness (B9): the indicators-evaluated count vs
3257
- // the indicators-known count distinguishes "ran fully and found
3258
- // nothing" from "couldn't actually evaluate". Pre-v0.13.22 the two
3259
- // states looked identical at the result-root level.
3254
+ // Evidence completeness: indicators-evaluated vs indicators-known
3255
+ // distinguishes "ran fully and found nothing" from "couldn't
3256
+ // actually evaluate". Without this, the two states look identical
3257
+ // at the result-root level — a not_detected verdict with zero
3258
+ // indicators-evaluated reads the same as one with every indicator
3259
+ // evaluated and miss.
3260
3260
  const indicatorsKnown = (playbook.phases && playbook.phases.detect && playbook.phases.detect.indicators)
3261
3261
  ? playbook.phases.detect.indicators.length : null;
3262
3262
  const indicatorsEvaluated = phases.detect && typeof phases.detect.indicators_evaluated_count === 'number'
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exceptd-security",
3
- "version": "0.13.25",
3
+ "version": "0.13.27",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation",
5
5
  "homepage": "https://exceptd.com",
6
6
  "license": "Apache-2.0",
@@ -53,7 +53,7 @@
53
53
  ],
54
54
  "last_threat_review": "2026-05-01",
55
55
  "signature": "lXhZgoIrrVloO3XaTvo/43AxZn4mwErstd7DR0O/oVhD3AOGODM4HqrageYEou9WKOdMEGP5mJNTjJsXdP5NDA==",
56
- "signed_at": "2026-05-20T00:48:05.112Z",
56
+ "signed_at": "2026-05-20T03:47:06.133Z",
57
57
  "cwe_refs": [
58
58
  "CWE-125",
59
59
  "CWE-362",
@@ -117,7 +117,7 @@
117
117
  ],
118
118
  "last_threat_review": "2026-05-01",
119
119
  "signature": "vSVqu4wBm+d68ujZmM6Rto/HzViCkE0gPUcv/MYE/bjFiqamf/s0On4kTOo1KIveV9cOwYNxiItaGEWlVkRFDg==",
120
- "signed_at": "2026-05-20T00:48:05.114Z",
120
+ "signed_at": "2026-05-20T03:47:06.135Z",
121
121
  "cwe_refs": [
122
122
  "CWE-1039",
123
123
  "CWE-1426",
@@ -180,7 +180,7 @@
180
180
  ],
181
181
  "last_threat_review": "2026-05-01",
182
182
  "signature": "RIgXKvolQjgJdnlrDnVOd90IOY1B7VHHZD/YJQRzouL+wUeOLclPrdK/EgEuFyiu7lR4bi+Pl6aGB9G9tOxYCQ==",
183
- "signed_at": "2026-05-20T00:48:05.114Z",
183
+ "signed_at": "2026-05-20T03:47:06.135Z",
184
184
  "cwe_refs": [
185
185
  "CWE-22",
186
186
  "CWE-345",
@@ -226,7 +226,7 @@
226
226
  "framework_gaps": [],
227
227
  "last_threat_review": "2026-05-01",
228
228
  "signature": "RYOxeq/o3uTwTWq4H7RcdH2Aclg9UyCERfUH9Frwkzncsowg7LgxpaEDc3swTCv73HMEGbU8wVbXguZ4JxHUCQ==",
229
- "signed_at": "2026-05-20T00:48:05.114Z"
229
+ "signed_at": "2026-05-20T03:47:06.136Z"
230
230
  },
231
231
  {
232
232
  "name": "compliance-theater",
@@ -257,7 +257,7 @@
257
257
  ],
258
258
  "last_threat_review": "2026-05-01",
259
259
  "signature": "DneJCPKCPcoe6nQ82XptqSqNfSRdt1orKaO+o7K36YCciDrzwJb+1BuBLusPDtpcdDaGY0y0e+AqiTYJklhBAQ==",
260
- "signed_at": "2026-05-20T00:48:05.115Z"
260
+ "signed_at": "2026-05-20T03:47:06.136Z"
261
261
  },
262
262
  {
263
263
  "name": "exploit-scoring",
@@ -286,7 +286,7 @@
286
286
  ],
287
287
  "last_threat_review": "2026-05-01",
288
288
  "signature": "NA1hoQycvQhSUoG5rwlXX0mOVmGxoXRVezkELGEA2nZOdGis4gXkHT3O6Sfw7zxE4JuMrsCb65TEeOWk9WEPDg==",
289
- "signed_at": "2026-05-20T00:48:05.115Z"
289
+ "signed_at": "2026-05-20T03:47:06.137Z"
290
290
  },
291
291
  {
292
292
  "name": "rag-pipeline-security",
@@ -323,7 +323,7 @@
323
323
  ],
324
324
  "last_threat_review": "2026-05-01",
325
325
  "signature": "XgrzcA2brPhXrSTxrcLnJec0OpgGYJBoSTUlJ10UdePHffxqb9LTVGnfbmEk1ykQifXREZexui2bG7X/+eFfCQ==",
326
- "signed_at": "2026-05-20T00:48:05.116Z",
326
+ "signed_at": "2026-05-20T03:47:06.137Z",
327
327
  "cwe_refs": [
328
328
  "CWE-1395",
329
329
  "CWE-1426"
@@ -380,7 +380,7 @@
380
380
  ],
381
381
  "last_threat_review": "2026-05-01",
382
382
  "signature": "9+hZlZOqZdeACUmamQk66L5levZhhwnFXuYRhdT6Mce99eQaKT7wNfWq12hXQztkRcVRKaFH+a01zwJQwsRQCA==",
383
- "signed_at": "2026-05-20T00:48:05.116Z",
383
+ "signed_at": "2026-05-20T03:47:06.138Z",
384
384
  "d3fend_refs": [
385
385
  "D3-CA",
386
386
  "D3-CSPP",
@@ -415,7 +415,7 @@
415
415
  "framework_gaps": [],
416
416
  "last_threat_review": "2026-05-01",
417
417
  "signature": "ciqhVloMWWXEigPZvvwoV2c54tEqsDqsoc+sS/mNTFFJk2H+tz2+XUrgfEPRuYw0FeyNB6/+27pL2NpKHzUqAg==",
418
- "signed_at": "2026-05-20T00:48:05.116Z",
418
+ "signed_at": "2026-05-20T03:47:06.138Z",
419
419
  "cwe_refs": [
420
420
  "CWE-1188"
421
421
  ]
@@ -443,7 +443,7 @@
443
443
  "framework_gaps": [],
444
444
  "last_threat_review": "2026-05-01",
445
445
  "signature": "xiHAhhdufm9hCKU8PLiPE0MX65ej2F4OZwtlWLGLCiie9/km+Kiqbt192LcMvr94v83C98pb9wIaqFsFWft6AQ==",
446
- "signed_at": "2026-05-20T00:48:05.117Z"
446
+ "signed_at": "2026-05-20T03:47:06.138Z"
447
447
  },
448
448
  {
449
449
  "name": "global-grc",
@@ -475,7 +475,7 @@
475
475
  "framework_gaps": [],
476
476
  "last_threat_review": "2026-05-01",
477
477
  "signature": "oYsSk35N2Uzq7MRofACykylcVwkgPhI4luWZ14vmQT+gUKLyZiKVOUJbe1+7lGl6BYPRN0sUDQ0f7S5Eu5w2Ag==",
478
- "signed_at": "2026-05-20T00:48:05.117Z"
478
+ "signed_at": "2026-05-20T03:47:06.139Z"
479
479
  },
480
480
  {
481
481
  "name": "zeroday-gap-learn",
@@ -502,7 +502,7 @@
502
502
  "framework_gaps": [],
503
503
  "last_threat_review": "2026-05-01",
504
504
  "signature": "igRqYyU1unRFH40BsPyAR62SPrk8QZv8dPGb8S9O9EvLCNOZAzm3t+HdT/NKqzWHwrpomOzkkkyLfYI/0qTUDA==",
505
- "signed_at": "2026-05-20T00:48:05.117Z"
505
+ "signed_at": "2026-05-20T03:47:06.139Z"
506
506
  },
507
507
  {
508
508
  "name": "pqc-first",
@@ -554,7 +554,7 @@
554
554
  ],
555
555
  "last_threat_review": "2026-05-01",
556
556
  "signature": "vhc3wuQEro/86s1ro2b/KakUXg8QVnySYTBqA7ebzv9oeR2HYO5bvGEJp3oOHWtL37JDqcCAHYadSN/qxIyCCA==",
557
- "signed_at": "2026-05-20T00:48:05.118Z",
557
+ "signed_at": "2026-05-20T03:47:06.139Z",
558
558
  "cwe_refs": [
559
559
  "CWE-327"
560
560
  ],
@@ -601,7 +601,7 @@
601
601
  ],
602
602
  "last_threat_review": "2026-05-01",
603
603
  "signature": "MS35nWm8djfJGn4OOoT0JKJ2aO+Dkbb6wOOWJYvNZlRKT3UGA59o2gxg1JOnD20hb/RwxtkmCujhl2tuYSR+AQ==",
604
- "signed_at": "2026-05-20T00:48:05.118Z"
604
+ "signed_at": "2026-05-20T03:47:06.140Z"
605
605
  },
606
606
  {
607
607
  "name": "security-maturity-tiers",
@@ -638,7 +638,7 @@
638
638
  ],
639
639
  "last_threat_review": "2026-05-01",
640
640
  "signature": "8Px1s2lDj10/Q6erwEQlXgUHM1+OTruUR8qAHPX7Oo3k/l69N6P9sm0PsafS9wDFtj9l5C/OiLiFgzMlMt6vBw==",
641
- "signed_at": "2026-05-20T00:48:05.118Z",
641
+ "signed_at": "2026-05-20T03:47:06.140Z",
642
642
  "cwe_refs": [
643
643
  "CWE-1188"
644
644
  ]
@@ -673,7 +673,7 @@
673
673
  "framework_gaps": [],
674
674
  "last_threat_review": "2026-05-11",
675
675
  "signature": "WAu5fRirzSOcnnZsTx2d/JJZwa/LPpXCi+31qATTGLmoNuhyy81k3ooPe9kCM3E0CLMtvTePg9DagYqBninZDQ==",
676
- "signed_at": "2026-05-20T00:48:05.119Z"
676
+ "signed_at": "2026-05-20T03:47:06.140Z"
677
677
  },
678
678
  {
679
679
  "name": "attack-surface-pentest",
@@ -744,7 +744,7 @@
744
744
  "PTES revision incorporating AI-surface enumeration"
745
745
  ],
746
746
  "signature": "7eEwCXFd9pDKUw7yCUbRJSjfzozE44dwwwemCQUPm8JBPztLltibD9bL/RszSbYyCrYJmVb5Drncz2cGe62gCw==",
747
- "signed_at": "2026-05-20T00:48:05.119Z"
747
+ "signed_at": "2026-05-20T03:47:06.141Z"
748
748
  },
749
749
  {
750
750
  "name": "fuzz-testing-strategy",
@@ -804,7 +804,7 @@
804
804
  "OSS-Fuzz-Gen / AI-assisted harness generation becoming the default expectation for OSS maintainers"
805
805
  ],
806
806
  "signature": "Z7ypCUnXx8JpLtgxxB6RHNi39w74AmrGY1N4ofAGCXhkuM2EaFVm1AU0dvl9UQ1bVLfHKEDGqMO/TwlIY7RABg==",
807
- "signed_at": "2026-05-20T00:48:05.119Z"
807
+ "signed_at": "2026-05-20T03:47:06.141Z"
808
808
  },
809
809
  {
810
810
  "name": "dlp-gap-analysis",
@@ -879,7 +879,7 @@
879
879
  "Quebec Law 25, India DPDPA, KSA PDPL enforcement actions naming AI-tool prompt data as in-scope personal information"
880
880
  ],
881
881
  "signature": "fgxG344JGYBWWWwFXZ1IzGipWKP7EyBhrsvsbsb0CCGXfv/MvNHVNI6G0zQddCsWX1JeQbhZT3Vk8v1uJKDTDA==",
882
- "signed_at": "2026-05-20T00:48:05.120Z"
882
+ "signed_at": "2026-05-20T03:47:06.141Z"
883
883
  },
884
884
  {
885
885
  "name": "supply-chain-integrity",
@@ -956,7 +956,7 @@
956
956
  "OpenSSF model-signing — emerging Sigstore-based signing standard for ML model weights; track for production adoption"
957
957
  ],
958
958
  "signature": "pcLrM98A3vUSZRjwNAk0aZ9umvOwB41XCLLsCOy/IebB2F/06oIrGUKkMHtHwm4pTVPShMMcKdZQQ3jz30FnCg==",
959
- "signed_at": "2026-05-20T00:48:05.120Z"
959
+ "signed_at": "2026-05-20T03:47:06.142Z"
960
960
  },
961
961
  {
962
962
  "name": "defensive-countermeasure-mapping",
@@ -1013,7 +1013,7 @@
1013
1013
  ],
1014
1014
  "last_threat_review": "2026-05-11",
1015
1015
  "signature": "gqF8eU3VBrZhO2WnlcqKa7wm1d2mmWtvpbmx0kNCgHojNV+qEt+Ij84RO6bZvaUqhfYPWizWL79Fa4DL0curAQ==",
1016
- "signed_at": "2026-05-20T00:48:05.120Z"
1016
+ "signed_at": "2026-05-20T03:47:06.142Z"
1017
1017
  },
1018
1018
  {
1019
1019
  "name": "identity-assurance",
@@ -1080,7 +1080,7 @@
1080
1080
  "d3fend_refs": [],
1081
1081
  "last_threat_review": "2026-05-11",
1082
1082
  "signature": "Wv5hGMeHjlaQK1zwicVCA7AvdKgJBgvcjdpGM9Ywahh9tagAKhbkOjybowDQZzu7OZ3bDkbh6pBYc1Sdwr6NAA==",
1083
- "signed_at": "2026-05-20T00:48:05.121Z"
1083
+ "signed_at": "2026-05-20T03:47:06.142Z"
1084
1084
  },
1085
1085
  {
1086
1086
  "name": "ot-ics-security",
@@ -1136,7 +1136,7 @@
1136
1136
  "d3fend_refs": [],
1137
1137
  "last_threat_review": "2026-05-11",
1138
1138
  "signature": "8t5qKHd3yWi57dvG36YQkLN/X9bQWqtEiYjay4IfSmqhJpM/xXPaQVKNGz3wscrO8OLKUZ0OaX7Mj5kzpgBKBQ==",
1139
- "signed_at": "2026-05-20T00:48:05.121Z"
1139
+ "signed_at": "2026-05-20T03:47:06.143Z"
1140
1140
  },
1141
1141
  {
1142
1142
  "name": "coordinated-vuln-disclosure",
@@ -1188,7 +1188,7 @@
1188
1188
  "NYDFS 23 NYCRR 500 amendments potentially adding explicit CVD program requirements"
1189
1189
  ],
1190
1190
  "signature": "GDGt4UPqBa04PjlpSmpyihGzd3OgfBN7jaAK5tfwp+LRSs3ygKOdbeivUCCHNagTY1hE6hG2Ou40ADfBFuXeAg==",
1191
- "signed_at": "2026-05-20T00:48:05.121Z"
1191
+ "signed_at": "2026-05-20T03:47:06.143Z"
1192
1192
  },
1193
1193
  {
1194
1194
  "name": "threat-modeling-methodology",
@@ -1238,7 +1238,7 @@
1238
1238
  "PASTA v2 updates incorporating AI/ML application threats"
1239
1239
  ],
1240
1240
  "signature": "rFBpOQEJUPpl+v88Lw/WqVJRhTl80vy0VbPAbzQj3Q0suJRRrJg368I9uKu5LXIBKFDvKxnGIcIzbGg9NUtaCA==",
1241
- "signed_at": "2026-05-20T00:48:05.122Z"
1241
+ "signed_at": "2026-05-20T03:47:06.143Z"
1242
1242
  },
1243
1243
  {
1244
1244
  "name": "webapp-security",
@@ -1312,7 +1312,7 @@
1312
1312
  "d3fend_refs": [],
1313
1313
  "last_threat_review": "2026-05-11",
1314
1314
  "signature": "ux85YI4t2mVHOyt744Yin1HHy+z11JIFygjKfFfQOBBl5QVV3A267jeIy7utix85irMcpZm/T3yx/ooqiK2tBA==",
1315
- "signed_at": "2026-05-20T00:48:05.122Z"
1315
+ "signed_at": "2026-05-20T03:47:06.144Z"
1316
1316
  },
1317
1317
  {
1318
1318
  "name": "ai-risk-management",
@@ -1362,7 +1362,7 @@
1362
1362
  "d3fend_refs": [],
1363
1363
  "last_threat_review": "2026-05-11",
1364
1364
  "signature": "IIXnkZ5ZNqFwOto5KfytADTLLZLoyXNZACD1ORZ40P1HUAQxe6u2uyXFzzsfuob4Uy06jNkRGr2FFgCphUH1Cw==",
1365
- "signed_at": "2026-05-20T00:48:05.122Z"
1365
+ "signed_at": "2026-05-20T03:47:06.144Z"
1366
1366
  },
1367
1367
  {
1368
1368
  "name": "sector-healthcare",
@@ -1422,7 +1422,7 @@
1422
1422
  "d3fend_refs": [],
1423
1423
  "last_threat_review": "2026-05-11",
1424
1424
  "signature": "AhF9KF8ZBlDteciV+F8IBSmFVYCvQOn44GmD4rZjgLoPxfIv/QE1/vSkK32zyqDKtHWkLSXExbkkPkxA/V6dDw==",
1425
- "signed_at": "2026-05-20T00:48:05.123Z"
1425
+ "signed_at": "2026-05-20T03:47:06.145Z"
1426
1426
  },
1427
1427
  {
1428
1428
  "name": "sector-financial",
@@ -1503,7 +1503,7 @@
1503
1503
  "TIBER-EU framework v2.0 alignment with DORA TLPT RTS (JC 2024/40); cross-recognition with CBEST and iCAST"
1504
1504
  ],
1505
1505
  "signature": "HQgZvb4ReziEz5rNFr8i/O8/rJEZR+iHRROT7m/D2QUqhrcNISPkYXENsUZlG8xapzy/Ik92ehkseyj4hdmhCQ==",
1506
- "signed_at": "2026-05-20T00:48:05.123Z"
1506
+ "signed_at": "2026-05-20T03:47:06.145Z"
1507
1507
  },
1508
1508
  {
1509
1509
  "name": "sector-federal-government",
@@ -1572,7 +1572,7 @@
1572
1572
  "Australia PSPF 2024 revision and ISM quarterly updates — track for Essential Eight Maturity Level requirements for federal entities"
1573
1573
  ],
1574
1574
  "signature": "linxmsXZiOYtcs71sSWgGCrvb8xQfmxmtTY5PRvZJ0/8FgJulo0tQtejzexYG775s7XhjAmGsDP238BQTQ8ADA==",
1575
- "signed_at": "2026-05-20T00:48:05.124Z"
1575
+ "signed_at": "2026-05-20T03:47:06.145Z"
1576
1576
  },
1577
1577
  {
1578
1578
  "name": "sector-energy",
@@ -1637,7 +1637,7 @@
1637
1637
  "ICS-CERT advisory feed (https://www.cisa.gov/news-events/cybersecurity-advisories/ics-advisories) for vendor CVEs in Siemens, Rockwell, Schneider Electric, ABB, GE Vernova, Hitachi Energy, AVEVA / OSIsoft PI"
1638
1638
  ],
1639
1639
  "signature": "JjBfc0ovta560Clk0x3QGRM5osFJDwcvpy3rT7QEGdCIL827jzE8QCow1C8deXq+4JhY2sA/d7/8IsxikdlkCg==",
1640
- "signed_at": "2026-05-20T00:48:05.124Z"
1640
+ "signed_at": "2026-05-20T03:47:06.146Z"
1641
1641
  },
1642
1642
  {
1643
1643
  "name": "sector-telecom",
@@ -1723,7 +1723,7 @@
1723
1723
  "O-RAN SFG / WG11 security specifications"
1724
1724
  ],
1725
1725
  "signature": "JWVxKFoKrbX4d+Tko1d4OBdwyg25MfFFKn4CT6E/CzH+YwnU3T6Y76uBQIKg3+gIGTvPduqyvQwQQ5FxKDuPBw==",
1726
- "signed_at": "2026-05-20T00:48:05.124Z"
1726
+ "signed_at": "2026-05-20T03:47:06.146Z"
1727
1727
  },
1728
1728
  {
1729
1729
  "name": "api-security",
@@ -1792,7 +1792,7 @@
1792
1792
  "d3fend_refs": [],
1793
1793
  "last_threat_review": "2026-05-11",
1794
1794
  "signature": "BmCRCestWqr55+fCynEhtAl5NWLT+xLTkpwS0Icp3SaoZOw/ce3Y6TtqjHRSKn4CBJq7YDiLRWxmhO3MStvOAA==",
1795
- "signed_at": "2026-05-20T00:48:05.125Z"
1795
+ "signed_at": "2026-05-20T03:47:06.146Z"
1796
1796
  },
1797
1797
  {
1798
1798
  "name": "cloud-security",
@@ -1873,7 +1873,7 @@
1873
1873
  "CISA KEV additions for cloud-control-plane CVEs (IMDSv1 abuses, federation token mishandling, cross-tenant boundary failures); CISA Cybersecurity Advisories for cross-cloud advisories"
1874
1874
  ],
1875
1875
  "signature": "/DV3pmZwrRySrk1OCbyI+0BQESacjupJfUX3eC2NGtXuYOBro0vndIP+z27heFxumnjU3a9sfla7/U9X+pqnDw==",
1876
- "signed_at": "2026-05-20T00:48:05.125Z"
1876
+ "signed_at": "2026-05-20T03:47:06.147Z"
1877
1877
  },
1878
1878
  {
1879
1879
  "name": "container-runtime-security",
@@ -1935,7 +1935,7 @@
1935
1935
  "d3fend_refs": [],
1936
1936
  "last_threat_review": "2026-05-11",
1937
1937
  "signature": "E2UGSf9ATyYgzBr8uM/0ubOUmDqo1jVA7f9mVxv6LHfWGCNuQNXDyuNou9VAmUCeeXEeUYIi3AFjXkJqpOkxDA==",
1938
- "signed_at": "2026-05-20T00:48:05.125Z"
1938
+ "signed_at": "2026-05-20T03:47:06.147Z"
1939
1939
  },
1940
1940
  {
1941
1941
  "name": "mlops-security",
@@ -2006,7 +2006,7 @@
2006
2006
  "MITRE ATLAS v5.6.0 (released February 2026) shipped the AML.T0010 sub-technique expansion this forecast tracked plus new techniques (\"Publish Poisoned AI Agent Tool\", \"Escape to Host\"); inventory now 16 tactics, 84 techniques, 56 sub-techniques. Forward watch: ATLAS v5.5 / v6.0 — track next-cadence updates to agentic-AI TTPs and MLOps-pipeline-specific techniques"
2007
2007
  ],
2008
2008
  "signature": "BGNE6ZQWBA1LmsUFe8tU0L67iGDSrFqiuqaZD2f1KqfcyqqzQfMs9PWNHFzxxaJmXeKlm87eU8lgELF0bX+RBA==",
2009
- "signed_at": "2026-05-20T00:48:05.126Z"
2009
+ "signed_at": "2026-05-20T03:47:06.148Z"
2010
2010
  },
2011
2011
  {
2012
2012
  "name": "incident-response-playbook",
@@ -2068,7 +2068,7 @@
2068
2068
  "NYDFS 23 NYCRR 500.17 amendments tightening ransom-payment 24h disclosure operationalization"
2069
2069
  ],
2070
2070
  "signature": "FkZQerh3VHVJAwIcCktDyMRh5KE2+Em/i0ek8zEz7JG/PXtQx8ujHWTh3VjZbOLhPNtdB2qxgXOIAYIofaVOAQ==",
2071
- "signed_at": "2026-05-20T00:48:05.126Z"
2071
+ "signed_at": "2026-05-20T03:47:06.148Z"
2072
2072
  },
2073
2073
  {
2074
2074
  "name": "ransomware-response",
@@ -2148,7 +2148,7 @@
2148
2148
  ],
2149
2149
  "last_threat_review": "2026-05-15",
2150
2150
  "signature": "n3UToNuN3A1HgLvcuqmIx8vrZY71+r/79waK92jG+rSX4uYOzkmxMUpROrE5K9bDwMezNBHdjWv8Uul6zugyDQ==",
2151
- "signed_at": "2026-05-20T00:48:05.127Z"
2151
+ "signed_at": "2026-05-20T03:47:06.148Z"
2152
2152
  },
2153
2153
  {
2154
2154
  "name": "email-security-anti-phishing",
@@ -2201,7 +2201,7 @@
2201
2201
  "d3fend_refs": [],
2202
2202
  "last_threat_review": "2026-05-11",
2203
2203
  "signature": "rK+WnuS+9tqEABmwc0jO/PEmxcLjG1/tmUb897HsClQeKzf+TQOlwBE+OsbtuKxpjYNwur62Xxs3TxObkwm8Cw==",
2204
- "signed_at": "2026-05-20T00:48:05.127Z"
2204
+ "signed_at": "2026-05-20T03:47:06.149Z"
2205
2205
  },
2206
2206
  {
2207
2207
  "name": "age-gates-child-safety",
@@ -2269,7 +2269,7 @@
2269
2269
  "US state adult-site age-verification laws — 19+ states by mid-2026 (TX HB 18 upheld by SCOTUS June 2025 in Free Speech Coalition v. Paxton); track ongoing challenges in remaining states"
2270
2270
  ],
2271
2271
  "signature": "+OO0RhQ303RJV7kaH38IuZpLeQbapep6Ds4Re/WEZu0FHBwKSlwvF7jbtP7KQ57xldJYn/xZm2jaszyOacMfDg==",
2272
- "signed_at": "2026-05-20T00:48:05.127Z"
2272
+ "signed_at": "2026-05-20T03:47:06.149Z"
2273
2273
  },
2274
2274
  {
2275
2275
  "name": "cloud-iam-incident",
@@ -2349,7 +2349,7 @@
2349
2349
  ],
2350
2350
  "last_threat_review": "2026-05-15",
2351
2351
  "signature": "e/kij7GtKaytROyIj7V5RH+FC9WtmVFzrmG2kIlNDNn29ep/CRNlIQKwXLpzo/81AIf634pmdr1qy/+vwIuUDA==",
2352
- "signed_at": "2026-05-20T00:48:05.128Z"
2352
+ "signed_at": "2026-05-20T03:47:06.149Z"
2353
2353
  },
2354
2354
  {
2355
2355
  "name": "idp-incident-response",
@@ -2430,11 +2430,11 @@
2430
2430
  ],
2431
2431
  "last_threat_review": "2026-05-15",
2432
2432
  "signature": "ew9Kglc9fAZzbn0ZIfGP7WSK/j4eV2VhSvpy+s5bEfNEVYIMa2kZjnGBapgUsyGDLes9H9K2ovjQyX17+GKiBw==",
2433
- "signed_at": "2026-05-20T00:48:05.128Z"
2433
+ "signed_at": "2026-05-20T03:47:06.150Z"
2434
2434
  }
2435
2435
  ],
2436
2436
  "manifest_signature": {
2437
2437
  "algorithm": "Ed25519",
2438
- "signature_base64": "wn3gVy+dARzm5jSMzCYVWnNZaqzV3XpvKSmZ49+W4W+AwBZGoW6lo+MNPaQ8bKKpLgmwx94EMOnGhgyFM5dvAQ=="
2438
+ "signature_base64": "clr4Flw551fOYatT6y2GZqejMax68GStrCb24cYycm5/YhRugCF/X7cjrzy+FrjlKOz0jEVPmRsg+vE+GjeTAg=="
2439
2439
  }
2440
2440
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.13.25",
3
+ "version": "0.13.27",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 42 skills, 10 catalogs (312 CVEs / 171 CWEs / 805 ATT&CK + ICS / 170 ATLAS / 468 D3FEND / 7476 RFCs), 34 jurisdictions, 10-class catalog gap detector + budget gate, real XML parser + canonical-form diff + content-pattern regression detection, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
@@ -101,6 +101,8 @@
101
101
  "audit-catalog-gaps": "node scripts/audit-catalog-gaps.js --pretty",
102
102
  "audit-catalog-gaps:json": "node scripts/audit-catalog-gaps.js",
103
103
  "audit-catalog-gaps:strict": "node scripts/audit-catalog-gaps.js --pretty --strict",
104
+ "check-version-tags": "node scripts/check-version-tags.js",
105
+ "check-version-tags:update": "node scripts/check-version-tags.js --update-baseline",
104
106
  "predeploy": "node scripts/predeploy.js",
105
107
  "diff-coverage": "node scripts/check-test-coverage.js",
106
108
  "prepublishOnly": "node -e \"if(process.env.EXCEPTD_SKIP_PREPUBLISH_PREDEPLOY!=='1'){const r=require('child_process').spawnSync(process.execPath,['scripts/predeploy.js'],{stdio:'inherit'});if(r.status){process.exit(r.status)}}\" && node lib/validate-package.js",
package/sbom.cdx.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:9721d2eb-b479-4a91-8e53-f6c07d9d9333",
4
+ "serialNumber": "urn:uuid:e25943d6-47a9-4837-b3db-bff8760f7682",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2106-05-08T22:44:59.000Z",
7
+ "timestamp": "2146-05-04T13:21:26.000Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "blamejs",
11
11
  "name": "scripts/refresh-sbom.js",
12
- "version": "0.13.25"
12
+ "version": "0.13.27"
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.13.25",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.13.27",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.13.25",
19
+ "version": "0.13.27",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 42 skills, 10 catalogs (312 CVEs / 171 CWEs / 805 ATT&CK + ICS / 170 ATLAS / 468 D3FEND / 7476 RFCs), 34 jurisdictions, 10-class catalog gap detector + budget gate, real XML parser + canonical-form diff + content-pattern regression detection, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,17 +25,17 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.13.25",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.13.27",
29
29
  "hashes": [
30
30
  {
31
31
  "alg": "SHA-256",
32
- "content": "bf26fba4dd6ece0592fcccfd7e58fd670bc1b5b46f9f01bda086e42b3b4b30a4"
32
+ "content": "0e6a0230f25fee37c08637aad469a3dc640b544ff7b8b426ddeb8f59c83056d5"
33
33
  }
34
34
  ],
35
35
  "externalReferences": [
36
36
  {
37
37
  "type": "distribution",
38
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.13.25"
38
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.13.27"
39
39
  },
40
40
  {
41
41
  "type": "vcs",
@@ -116,11 +116,11 @@
116
116
  "hashes": [
117
117
  {
118
118
  "alg": "SHA-256",
119
- "content": "fb8caf215ddd040d88701f1398d9f959e16a63a2fd7fac3392bdca6af2926a0b"
119
+ "content": "3b36f7f22962fdb7d595369940fc1cb5a4f58e22bb531da6dc56f58d1af7ef3f"
120
120
  },
121
121
  {
122
122
  "alg": "SHA3-512",
123
- "content": "6fc54dda4e3cc3b1c5148ea2b66358c71d78b81b96cf844cdaa59bd8739abd76435e218a81957e2f3a30b990ff8ff8f0a7af6ba92d2d227584f2848c60ef579d"
123
+ "content": "c670a08d9db2838a25bab62aed25a06b47236cd3ca278d243ec951b143d2e98c9cf2c1eecc83966936d02921c1b00fa7837176d09ece03d629f78592545e7db5"
124
124
  }
125
125
  ]
126
126
  },
@@ -281,11 +281,11 @@
281
281
  "hashes": [
282
282
  {
283
283
  "alg": "SHA-256",
284
- "content": "f375c1932e65be65b688db7069956dc97b3bcc3fe5492446fc3f5f3ea9c4b52e"
284
+ "content": "a8b22536e4d63b36a63018bf1024a407cc3533be7ee428e0b414ad6991f623ad"
285
285
  },
286
286
  {
287
287
  "alg": "SHA3-512",
288
- "content": "ceb36659799f19868b5d32af014de1a315279c2321538d7aaa73a7222295c9b5db882a402de04c3c8299f54dfda6743aea9900d215ffd6f1ebcf9b2badd2661a"
288
+ "content": "d6511abcb934137785dac21dbd0a62a55e6e1e67a9c187cc834112db2ca82ed761bcd43321e5334069904dd0eda3b1d07b87ca24e8dfe490bfc75da5186747ba"
289
289
  }
290
290
  ]
291
291
  },
@@ -1031,11 +1031,11 @@
1031
1031
  "hashes": [
1032
1032
  {
1033
1033
  "alg": "SHA-256",
1034
- "content": "34b9a8ed6d468fe83422e7fa1aae30d205ccd01e8f9e9e3f4a2273ab10f5cfc5"
1034
+ "content": "7d129a3edcb6ee6ff77622fb7ab2a9b9dd45a218d7a74d209edc72375c1d4cba"
1035
1035
  },
1036
1036
  {
1037
1037
  "alg": "SHA3-512",
1038
- "content": "fa5b647c27f0252d9ae1eae179a1b9e10c3186a87328f428c716c7adc74eac347aea5c6094fb7997295d32b3bfd18536709e1dba9659a4b807a274a233d227d8"
1038
+ "content": "4411999d653bceaa72d15b1f9bfa267949a233e09161a003af49da39c8aaca533143428f6ea5d7fade114a744c28c804b1c7c2c5e58b993fa921ea1ae9ab3a48"
1039
1039
  }
1040
1040
  ]
1041
1041
  },
@@ -1451,11 +1451,11 @@
1451
1451
  "hashes": [
1452
1452
  {
1453
1453
  "alg": "SHA-256",
1454
- "content": "56140e0d1624f8c83333f27759e21815daf57a8face69d7aff3eb88d087f27b1"
1454
+ "content": "9e9f77b63bb9cf8a34e235fe103e3e71efa558f2802fd563f73b18dc8f516d99"
1455
1455
  },
1456
1456
  {
1457
1457
  "alg": "SHA3-512",
1458
- "content": "2a89b19e1c07e14ea1f39f430cc08efe9a9da99373851014ade1c82192dce9c5bb67521ea1ff2985bfb1b731ce81ee50c032d5a314d02ebc89e21bc24c08052f"
1458
+ "content": "5f7c38510b2a99fdbec98735d32ffb951ed2969578ab0d86a18c056df700d5fad264f4316d8311b0cc948797cf0e4c12cc6874e1fb13c4f25bdc01b376a3c1b2"
1459
1459
  }
1460
1460
  ]
1461
1461
  },
@@ -1939,6 +1939,21 @@
1939
1939
  }
1940
1940
  ]
1941
1941
  },
1942
+ {
1943
+ "bom-ref": "file:scripts/check-version-tags.js",
1944
+ "type": "file",
1945
+ "name": "scripts/check-version-tags.js",
1946
+ "hashes": [
1947
+ {
1948
+ "alg": "SHA-256",
1949
+ "content": "ab58a0d6a5f43e94f63161af45b4e436fe50f898e9a44588bdc15103dbc31aa8"
1950
+ },
1951
+ {
1952
+ "alg": "SHA3-512",
1953
+ "content": "36a39edf675786de2e7417e6097cd18f03f499ef994b8d728f93f1ca7b657e5c577f2b6ad7360a3470e7fba6287cbc65ae1eb98578c0d4ce689bd9e0f533c398"
1954
+ }
1955
+ ]
1956
+ },
1942
1957
  {
1943
1958
  "bom-ref": "file:scripts/hooks/pre-commit.sh",
1944
1959
  "type": "file",
@@ -1961,11 +1976,11 @@
1961
1976
  "hashes": [
1962
1977
  {
1963
1978
  "alg": "SHA-256",
1964
- "content": "306e02cc802bc1ca6fe98fb1e4e57c895c7f458895cb2acc459dc6f7ba0127c9"
1979
+ "content": "b1edfc22514b633f9932382d5d0fa091fa28b83535b4f0dac764ea2f4aebc85d"
1965
1980
  },
1966
1981
  {
1967
1982
  "alg": "SHA3-512",
1968
- "content": "e851348e5f7bec18a3a2a3757d1f5720cc0892c0bf527304e5e79dfc9931c7309ba6fd53918fcd63c2e7cd880487642af8f06d0b1195e0620e2aa9fadf643ba6"
1983
+ "content": "f2b2d68424127b87e0f6a5a7cd2e64e4910d26af9ef4279f0aac49c81fdd9a47617013cde0165066a7678db1bee3a3a4c75882228befb94cad5219125dbb8fd9"
1969
1984
  }
1970
1985
  ]
1971
1986
  },
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * scripts/check-version-tags.js
6
+ *
7
+ * Refuses NEW version-stamped comments / filenames in the tracked
8
+ * source tree. The authoritative version surfaces are:
9
+ *
10
+ * 1. package.json / manifest.json `"version"` field
11
+ * 2. CHANGELOG.md `## X.Y.Z` headings
12
+ * 3. git tags
13
+ * 4. CLI `version` verb output (reads from package.json)
14
+ *
15
+ * Anywhere else, `// v0.13.22` / `Pre-v0.13.22` / `*-v0_13_22.test.js`
16
+ * is phase residue — operators don't have the roadmap, version tags
17
+ * rot the moment the next release lands, and `git clone` ships every
18
+ * comment to operators along with the code.
19
+ *
20
+ * The check uses a baseline snapshot (`tests/.version-tag-baseline.
21
+ * json`) capturing current violation counts per file. Future scans
22
+ * compare against the baseline:
23
+ *
24
+ * - Filename violations beyond baseline → fail.
25
+ * - Comment violations beyond baseline (in any file) → fail.
26
+ * - Violations strictly within baseline → ok.
27
+ * - Violations below baseline (drift reduced) → ok +
28
+ * suggestion to refresh the baseline.
29
+ *
30
+ * Refresh: `node scripts/check-version-tags.js --update-baseline`.
31
+ *
32
+ * Wired into `npm run predeploy` as a gate.
33
+ */
34
+
35
+ const fs = require("node:fs");
36
+ const path = require("node:path");
37
+
38
+ const ROOT = path.join(__dirname, "..");
39
+ const BASELINE_PATH = path.join(ROOT, "tests", ".version-tag-baseline.json");
40
+
41
+ // Directories we do not walk at all.
42
+ const SKIP_DIRS = new Set([
43
+ "node_modules", ".git", ".keys", ".cache", ".scratch",
44
+ "data", "vendor", ".husky",
45
+ ]);
46
+
47
+ // File extensions we scan for comment violations.
48
+ const SCAN_EXTS = new Set([".js", ".cjs", ".mjs", ".md"]);
49
+
50
+ // Paths that the project intentionally version-stamps:
51
+ // - CHANGELOG headings are how operators navigate the file
52
+ // - package.json / manifest.json carry the canonical version field
53
+ // - manifest-snapshot.json + sbom.cdx.json contain version-pinned
54
+ // metadata (the SBOM IS a version-stamped manifest)
55
+ // - lib/version-pins.js is a version-constant lookup table
56
+ // - This checker itself documents what it forbids
57
+ // - .git-blame-ignore-revs carries commit hashes, not version tags,
58
+ // but is conventional config the user maintains
59
+ const COMMENT_EXEMPT = new Set([
60
+ "package.json",
61
+ "manifest.json",
62
+ "manifest-snapshot.json",
63
+ "sbom.cdx.json",
64
+ "CHANGELOG.md",
65
+ "lib/version-pins.js",
66
+ "scripts/check-version-tags.js",
67
+ // Gitignored local-only contributor docs — never shipped.
68
+ "CLAUDE.md",
69
+ ]);
70
+
71
+ // Pattern: project version like `v0.13.22` or bare `0.13.22`. Matches
72
+ // our pre-1.0 release range. External package versions like ATLAS
73
+ // `v5.6.0` or CycloneDX `1.6` don't match because the major is 0.
74
+ const VERSION_TAG_RE = /\bv?0\.\d+\.\d+\b/;
75
+
76
+ // Phase residue patterns — broader than just version tags.
77
+ const PHASE_RESIDUE_RES = [
78
+ /\bcycle\s+\d+\b/i, // "cycle 13 P3 F3"
79
+ /\bphase\s+\d+(\.\d+)+\b/i,// "phase 9.11k"
80
+ /\bPre-v?0\.\d+\.\d+\b/i, // "Pre-0.13.22"
81
+ ];
82
+
83
+ // Filename pattern. `-v0_13_22.test.js` style.
84
+ const FILENAME_VERSION_RE = /-v\d+_\d+_\d+\.test\.\w+$/;
85
+
86
+ function walk(dir, results = []) {
87
+ for (const name of fs.readdirSync(dir)) {
88
+ if (SKIP_DIRS.has(name)) continue;
89
+ const full = path.join(dir, name);
90
+ const rel = path.relative(ROOT, full).replace(/\\/g, "/");
91
+ let stat;
92
+ try { stat = fs.statSync(full); }
93
+ catch { continue; }
94
+ if (stat.isDirectory()) {
95
+ walk(full, results);
96
+ } else if (stat.isFile()) {
97
+ results.push(rel);
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+
103
+ function countCommentViolations(rel) {
104
+ if (COMMENT_EXEMPT.has(rel)) return 0;
105
+ const ext = path.extname(rel);
106
+ if (!SCAN_EXTS.has(ext)) return 0;
107
+ let text;
108
+ try { text = fs.readFileSync(path.join(ROOT, rel), "utf8"); }
109
+ catch { return 0; }
110
+ let count = 0;
111
+ for (const line of text.split(/\r?\n/)) {
112
+ if (VERSION_TAG_RE.test(line)) { count++; continue; }
113
+ for (const re of PHASE_RESIDUE_RES) {
114
+ if (re.test(line)) { count++; break; }
115
+ }
116
+ }
117
+ return count;
118
+ }
119
+
120
+ function scanCurrent() {
121
+ const files = walk(ROOT);
122
+ const byFile = {};
123
+ const filenameViolations = [];
124
+ for (const rel of files) {
125
+ if (FILENAME_VERSION_RE.test(rel)) filenameViolations.push(rel);
126
+ const n = countCommentViolations(rel);
127
+ if (n > 0) byFile[rel] = n;
128
+ }
129
+ return { byFile, filenameViolations };
130
+ }
131
+
132
+ function readBaseline() {
133
+ if (!fs.existsSync(BASELINE_PATH)) {
134
+ return { byFile: {}, filenameViolations: [], _missing: true };
135
+ }
136
+ try {
137
+ return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
138
+ } catch (e) {
139
+ console.error(`[check-version-tags] baseline at ${path.relative(ROOT, BASELINE_PATH)} is malformed: ${e.message}`);
140
+ process.exitCode = 2;
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function writeBaseline(current) {
146
+ const body = {
147
+ note: "Snapshot of pre-existing version-tag drift. The check-version-tags gate fails when these counts go UP. Refresh after an organic cleanup with `node scripts/check-version-tags.js --update-baseline`.",
148
+ recorded_at: new Date().toISOString().split("T")[0],
149
+ byFile: current.byFile,
150
+ filenameViolations: current.filenameViolations,
151
+ };
152
+ fs.writeFileSync(BASELINE_PATH, JSON.stringify(body, null, 2) + "\n");
153
+ console.log(`[check-version-tags] wrote baseline to ${path.relative(ROOT, BASELINE_PATH)}`);
154
+ console.log(` ${Object.keys(current.byFile).length} file(s) with comment violations`);
155
+ console.log(` ${current.filenameViolations.length} filename violation(s)`);
156
+ }
157
+
158
+ function main() {
159
+ const wantUpdate = process.argv.includes("--update-baseline");
160
+ const current = scanCurrent();
161
+
162
+ if (wantUpdate) {
163
+ writeBaseline(current);
164
+ process.exitCode = 0;
165
+ return;
166
+ }
167
+
168
+ const baseline = readBaseline();
169
+ if (!baseline) return;
170
+
171
+ if (baseline._missing) {
172
+ console.error(`[check-version-tags] baseline missing at ${path.relative(ROOT, BASELINE_PATH)}.`);
173
+ console.error("Run `node scripts/check-version-tags.js --update-baseline` to capture the current state.");
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+
178
+ const regressions = [];
179
+
180
+ // Filename regressions: any new filename matching the pattern that
181
+ // wasn't in the baseline.
182
+ for (const rel of current.filenameViolations) {
183
+ if (!baseline.filenameViolations.includes(rel)) {
184
+ regressions.push({
185
+ kind: "filename",
186
+ path: rel,
187
+ reason: "new test filename carries a version tag — rename to describe the surface it pins, not the release",
188
+ });
189
+ }
190
+ }
191
+
192
+ // Comment regressions: per-file count grew.
193
+ for (const [rel, n] of Object.entries(current.byFile)) {
194
+ const prior = baseline.byFile[rel] || 0;
195
+ if (n > prior) {
196
+ regressions.push({
197
+ kind: "comment",
198
+ path: rel,
199
+ baseline: prior,
200
+ actual: n,
201
+ reason: `comment-level version-tag count grew from ${prior} to ${n} — describe the WHY of the current code, not the release that introduced it`,
202
+ });
203
+ }
204
+ }
205
+
206
+ // Files newly added to the violation set (not in baseline at all).
207
+ for (const rel of Object.keys(current.byFile)) {
208
+ if (!(rel in baseline.byFile)) {
209
+ const n = current.byFile[rel];
210
+ // Skip if already captured as a count regression above.
211
+ if (regressions.some(r => r.path === rel)) continue;
212
+ regressions.push({
213
+ kind: "comment",
214
+ path: rel,
215
+ baseline: 0,
216
+ actual: n,
217
+ reason: `new file carries ${n} version-tag comment(s) — describe the WHY of the current code, not the release that introduced it`,
218
+ });
219
+ }
220
+ }
221
+
222
+ if (regressions.length === 0) {
223
+ const totalFiles = Object.keys(current.byFile).length;
224
+ const totalFilenames = current.filenameViolations.length;
225
+ console.log(`[check-version-tags] ok — no new version tags. (${totalFiles} file(s) within baseline, ${totalFilenames} legacy filename(s).)`);
226
+ process.exitCode = 0;
227
+ return;
228
+ }
229
+
230
+ console.error(`[check-version-tags] FAIL — ${regressions.length} new version-tag regression(s).`);
231
+ console.error("");
232
+ for (const r of regressions) {
233
+ if (r.kind === "filename") {
234
+ console.error(` ${r.path}`);
235
+ console.error(` → ${r.reason}`);
236
+ } else {
237
+ console.error(` ${r.path} (${r.baseline} → ${r.actual})`);
238
+ console.error(` → ${r.reason}`);
239
+ }
240
+ }
241
+ console.error("");
242
+ console.error("Authoritative version surfaces (mentions are LOAD-BEARING here):");
243
+ console.error(" package.json / manifest.json `version` field");
244
+ console.error(" CHANGELOG.md `## X.Y.Z` headings (body should describe behavior, not compare versions)");
245
+ console.error(" git tags");
246
+ console.error("");
247
+ console.error("Fix anywhere else:");
248
+ console.error(" - Rename test files to describe the surface (no `-v0_X_Y` suffix).");
249
+ console.error(" - Rewrite comments to describe the WHY of the current code, not the release.");
250
+ console.error(" - In CHANGELOG bodies, use 'Previously' / 'Now' phrasing, not 'Pre-X.Y.Z'.");
251
+ console.error("");
252
+ console.error("If a violation is legitimate (e.g. a deprecation timeline that needs a specific version), add the");
253
+ console.error("file path to COMMENT_EXEMPT in scripts/check-version-tags.js with a justifying comment.");
254
+
255
+ process.exitCode = 1;
256
+ }
257
+
258
+ if (require.main === module) main();
259
+
@@ -207,6 +207,20 @@ const GATES = [
207
207
  args: [path.join(ROOT, "scripts", "check-catalog-gap-budget.js")],
208
208
  ciJobName: "Data integrity (catalog + manifest snapshot)",
209
209
  },
210
+ {
211
+ // Version-tag drift gate. Compares the tracked tree against a
212
+ // baseline snapshot of pre-existing `// vX.Y.Z` comments and
213
+ // `*-vX_Y_Z.test.js` filenames. Fails on NEW additions outside
214
+ // the authoritative version surfaces (package.json /
215
+ // manifest.json / CHANGELOG headings / git tags). The full rule
216
+ // is documented at the top of check-version-tags.js; refresh the
217
+ // baseline after an organic cleanup via
218
+ // `node scripts/check-version-tags.js --update-baseline`.
219
+ name: "Version-tag drift (no new phase residue)",
220
+ command: process.execPath,
221
+ args: [path.join(ROOT, "scripts", "check-version-tags.js")],
222
+ ciJobName: "Data integrity (catalog + manifest snapshot)",
223
+ },
210
224
  ];
211
225
 
212
226
  function runGate(gate) {