@blamejs/exceptd-skills 0.11.12 → 0.11.14

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,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.14 — 2026-05-13
4
+
5
+ **Patch: items 129-134 + freshness surface — claims-vs-reality gap closure + opt-in registry-check.**
6
+
7
+ ### New: freshness surface (all opt-in, all offline-safe)
8
+
9
+ - **`doctor --registry-check`.** Queries the npm registry for the latest published version + publish date. Reports `local_version`, `latest_version`, `days_since_latest_publish`, and a `behind` / `same` / `ahead` flag. Routed through a child process so the call is bounded by a hard timeout; offline degrades to a structured warning, not a hang. Opt-in: doctor without the flag stays offline.
10
+
11
+ - **`run --upstream-check`.** Same registry call, fires before phase-4 detect. Surfaces an `upstream_check` block on the run result + a visible stderr warning when the local catalog is behind. Operators wiring CI gates can read `result.upstream_check.behind` to decide whether to trust today's findings. Doesn't fetch the catalog — only compares timestamps.
12
+
13
+ - **`refresh --network`.** Fetches the latest signed catalog snapshot from the maintainer's npm-published tarball, verifies every skill's Ed25519 signature against the `keys/public.pem` already in the operator's install, and swaps `data/` + `skills/` + `manifest.json` in place. Same trust anchor as `npm update -g`; only the data slice changes, so CLI/lib code stays pinned. Refuses the swap on public-key fingerprint mismatch (key rotation requires explicit `npm update -g` so the trust transition is auditable). Refuses when the install dir isn't writable (typical global installs) and points operators at `npm update -g` instead. Includes `--dry-run` for verifying signatures without applying. Backs up the prior `data/` to a timestamped dir so rollback is one `mv` away.
14
+
15
+ All three honor `EXCEPTD_REGISTRY_FIXTURE` env var (path to a JSON file mimicking the registry response) so test runners and air-gapped operators can exercise the freshness paths offline.
16
+
17
+ ### Bugs
18
+
19
+ - **#129 air-gap workflow is now operator-accessible.** Pre-0.11.14 the docs implied `refresh --from-cache` worked offline but the cache-population path wasn't surfaced; an empty cache produced a stack trace. Now `refresh --prefetch` is the operator-facing alias for the prefetch script (legacy `--no-network` retained). Missing-cache errors emit a structured hint that names the exact command: "(1) on connected host: `exceptd refresh --prefetch`, (2) copy `.cache/upstream/`, (3) offline: `exceptd refresh --from-cache --apply`." Help text rewritten to document the workflow.
20
+
21
+ - **#130 `exceptd path copy` writes to the clipboard.** Previously the `copy` argument was silently consumed and the path was just printed — operators wondering "did anything happen?" had no signal. Now the verb invokes the platform clipboard tool (`clip` on Windows, `pbcopy` on macOS, `wl-copy` / `xclip` / `xsel` on Linux), confirms the copy on stderr, and still prints the path on stdout so shell consumers like `cd "$(exceptd path)"` continue to work. When no clipboard tool is available, a clear warning fires instead of a silent fallthrough.
22
+
23
+ - **#131 `run <skill-name>` suggests the right playbook.** 13 playbooks vs 38 skills with a many-to-many relationship: operators routinely typed `run kernel-lpe-triage` (a skill) and got "Playbook not found." Now the error names the playbook(s) that load the skill (e.g. `kernel`), distinguishes skill-vs-playbook semantics, and suggests both `exceptd run <playbook>` (execute) and `exceptd skill <name>` (read). Near-matches on unknown ids also surface (`run secret` → "Did you mean: secrets?"). Landing site updated to clarify the distinction near the skills grid.
24
+
25
+ - **#134 `ci` exit-code matrix puts BLOCKED before FAIL.** Pre-0.11.14 a preflight halt produced exit 2 (FAIL) — indistinguishable from "playbook detected a real problem." Operators wiring CI gates against `exit 2` couldn't separate "we never executed" from "we executed and found something." Now the precedence is BLOCKED (4) → FAIL (2) → NO-DATA (3) → PASS (0). The earlier `if (fail)` short-circuit was rearranged so blocked counts take precedence.
26
+
27
+ ### Website (operator-facing)
28
+
29
+ - **#132** `exceptd build-indexes` references replaced with `exceptd refresh --indexes-only`.
30
+ - **#133** "13-gate predeploy" feature card relabeled "13-gate release hygiene" and explicitly disambiguated from the operator-facing `exceptd ci` verb.
31
+ - **#131** Skills grid header clarifies "skills are read-only; playbooks execute" with the three relevant verbs.
32
+ - **#129** Operator persona card shows the actual air-gap workflow: `refresh --prefetch` → copy → `refresh --from-cache --apply`.
33
+
34
+ ### Tests
35
+
36
+ 7 new regression cases. 354 total. Notable: `#125/#134` now triggers a REAL preflight halt by submitting `repo-context: false` keyed by playbook id (autoDetectPreconditions can't override an explicit submission), and asserts `r.status === 4` not just non-zero — the earlier test only caught "not 0" which my v0.11.12 "fix" passed by coincidence (no-evidence → exit 3, also non-zero).
37
+
38
+ ### Lesson codified
39
+
40
+ When a "fix" passes a regression test by coincidence (any non-zero exit satisfies "not 0"), the test is too weak. Tests must assert the EXACT contract — exit 4, not "any non-zero." Added to CLAUDE.md.
41
+
42
+ ## 0.11.13 — 2026-05-13
43
+
44
+ **Patch: the final two stragglers — universal `ok:false` exit and empty-submission diff counters.**
45
+
46
+ ### Bugs
47
+
48
+ - **#127 (originally #100) — `ok:false` body always yields non-zero exit.** Pre-0.11.13 several verbs emitted a result body with `ok: false` to stdout but didn't set `process.exitCode`, so `exceptd run ...; echo $?` returned 0 and `set -e` shell scripts couldn't gate on it. The previous fix was per-verb. Now `emit()` itself sets `process.exitCode = 1` whenever the body has `ok: false` at top level (unless a caller already set a different non-zero code). Universal contract: anything that emits `ok: false` to stdout OR stderr returns non-zero, no exceptions. New verbs cannot regress this — the catch is at the renderer.
49
+
50
+ - **#128 (originally #102) — attest diff falls back to playbook catalog when submissions are empty.** Pre-0.11.13 `attest diff` between two identical empty-submission attestations reported `status: unchanged` (hash equality) but `total_compared: 0, unchanged_count: 0` — operators couldn't tell whether "0 unchanged" meant "diff didn't iterate" or "nothing to compare." Now: when a submission has neither `artifacts` nor `observations`, the diff helper falls back to the playbook's `look.artifacts` catalog (via the attestation's stored `playbook_id`). Result: `total_compared` reflects the catalog size; `unchanged_count` equals `total_compared` when both sides are uniformly empty. Real observation submissions retain the prior behavior.
51
+
52
+ ### Tests
53
+
54
+ 3 new regression cases. 347 total. The `#127` test asserts the universal contract by hitting `attest verify` on a non-existent session id and checking that any `ok:false` body (stdout or stderr) maps to non-zero exit. The `#128` test runs two `{}` submissions through `run sbom` and asserts the diff reports `total_compared > 0` matching `unchanged_count`.
55
+
56
+ ### Lesson codified in CLAUDE.md
57
+
58
+ When a class of bug ("verb forgot to set exit code") keeps recurring across releases, fix the class, not the instance. Move the contract to the lowest layer that all paths share — here, `emit()` itself.
59
+
3
60
  ## 0.11.12 — 2026-05-12
4
61
 
5
62
  **Patch: items 123-126 — content-not-just-shape, exit-code discipline, diff iteration.**
package/bin/exceptd.js CHANGED
@@ -71,6 +71,7 @@ const COMMANDS = {
71
71
  "-h": null,
72
72
  prefetch: () => path.join(PKG_ROOT, "lib", "prefetch.js"),
73
73
  refresh: () => path.join(PKG_ROOT, "lib", "refresh-external.js"),
74
+ "refresh-network": () => path.join(PKG_ROOT, "lib", "refresh-network.js"),
74
75
  "build-indexes": () => path.join(PKG_ROOT, "scripts", "build-indexes.js"),
75
76
  verify: () => path.join(PKG_ROOT, "lib", "verify.js"),
76
77
  scan: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
@@ -196,6 +197,9 @@ v0.11.0 canonical surface
196
197
  --ci exit-code gate (use \`exceptd ci\` instead)
197
198
  --operator <name> bind attestation to identity
198
199
  --ack explicit jurisdiction-consent
200
+ --upstream-check (v0.11.14) opt-in registry freshness
201
+ check before detect; warns if local
202
+ catalog is behind latest published
199
203
  --session-id <id> reuse session id (collision refused)
200
204
  --force-overwrite override session collision refusal
201
205
  --session-key <hex> HMAC sign evidence_package
@@ -218,6 +222,8 @@ v0.11.0 canonical surface
218
222
  doctor Health check: signatures + currency + cve catalog
219
223
  + rfc catalog + attestation-signing status.
220
224
  --signatures | --currency | --cves | --rfcs
225
+ --registry-check (v0.11.14) opt-in: query npm registry
226
+ for latest published version + days behind
221
227
 
222
228
  ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
223
229
  3 ran-but-no-evidence, 4 blocked (ok:false), 1 framework error.
@@ -242,6 +248,13 @@ v0.11.0 canonical surface
242
248
 
243
249
  refresh [args] Refresh upstream catalogs + indexes. Replaces
244
250
  prefetch + refresh + build-indexes.
251
+ --network (v0.11.14) fetch latest signed
252
+ catalog snapshot from npm registry,
253
+ verify against local keys/public.pem,
254
+ swap data/ in place (no CLI/lib reload)
255
+ --prefetch populate offline cache
256
+ --from-cache consume offline cache
257
+ --indexes-only rebuild indexes only
245
258
 
246
259
  v0.10.x compatibility (will be removed in v0.12)
247
260
  ────────────────────────────────────────────────
@@ -321,6 +334,35 @@ function main() {
321
334
  process.exit(0);
322
335
  }
323
336
  if (cmd === "path") {
337
+ // v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
338
+ // printing the path. Operators on Windows / Linux saw no clipboard write.
339
+ // Now: implement clipboard copy on the three host platforms (clip on
340
+ // Windows, pbcopy on macOS, xclip|wl-copy|xsel on Linux). If no usable
341
+ // tool is found, fall through to print + stderr-warn (so STDOUT still
342
+ // gives the path for shell consumers like `cd "$(exceptd path)"`).
343
+ const wantCopy = rest.includes("copy") || rest.includes("--copy");
344
+ if (wantCopy) {
345
+ const { spawnSync } = require("child_process");
346
+ const platform = process.platform;
347
+ const candidates = platform === "win32" ? [["clip"]]
348
+ : platform === "darwin" ? [["pbcopy"]]
349
+ : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-bi"]];
350
+ let copied = false;
351
+ let tried = [];
352
+ for (const [bin, ...argv] of candidates) {
353
+ tried.push(bin);
354
+ const res = spawnSync(bin, argv, { input: PKG_ROOT, encoding: "utf8" });
355
+ if (res.status === 0 && !res.error) { copied = true; break; }
356
+ }
357
+ if (copied) {
358
+ process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
359
+ process.stdout.write(PKG_ROOT + "\n");
360
+ process.exit(0);
361
+ }
362
+ process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
363
+ process.stdout.write(PKG_ROOT + "\n");
364
+ process.exit(0);
365
+ }
324
366
  process.stdout.write(PKG_ROOT + "\n");
325
367
  process.exit(0);
326
368
  }
@@ -356,12 +398,22 @@ function main() {
356
398
  // here so the deprecation pointer actually works.
357
399
  let effectiveCmd = cmd;
358
400
  let effectiveRest = rest;
359
- if (cmd === "refresh" && rest.includes("--no-network")) {
401
+ if (cmd === "refresh" && (rest.includes("--no-network") || rest.includes("--prefetch"))) {
402
+ // v0.11.14 (#129): --prefetch is the operator-facing name for the
403
+ // cache-population path. --no-network retained as alias for back-compat.
360
404
  effectiveCmd = "prefetch";
361
- effectiveRest = rest.filter(a => a !== "--no-network");
405
+ effectiveRest = rest.filter(a => a !== "--no-network" && a !== "--prefetch");
362
406
  } else if (cmd === "refresh" && rest.includes("--indexes-only")) {
363
407
  effectiveCmd = "build-indexes";
364
408
  effectiveRest = rest.filter(a => a !== "--indexes-only");
409
+ } else if (cmd === "refresh" && rest.includes("--network")) {
410
+ // v0.11.14: --network fetches a fresh signed catalog snapshot from the
411
+ // maintainer's npm-published tarball, verifies signatures against the
412
+ // public key already shipped in the operator's install, and swaps
413
+ // data/ in place. Same trust boundary as `npm update -g`; fresher
414
+ // data slice without requiring a full package upgrade.
415
+ effectiveCmd = "refresh-network";
416
+ effectiveRest = rest.filter(a => a !== "--network");
365
417
  }
366
418
 
367
419
  const resolver = COMMANDS[effectiveCmd];
@@ -448,6 +500,16 @@ function emit(obj, pretty, humanRenderer) {
448
500
  // default (no flag, renderer present) → HUMAN
449
501
  // default (no flag, no renderer) → indented JSON when TTY else compact
450
502
  // This closes the longest-standing UX gap across 8 releases.
503
+ //
504
+ // v0.11.13 (#127): emit() now ALSO sets process.exitCode = 1 when the body
505
+ // carries `ok: false` at top level (unless a caller already set a different
506
+ // non-zero exitCode). Pre-0.11.13 verbs that emitted ok:false to stdout
507
+ // without explicitly setting the exit code returned 0, defeating `set -e`
508
+ // and CI gates. The previous fix was per-verb; this is a universal catch
509
+ // so new verbs / new ok:false paths can't regress the contract.
510
+ if (obj && obj.ok === false && !process.exitCode) {
511
+ process.exitCode = 1;
512
+ }
451
513
  const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON;
452
514
  if (humanRenderer && !wantJson && !pretty) {
453
515
  process.stdout.write(humanRenderer(obj) + "\n");
@@ -584,10 +646,55 @@ function dispatchPlaybook(cmd, argv) {
584
646
  case "ci": return cmdCi(runner, args, runOpts, pretty);
585
647
  }
586
648
  } catch (e) {
649
+ // v0.11.14 (#131): when the operator typed a skill name (kernel-lpe-triage)
650
+ // and got "Playbook not found," surface the playbooks that load that skill.
651
+ // 13 playbooks vs 38 skills with many-to-many: operators routinely confuse
652
+ // the two because the website (and AGENTS.md) describe both as runnable.
653
+ const m = e && e.message && e.message.match(/^Playbook not found: ([^\s(]+)/);
654
+ if (m) {
655
+ const wanted = m[1];
656
+ const hint = buildSkillToPlaybookHint(runner, wanted);
657
+ if (hint) {
658
+ return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
659
+ }
660
+ }
587
661
  emitError(e.message, { verb: cmd }, pretty);
588
662
  }
589
663
  }
590
664
 
665
+ function buildSkillToPlaybookHint(runner, wanted) {
666
+ try {
667
+ const ids = runner.listPlaybooks ? runner.listPlaybooks() : [];
668
+ const matches = [];
669
+ for (const id of ids) {
670
+ let pb;
671
+ try { pb = runner.loadPlaybook(id); } catch { continue; }
672
+ const skills = new Set();
673
+ const collect = (val) => {
674
+ if (Array.isArray(val)) val.forEach(collect);
675
+ else if (val && typeof val === "object") Object.values(val).forEach(collect);
676
+ else if (typeof val === "string") skills.add(val);
677
+ };
678
+ collect(pb.phases?.govern?.skill_preload);
679
+ for (const d of (pb.directives || [])) {
680
+ collect(d.phase_overrides?.govern?.skill_preload);
681
+ }
682
+ if (skills.has(wanted)) matches.push(id);
683
+ }
684
+ if (matches.length > 0) {
685
+ return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
686
+ `To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
687
+ `Tip: \`exceptd plan\` lists all 13 playbooks; \`exceptd watchlist\` lists skills.`;
688
+ }
689
+ // No matching skill either — provide nearest-playbook suggestions.
690
+ const near = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
691
+ if (near.length > 0) {
692
+ return `Did you mean: ${near.join(", ")}? Run \`exceptd plan\` for the full list.`;
693
+ }
694
+ return `Run \`exceptd plan\` to list the 13 playbooks.`;
695
+ } catch { return null; }
696
+ }
697
+
591
698
  function printPlaybookVerbHelp(verb) {
592
699
  const cmds = {
593
700
  plan: `plan — list playbooks + directives, grouped by scope.
@@ -1293,7 +1400,30 @@ function cmdRun(runner, args, runOpts, pretty) {
1293
1400
  }
1294
1401
  }
1295
1402
 
1403
+ // v0.11.14: opt-in `--upstream-check` queries the npm registry BEFORE
1404
+ // detect to warn operators if their local catalog is behind the latest
1405
+ // published version. Opt-in so the runner stays offline by default.
1406
+ // Network bounded by an 8s timeout; degrades gracefully when offline.
1407
+ let upstreamCheck = null;
1408
+ if (args["upstream-check"]) {
1409
+ try {
1410
+ const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
1411
+ const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
1412
+ encoding: "utf8",
1413
+ cwd: PKG_ROOT,
1414
+ timeout: 8000,
1415
+ });
1416
+ try { upstreamCheck = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
1417
+ if (upstreamCheck && upstreamCheck.behind) {
1418
+ process.stderr.write(`[exceptd run --upstream-check] STALE: local v${upstreamCheck.local_version} < published v${upstreamCheck.latest_version} (published ${upstreamCheck.latest_published_at}, ${upstreamCheck.days_since_latest_publish}d ago). Continuing with local catalog. Run \`npm update -g @blamejs/exceptd-skills\` or \`exceptd refresh --network\` to consume the latest.\n`);
1419
+ }
1420
+ } catch (e) {
1421
+ upstreamCheck = { ok: false, error: e.message, source: "offline" };
1422
+ }
1423
+ }
1424
+
1296
1425
  const result = runner.run(playbookId, directiveId, submission, runOpts);
1426
+ if (result && upstreamCheck) result.upstream_check = upstreamCheck;
1297
1427
 
1298
1428
  // v0.11.9 (#113/#114): surface --operator and --ack in the run result so
1299
1429
  // operators see the attribution + consent state without inspecting the
@@ -2206,16 +2336,35 @@ function cmdAttest(runner, args, runOpts, pretty) {
2206
2336
  * canonical nested view of both shapes lets `attest diff` produce meaningful
2207
2337
  * counts regardless of which shape the operator submitted.
2208
2338
  */
2339
+ function _playbookArtifactCatalog(runner, playbookId) {
2340
+ if (!playbookId) return null;
2341
+ try {
2342
+ const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
2343
+ if (!pb) return null;
2344
+ const arts = (pb.phases?.look?.artifacts || []).filter(a => a && a.id);
2345
+ if (arts.length === 0) return null;
2346
+ return Object.fromEntries(arts.map(a => [a.id, { captured: false, _catalog_stub: true }]));
2347
+ } catch { return null; }
2348
+ }
2349
+ function _playbookSignalCatalog(runner, playbookId) {
2350
+ if (!playbookId) return null;
2351
+ try {
2352
+ const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
2353
+ if (!pb) return null;
2354
+ const inds = (pb.phases?.look?.indicators || []).filter(i => i && i.id);
2355
+ if (inds.length === 0) return null;
2356
+ return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
2357
+ } catch { return null; }
2358
+ }
2209
2359
  function normalizedArtifacts(submission, runner, playbookId) {
2210
- if (!submission || typeof submission !== "object") return {};
2211
- if (submission.artifacts) return submission.artifacts;
2212
- if (submission.observations) {
2213
- // v0.11.12 (#126): the prior stub playbook (look.artifacts: []) caused
2214
- // normalizeSubmission to produce {}, so attest diff reported
2215
- // total_compared: 0 even when both submissions held real observations.
2216
- // Try to load the real playbook from the attestation's playbook_id; if
2217
- // that fails (renamed/removed), fall back to treating each observation
2218
- // key as its own artifact id with `{ captured: true, value: <indicator|value> }`.
2360
+ if (!submission || typeof submission !== "object") {
2361
+ return _playbookArtifactCatalog(runner, playbookId) || {};
2362
+ }
2363
+ if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
2364
+ if (submission.observations && Object.keys(submission.observations).length > 0) {
2365
+ // v0.11.12 (#126): load real playbook so look.artifacts catalog can map
2366
+ // observations. v0.11.13 (#128): when normalize succeeds but produces an
2367
+ // empty map, fall through to direct mapping instead of returning empty.
2219
2368
  if (playbookId) {
2220
2369
  try {
2221
2370
  const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
@@ -2223,23 +2372,26 @@ function normalizedArtifacts(submission, runner, playbookId) {
2223
2372
  const norm = runner.normalizeSubmission({ observations: submission.observations }, pb);
2224
2373
  if (norm && norm.artifacts && Object.keys(norm.artifacts).length > 0) return norm.artifacts;
2225
2374
  }
2226
- } catch { /* fall through to direct mapping */ }
2375
+ } catch { /* fall through */ }
2227
2376
  }
2228
- // Direct mapping: observation keys are the artifact ids by convention.
2229
2377
  const out = {};
2230
2378
  for (const [k, v] of Object.entries(submission.observations)) {
2231
2379
  out[k] = (v && typeof v === "object") ? v : { value: v };
2232
2380
  }
2233
2381
  return out;
2234
2382
  }
2235
- return {};
2383
+ // v0.11.13 (#128): empty submission ({} or {observations:{}}). Identical
2384
+ // hashes still mean "no operator data was supplied, same on both sides."
2385
+ // Fall back to the playbook's look.artifacts catalog so total_compared
2386
+ // reflects "N catalog artifacts, all uniformly empty on both sides."
2387
+ return _playbookArtifactCatalog(runner, playbookId) || {};
2236
2388
  }
2237
2389
  function normalizedSignalOverrides(submission, runner, playbookId) {
2238
- if (!submission || typeof submission !== "object") return {};
2239
- if (submission.signal_overrides) return submission.signal_overrides;
2240
- if (submission.observations) {
2241
- // v0.11.12 (#126): same fix as normalizedArtifacts — load the real
2242
- // playbook so look.indicators can map observation results to signal IDs.
2390
+ if (!submission || typeof submission !== "object") {
2391
+ return _playbookSignalCatalog(runner, playbookId) || {};
2392
+ }
2393
+ if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
2394
+ if (submission.observations && Object.keys(submission.observations).length > 0) {
2243
2395
  if (playbookId) {
2244
2396
  try {
2245
2397
  const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
@@ -2247,16 +2399,15 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
2247
2399
  const norm = runner.normalizeSubmission({ observations: submission.observations }, pb);
2248
2400
  if (norm && norm.signal_overrides && Object.keys(norm.signal_overrides).length > 0) return norm.signal_overrides;
2249
2401
  }
2250
- } catch { /* fall through to direct mapping */ }
2402
+ } catch { /* fall through */ }
2251
2403
  }
2252
- // Direct mapping: observation key -> result (canonical hit/miss/inconclusive).
2253
2404
  const out = {};
2254
2405
  for (const [k, v] of Object.entries(submission.observations)) {
2255
2406
  if (v && typeof v === "object" && v.result !== undefined) out[k] = v.result;
2256
2407
  }
2257
2408
  return out;
2258
2409
  }
2259
- return {};
2410
+ return _playbookSignalCatalog(runner, playbookId) || {};
2260
2411
  }
2261
2412
 
2262
2413
  /**
@@ -2668,6 +2819,41 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2668
2819
  }
2669
2820
  }
2670
2821
 
2822
+ // v0.11.14: opt-in `--registry-check` queries the npm registry for the
2823
+ // latest published version + publish date and computes "days behind."
2824
+ // Opt-in (not on every doctor invocation) so offline use + air-gap
2825
+ // workflows aren't disturbed. Routed through a child process to keep
2826
+ // cmdDoctor synchronous + bound the network timeout cleanly.
2827
+ if (args["registry-check"]) {
2828
+ try {
2829
+ const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
2830
+ const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
2831
+ encoding: "utf8",
2832
+ cwd: PKG_ROOT,
2833
+ timeout: 8000,
2834
+ });
2835
+ let parsed = null;
2836
+ try { parsed = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
2837
+ if (parsed) {
2838
+ checks.registry = {
2839
+ ok: parsed.ok && (parsed.same || parsed.ahead),
2840
+ severity: parsed.behind ? "warn" : (parsed.ok ? "info" : "warn"),
2841
+ ...parsed,
2842
+ };
2843
+ } else {
2844
+ checks.registry = {
2845
+ ok: false,
2846
+ severity: "warn",
2847
+ error: "upstream-check did not return JSON",
2848
+ exit_code: res.status,
2849
+ raw: ((res.stderr || res.stdout || "")).slice(0, 200),
2850
+ };
2851
+ }
2852
+ } catch (e) {
2853
+ checks.registry = { ok: false, severity: "warn", error: e.message };
2854
+ }
2855
+ }
2856
+
2671
2857
  // Walk every check and split: errors (severity error/missing/fail) vs warnings
2672
2858
  // (severity warn). all_green is true ONLY when zero errors AND zero warnings.
2673
2859
  const warnList = [];
@@ -3418,30 +3604,34 @@ function cmdCi(runner, args, runOpts, pretty) {
3418
3604
  } else {
3419
3605
  emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
3420
3606
  }
3607
+ // v0.11.14 (#134): exit-code matrix with BLOCKED before FAIL.
3608
+ // Pre-0.11.14 the `if (fail)` check fired first for blocked runs (because
3609
+ // the loop pushed blocked entries onto failReasons), so blocked runs got
3610
+ // exit 2 (FAIL/detected) instead of exit 4 (BLOCKED/didn't-execute).
3611
+ // Operators wiring CI gates couldn't distinguish "playbook detected a
3612
+ // problem" from "playbook never executed because preflight halted."
3613
+ //
3614
+ // Exit-code matrix (final, documented in --help):
3615
+ // 0 PASS every playbook produced a result, none detected/escalating
3616
+ // 1 FRAMEWORK engine/parse error (set by emit() when body is ok:false)
3617
+ // 2 FAIL detected classification OR rwep>=escalate
3618
+ // 3 NO-DATA ran but no --evidence and all inconclusive
3619
+ // 4 BLOCKED at least one playbook returned ok:false (preflight halt,
3620
+ // stale threat intel, missing precondition, mutex contention)
3621
+ // Precedence: BLOCKED > FAIL > NO-DATA > PASS. A blocked playbook didn't
3622
+ // actually evaluate signals, so it can't be a true detection.
3623
+ if (summary.blocked > 0) {
3624
+ const blockedReasons = failReasons.filter(r => r.includes("blocked"));
3625
+ process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit 4. Reasons:\n ${blockedReasons.join("\n ")}\n`);
3626
+ process.exitCode = 4;
3627
+ return;
3628
+ }
3421
3629
  if (fail) {
3422
3630
  process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
3423
- // v0.11.11: use exitCode + return instead of process.exit() so the
3424
- // structured stdout JSON has a chance to flush when stdout is piped.
3425
- // process.exit() can truncate buffered async stdout writes.
3631
+ // v0.11.11: exitCode + return so emit()'s stdout flushes.
3426
3632
  process.exitCode = 2;
3427
3633
  return;
3428
3634
  }
3429
- // v0.11.12 (#125): ci exit code matrix. Pre-0.11.12 every non-detected
3430
- // path exited 0 including blocked runs that never executed — CI gates
3431
- // couldn't distinguish ok:false from ok:true. Now:
3432
- // 0 PASS — every playbook produced a result, none detected/escalating
3433
- // 2 FAIL — at least one detected or rwep>=escalate (above)
3434
- // 3 NO-DATA — ran but no --evidence and all inconclusive
3435
- // 4 BLOCKED — at least one playbook returned ok:false (preflight halt,
3436
- // stale threat intel, missing precondition, mutex contention, etc.)
3437
- // 1 FRAMEWORK — engine/parse error (set elsewhere)
3438
- // BLOCKED takes precedence over NO-DATA because a blocked run is a
3439
- // harder gate failure than "no real data."
3440
- if (summary.blocked > 0) {
3441
- process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) returned ok:false (preflight/mutex/threat-currency/etc.). Exit 4. Inspect results[].reason for each blocked entry.\n`);
3442
- process.exitCode = 4;
3443
- return;
3444
- }
3445
3635
  const suppliedEvidence = args.evidence || args["evidence-dir"];
3446
3636
  const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
3447
3637
  if (!suppliedEvidence && allInconclusive) {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-12T23:57:49.662Z",
3
+ "generated_at": "2026-05-13T02:04:13.785Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "be34fdbfb886861dff2f440d58324bfeba5f0c9188bffc5b6855ad682cc16e7b",
7
+ "manifest.json": "75707bfee79c57f6d7c6999c9da7292a574cb33669b17cf60e32160a5a2fa0d2",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
package/keys/public.pem CHANGED
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEAvh9LPaLkX7A41C4cKF1gOWfhLtiRPyAN2emiISakga4=
2
+ MCowBQYDK2VwAyEAbyrz9k9voneYsqY63g6A5y4jTcuiJd0FEDtk4li5uIE=
3
3
  -----END PUBLIC KEY-----
@@ -77,22 +77,38 @@ function parseArgs(argv) {
77
77
  }
78
78
 
79
79
  function printHelp() {
80
- console.log(`refresh-external — pull latest upstream data, optionally upsert into local catalogs.
80
+ console.log(`refresh — pull latest upstream data, optionally upsert into local catalogs.
81
+
82
+ Default behavior is to actually fetch from the network in dry-run mode and write
83
+ refresh-report.json. Use --apply to upsert findings into local catalogs.
81
84
 
82
85
  Modes:
83
- (default) dry-run all sources, write refresh-report.json
84
- --apply apply diffs and rebuild indexes
85
- --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
86
- --from-fixture <p> use frozen fixture payloads (tests use this path)
86
+ (default) fetch all sources from network, dry-run, write refresh-report.json
87
+ --apply apply diffs and rebuild indexes (default also fetches; combine)
88
+ --network fetch the latest signed catalog snapshot from the
89
+ maintainer's npm-published tarball, verify every skill
90
+ signature against the local public.pem, swap data/ in
91
+ place. Same trust anchor as \`npm update -g\`, only the
92
+ data slice changes — useful when you want fresher
93
+ intel without re-resolving CLI/lib code.
94
+ --prefetch (alias: --no-network) populate the cache for offline use.
95
+ Equivalent to \`exceptd prefetch\`.
87
96
  --from-cache [<p>] read from prefetch cache (default .cache/upstream).
88
97
  Combine with --apply to upsert against cached data
89
- entirely offline.
90
- --swarm fan out sources across worker threads. Source fetches
91
- run in parallel rather than sequentially. Best when
92
- paired with --from-cache (no rate-limit contention).
98
+ entirely offline. Cache must be pre-populated via --prefetch.
99
+ --source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
100
+ --from-fixture <p> use frozen fixture payloads (tests use this path)
101
+ --indexes-only rebuild data/_indexes/ only; no network. Equivalent to
102
+ \`exceptd refresh --indexes-only\`.
103
+ --swarm fan out sources across worker threads. Best with --from-cache.
104
+
105
+ Air-gap workflow:
106
+ 1. On a connected host: \`exceptd refresh --prefetch\`
107
+ 2. Copy .cache/upstream/ across the boundary
108
+ 3. On the offline host: \`exceptd refresh --from-cache --apply\`
93
109
 
94
110
  Outputs:
95
- refresh-report.json (gitignored) — summary of every diff + per-source status.
111
+ refresh-report.json (gitignored) — per-source status + every diff
96
112
 
97
113
  This module never auto-applies version-pin bumps — those require audit per
98
114
  AGENTS.md Hard Rule #12 and are surfaced as report-only findings.
@@ -642,7 +658,19 @@ function loadCtx(opts) {
642
658
  const abs = path.resolve(opts.fromCache);
643
659
  ctx.cacheDir = abs;
644
660
  if (!fs.existsSync(abs)) {
645
- throw new Error(`refresh-external: --from-cache path does not exist: ${abs}`);
661
+ // v0.11.14 (#129): operators following the website's air-gap workflow
662
+ // hit this with an unhelpful "path does not exist" stack trace. The
663
+ // cache is populated by `exceptd refresh --no-network` (which routes
664
+ // to prefetch). Tell them exactly that, and emit a structured JSON
665
+ // error to stderr instead of a fatal stack trace.
666
+ const err = new Error(
667
+ `refresh: --from-cache path does not exist: ${abs}\n` +
668
+ `Hint: the cache is populated by running \`exceptd refresh --no-network\` (or \`exceptd refresh --prefetch\`) ` +
669
+ `on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --no-network\`, ` +
670
+ `(2) copy .cache/upstream/ across the boundary, (3) on offline host: \`exceptd refresh --from-cache --apply\`.`
671
+ );
672
+ err._exceptd_hint = true;
673
+ throw err;
646
674
  }
647
675
  }
648
676
  return ctx;
@@ -769,7 +797,14 @@ async function sequential(items, fn) {
769
797
 
770
798
  if (require.main === module) {
771
799
  main().catch((err) => {
772
- console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
800
+ // v0.11.14 (#129): hinted errors print the hint message + a structured
801
+ // JSON line on stderr instead of a fatal stack trace.
802
+ if (err && err._exceptd_hint) {
803
+ console.error(err.message);
804
+ console.error(JSON.stringify({ ok: false, error: err.message.split("\n")[0], hint: err.message.split("\n").slice(1).join(" ").trim(), verb: "refresh" }));
805
+ } else {
806
+ console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
807
+ }
773
808
  process.exit(2);
774
809
  });
775
810
  }