@blamejs/exceptd-skills 0.11.0 → 0.11.2

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/bin/exceptd.js CHANGED
@@ -81,6 +81,7 @@ const COMMANDS = {
81
81
  "validate-cves": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
82
82
  "validate-rfcs": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
83
83
  watchlist: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
84
+ watch: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
84
85
  "framework-gap": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
85
86
  "framework-gap-analysis": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
86
87
  // Seven-phase playbook verbs — handled in-process via lib/playbook-runner.js.
@@ -95,7 +96,7 @@ const COMMANDS = {
95
96
 
96
97
  const ORCHESTRATOR_PASSTHROUGH = new Set([
97
98
  "scan", "dispatch", "skill", "currency", "report",
98
- "validate-cves", "validate-rfcs", "watchlist",
99
+ "validate-cves", "validate-rfcs", "watchlist", "watch",
99
100
  "framework-gap", "framework-gap-analysis",
100
101
  ]);
101
102
 
@@ -275,6 +276,25 @@ Project rules: ${PKG_ROOT}/AGENTS.md
275
276
 
276
277
  function main() {
277
278
  const argv = process.argv.slice(2);
279
+
280
+ // --json-stdout-only: silence ALL stderr emissions (deprecation banners,
281
+ // unsigned-attestation warnings, hook output). Operators piping the JSON
282
+ // result through `jq` or scripting around exit codes want clean stdout
283
+ // exclusively. Handled here at top of main so the deprecation banner +
284
+ // unsigned warning are suppressed before they fire.
285
+ if (argv.includes("--json-stdout-only")) {
286
+ process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
287
+ process.env.EXCEPTD_UNSIGNED_WARNED = "1";
288
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
289
+ process.stderr.write = (chunk, encoding, cb) => {
290
+ // Let actual error frames through (uncaught exceptions need to surface
291
+ // for debugging); suppress framework stderr.
292
+ if (typeof chunk === "string" && chunk.startsWith("Error")) return origStderrWrite(chunk, encoding, cb);
293
+ if (typeof cb === "function") cb();
294
+ return true;
295
+ };
296
+ }
297
+
278
298
  if (argv.length === 0) {
279
299
  printWelcome();
280
300
  process.exit(0);
@@ -300,8 +320,17 @@ function main() {
300
320
  if (PLAYBOOK_VERBS.has(cmd)) {
301
321
  // One-time deprecation banner per process when a legacy verb is invoked.
302
322
  if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
323
+ // Mention the installed version explicitly so an operator on v0.10.x
324
+ // who reads "Prefer brief..." doesn't go looking for a verb that
325
+ // doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
326
+ // users see this with the explicit "upgrade to v0.11.0 first" note.
327
+ const ver = readPkgVersion();
328
+ const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
303
329
  process.stderr.write(
304
- `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (v0.11.0). ` +
330
+ `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
331
+ (haveBrief
332
+ ? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
333
+ : `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
305
334
  `Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
306
335
  `Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
307
336
  );
@@ -311,7 +340,21 @@ function main() {
311
340
  return;
312
341
  }
313
342
 
314
- const resolver = COMMANDS[cmd];
343
+ // v0.11.2 bug #65: `refresh --no-network` / `refresh --indexes-only` were
344
+ // documented as the v0.11.0 replacements for `prefetch` / `build-indexes`
345
+ // but the underlying refresh script doesn't know those flags. Translate
346
+ // here so the deprecation pointer actually works.
347
+ let effectiveCmd = cmd;
348
+ let effectiveRest = rest;
349
+ if (cmd === "refresh" && rest.includes("--no-network")) {
350
+ effectiveCmd = "prefetch";
351
+ effectiveRest = rest.filter(a => a !== "--no-network");
352
+ } else if (cmd === "refresh" && rest.includes("--indexes-only")) {
353
+ effectiveCmd = "build-indexes";
354
+ effectiveRest = rest.filter(a => a !== "--indexes-only");
355
+ }
356
+
357
+ const resolver = COMMANDS[effectiveCmd];
315
358
  if (typeof resolver !== "function") {
316
359
  // Emit a structured JSON error matching the seven-phase verbs so operators
317
360
  // piping through `jq` get one consistent shape across the CLI surface.
@@ -329,7 +372,7 @@ function main() {
329
372
 
330
373
  // Orchestrator subcommands need the subcommand name preserved as argv[0]
331
374
  // for orchestrator/index.js's switch statement.
332
- const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(cmd) ? [script, cmd, ...rest] : [script, ...rest];
375
+ const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
333
376
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
334
377
  if (res.error) {
335
378
  process.stderr.write(`exceptd: failed to run ${cmd}: ${res.error.message}\n`);
@@ -385,7 +428,13 @@ function parseArgs(argv, opts) {
385
428
  }
386
429
 
387
430
  function emit(obj, pretty) {
388
- const s = pretty ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
431
+ // v0.11.2 bug #60: when stdout is a TTY (interactive use), emit indented
432
+ // JSON instead of single-line — much more readable. Piped to a file or
433
+ // tool? Default to compact one-line JSON. --pretty forces indented
434
+ // regardless of TTY. --json-stdout-only is always compact.
435
+ const interactive = process.stdout.isTTY && !process.env.EXCEPTD_RAW_JSON;
436
+ const indent = pretty || (interactive && !pretty);
437
+ const s = indent ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
389
438
  process.stdout.write(s + "\n");
390
439
  }
391
440
 
@@ -429,9 +478,21 @@ function dispatchPlaybook(cmd, argv) {
429
478
  const args = parseArgs(argv, {
430
479
  bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
431
480
  "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
432
- "force-overwrite", "no-stream", "block-on-jurisdiction-clock"],
481
+ "force-overwrite", "no-stream", "block-on-jurisdiction-clock",
482
+ "json-stdout-only", "fix", "human", "json"],
433
483
  multi: ["playbook", "format"],
434
484
  });
485
+ // v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
486
+ // (or --pretty implies indented JSON). The v0.11.0 CHANGELOG claimed this
487
+ // was already done; the code in fact emitted JSON unconditionally. Now:
488
+ // --json or --pretty → JSON (one-line or indented respectively)
489
+ // --json-stdout-only → JSON, suppress stderr
490
+ // default → human-readable text
491
+ // Verbs that have their own human renderer (discover/doctor/refresh/lint
492
+ // /ask/attest list) continue to use it; verbs that don't yet (brief/run/
493
+ // ai-run/ci/attest show/export/diff/verify) fall back to indented JSON
494
+ // labeled as such — better than no signal.
495
+ args._jsonMode = !!(args.json || args.pretty || args["json-stdout-only"]);
435
496
  const pretty = !!args.pretty;
436
497
  const runOpts = {
437
498
  airGap: !!args["air-gap"],
@@ -1080,6 +1141,13 @@ function cmdRun(runner, args, runOpts, pretty) {
1080
1141
  }
1081
1142
 
1082
1143
  let submission = {};
1144
+ // v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
1145
+ // something is piping into us). If no --evidence flag and stdin is a pipe,
1146
+ // assume `--evidence -`. Operators forgetting the flag previously got a
1147
+ // confusing precondition halt; now the common case "just works."
1148
+ if (!args.evidence && process.stdin.isTTY === false) {
1149
+ args.evidence = "-";
1150
+ }
1083
1151
  if (args.evidence) {
1084
1152
  try {
1085
1153
  submission = readEvidence(args.evidence);
@@ -1098,7 +1166,12 @@ function cmdRun(runner, args, runOpts, pretty) {
1098
1166
  // Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
1099
1167
  // produce multiple bundles in the close response under bundles_by_format.
1100
1168
  if (args.format) {
1101
- const formats = Array.isArray(args.format) ? args.format : [args.format];
1169
+ // Normalize shortcut names to the runner's canonical bundle keys before
1170
+ // passing through. "csaf" → "csaf-2.0"; "sarif" / "openvex" / "markdown"
1171
+ // / "summary" stay verbatim. Anything else is rejected after the run
1172
+ // result is in hand (so the run still completes).
1173
+ const formats = (Array.isArray(args.format) ? args.format : [args.format])
1174
+ .map(f => f === "csaf" ? "csaf-2.0" : f);
1102
1175
  submission.signals = submission.signals || {};
1103
1176
  submission.signals._bundle_formats = formats;
1104
1177
  }
@@ -1223,6 +1296,80 @@ function cmdRun(runner, args, runOpts, pretty) {
1223
1296
  return;
1224
1297
  }
1225
1298
 
1299
+ // v0.11.2 bug #59 / feature #70: --format actually transforms the top-level
1300
+ // output. Previously it only populated close.evidence_package.bundles_by_format
1301
+ // and the operator still saw the full JSON. Now:
1302
+ // --format summary → single-line JSON digest (5 fields)
1303
+ // --format markdown → operator-readable markdown digest of the run
1304
+ // --format csaf-2.0/sarif/openvex → the corresponding bundle from close
1305
+ // (default — no --format) → full JSON result as before
1306
+ if (args.format) {
1307
+ const requested = Array.isArray(args.format) ? args.format[0] : args.format;
1308
+ const VALID = ["summary", "markdown", "csaf-2.0", "csaf", "sarif", "openvex", "json"];
1309
+ if (!VALID.includes(requested)) {
1310
+ return emitError(`run: --format "${requested}" not in accepted set ${JSON.stringify(VALID)}.`, null, pretty);
1311
+ }
1312
+ if (requested === "summary") {
1313
+ const cls = result.phases?.detect?.classification;
1314
+ const rwep = result.phases?.analyze?.rwep?.adjusted ?? 0;
1315
+ const blast = result.phases?.analyze?.blast_radius_score ?? 0;
1316
+ const cves = result.phases?.analyze?.matched_cves?.length ?? 0;
1317
+ const next = result.phases?.close?.feeds_into?.join(",") || "";
1318
+ const clocks = (result.phases?.close?.notification_actions || []).filter(n => n.clock_started_at).length;
1319
+ emit({
1320
+ ok: result.ok, playbook: result.playbook_id, session_id: result.session_id,
1321
+ classification: cls, rwep, blast_radius: blast, matched_cves: cves,
1322
+ feeds_into: next, jurisdiction_clocks: clocks, evidence_hash: result.evidence_hash,
1323
+ }, pretty);
1324
+ return;
1325
+ }
1326
+ if (requested === "markdown") {
1327
+ const lines = [];
1328
+ lines.push(`# exceptd run: ${result.playbook_id}`);
1329
+ lines.push(`session-id: ${result.session_id}`);
1330
+ lines.push(`evidence-hash: ${result.evidence_hash}`);
1331
+ lines.push("");
1332
+ const cls = result.phases?.detect?.classification || "n/a";
1333
+ const rwep = result.phases?.analyze?.rwep?.adjusted ?? 0;
1334
+ const top = result.phases?.analyze?.rwep?.threshold?.escalate ?? "n/a";
1335
+ lines.push(`**Classification:** ${cls} **RWEP:** ${rwep} / ${top} **Blast radius:** ${result.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
1336
+ lines.push("");
1337
+ const cves = result.phases?.analyze?.matched_cves || [];
1338
+ if (cves.length) {
1339
+ lines.push(`## Matched CVEs (${cves.length})`);
1340
+ for (const c of cves) lines.push(`- **${c.cve_id}** · RWEP ${c.rwep} · KEV=${c.cisa_kev} · ${c.active_exploitation}`);
1341
+ lines.push("");
1342
+ }
1343
+ const rem = result.phases?.validate?.selected_remediation;
1344
+ if (rem) {
1345
+ lines.push(`## Recommended remediation`);
1346
+ lines.push(`**${rem.id}** (priority ${rem.priority}) — ${rem.description}`);
1347
+ lines.push("");
1348
+ }
1349
+ const notif = result.phases?.close?.notification_actions || [];
1350
+ if (notif.length) {
1351
+ lines.push(`## Notification clocks`);
1352
+ for (const n of notif) lines.push(`- ${n.obligation_ref} → deadline ${n.deadline}`);
1353
+ lines.push("");
1354
+ }
1355
+ const feeds = result.phases?.close?.feeds_into || [];
1356
+ if (feeds.length) lines.push(`**Next playbooks suggested:** ${feeds.join(", ")}`);
1357
+ process.stdout.write(lines.join("\n") + "\n");
1358
+ return;
1359
+ }
1360
+ // CSAF/SARIF/OpenVEX bundles live under close.evidence_package — the
1361
+ // runner writes them under canonical keys ("csaf-2.0", "sarif",
1362
+ // "openvex"). Normalize the user-supplied shortcuts.
1363
+ const formatNorm = requested === "csaf" ? "csaf-2.0" : requested;
1364
+ const bbf = result.phases?.close?.evidence_package?.bundles_by_format || {};
1365
+ const body = bbf[formatNorm] || result.phases?.close?.evidence_package?.bundle_body;
1366
+ if (body) {
1367
+ emit(body, pretty);
1368
+ return;
1369
+ }
1370
+ // Fallback: full result
1371
+ }
1372
+
1226
1373
  emit(result, pretty);
1227
1374
  }
1228
1375
 
@@ -2224,22 +2371,43 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2224
2371
  const keyPath = path.join(process.cwd(), ".keys", "private.pem");
2225
2372
  const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
2226
2373
  const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
2374
+ // Bug #61 (v0.11.2): signing-status missing key is a real WARNING. The
2375
+ // attestation pipeline writes unsigned files when this is absent, which
2376
+ // operators reading the attestation later cannot verify for authenticity.
2377
+ // The summary line must reflect this — pre-0.11.2 said "all checks green"
2378
+ // directly above [!!] private key MISSING. Now: it's a warning that
2379
+ // populates summary.warnings_count.
2227
2380
  checks.signing = {
2228
- ok: true, // signing-status is informational, never "fails"
2381
+ ok: present, // not green if the key is missing operators need the nudge
2382
+ severity: present ? "info" : "warn",
2229
2383
  private_key_present: present,
2230
2384
  can_sign_attestations: present,
2231
- ...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
2385
+ ...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` (or `exceptd doctor --fix`) to enable attestation signing" }),
2232
2386
  };
2233
2387
  } catch (e) {
2234
2388
  checks.signing = { ok: false, error: e.message };
2235
2389
  }
2236
2390
  }
2237
2391
 
2238
- const allGreen = issues.length === 0;
2392
+ // Walk every check and split: errors (severity error/missing/fail) vs warnings
2393
+ // (severity warn). all_green is true ONLY when zero errors AND zero warnings.
2394
+ const warnList = [];
2395
+ const errorList = [];
2396
+ for (const [k, v] of Object.entries(checks)) {
2397
+ if (v.ok === false) errorList.push(k);
2398
+ else if (v.severity === "warn") warnList.push(k);
2399
+ }
2400
+ const allGreen = errorList.length === 0 && warnList.length === 0;
2239
2401
  const out = {
2240
2402
  verb: "doctor",
2241
2403
  checks,
2242
- summary: { all_green: allGreen, issues_count: issues.length, failed_checks: issues },
2404
+ summary: {
2405
+ all_green: allGreen,
2406
+ issues_count: errorList.length,
2407
+ warnings_count: warnList.length,
2408
+ failed_checks: errorList,
2409
+ warning_checks: warnList,
2410
+ },
2243
2411
  };
2244
2412
 
2245
2413
  if (wantJson) {
@@ -2253,7 +2421,10 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2253
2421
  lines.push("exceptd doctor");
2254
2422
  function mark(c, render) {
2255
2423
  if (!c) return;
2256
- const icon = c.ok ? "[ok]" : "[!!]";
2424
+ // Three states: ok / warn / error. Bug #61 (v0.11.2) — warn must not be
2425
+ // shown as ok and must count toward the summary so the bottom line
2426
+ // matches the visible icons above.
2427
+ const icon = c.ok && c.severity !== "warn" ? "[ok]" : (c.severity === "warn" ? "[!! warn]" : "[!! fail]");
2257
2428
  lines.push(` ${icon} ${render(c)}`);
2258
2429
  }
2259
2430
  mark(checks.signatures, c =>
@@ -2284,9 +2455,30 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2284
2455
  }
2285
2456
  }
2286
2457
  lines.push("");
2287
- lines.push(allGreen ? `summary: all checks green` : `summary: ${issues.length} issue(s) — ${issues.join(", ")}`);
2458
+ if (allGreen) {
2459
+ lines.push(`summary: all checks green`);
2460
+ } else if (errorList.length === 0) {
2461
+ lines.push(`summary: ${warnList.length} warning(s) — ${warnList.join(", ")}`);
2462
+ } else {
2463
+ lines.push(`summary: ${errorList.length} fail / ${warnList.length} warn — fail: ${errorList.join(", ")}; warn: ${warnList.join(", ") || "none"}`);
2464
+ }
2288
2465
  process.stdout.write(lines.join("\n") + "\n");
2289
- if (!allGreen) process.exitCode = 1;
2466
+ // Bug #69 (v0.11.2): --fix mode for missing private key.
2467
+ if (args.fix && checks.signing && !checks.signing.private_key_present) {
2468
+ process.stdout.write("\n[doctor --fix] generating Ed25519 keypair via `node lib/sign.js generate-keypair`...\n");
2469
+ const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], { stdio: "inherit", cwd: PKG_ROOT });
2470
+ if (r.status === 0) {
2471
+ process.stdout.write("[doctor --fix] keypair generated — re-run `exceptd doctor` to confirm.\n");
2472
+ } else {
2473
+ process.stdout.write(`[doctor --fix] generation failed (exit=${r.status}); run \`node lib/sign.js generate-keypair\` manually.\n`);
2474
+ process.exitCode = 1;
2475
+ return;
2476
+ }
2477
+ }
2478
+ if (errorList.length > 0) process.exitCode = 1;
2479
+ // Warnings alone do NOT force exit 1 — CI gates use exit 0 to mean "ran
2480
+ // successfully" even with informational warnings. Operators reading the
2481
+ // visible "[!! warn]" line still see the issue.
2290
2482
  }
2291
2483
 
2292
2484
  function cmdListAttestations(runner, args, runOpts, pretty) {
@@ -2534,10 +2726,31 @@ function cmdAiRun(runner, args, runOpts, pretty) {
2534
2726
  const tail = buf.trim();
2535
2727
  if (tail) handleLine(tail);
2536
2728
  if (!handled) {
2537
- writeLine({ event: "error", reason: "stdin closed without an evidence event." });
2729
+ // Bug #66 (v0.11.2): stdin closed without an evidence event. Before
2730
+ // declaring an error, try to interpret the raw stdin as a bare
2731
+ // submission object (the common shell-pipe case where `echo
2732
+ // '{...}' | exceptd ai-run secrets` pipes the submission body, not a
2733
+ // wrapped event). If it parses as such, run with it and complete the
2734
+ // phases. Otherwise emit the helpful error.
2735
+ const raw = (process.stdin._consumed || "") || buf;
2736
+ const allText = process.stdin._allText;
2737
+ if (allText && allText.trim()) {
2738
+ try {
2739
+ const parsed = JSON.parse(allText.trim());
2740
+ if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides || parsed.precondition_checks)) {
2741
+ handleLine(JSON.stringify({ event: "evidence", payload: parsed }));
2742
+ return;
2743
+ }
2744
+ } catch { /* fall through to error */ }
2745
+ }
2746
+ writeLine({ event: "error", reason: "stdin closed without an evidence event. Pipe `{\"event\":\"evidence\",\"payload\":{...}}` for streaming mode, or pass --no-stream + --evidence <file> for single-shot." });
2538
2747
  process.exit(1);
2539
2748
  }
2540
2749
  });
2750
+
2751
+ // Capture stdin for the post-close fallback.
2752
+ process.stdin._allText = "";
2753
+ process.stdin.on("data", chunk => { process.stdin._allText += chunk.toString(); });
2541
2754
  }
2542
2755
 
2543
2756
  /**
@@ -2573,6 +2786,31 @@ function buildSubmissionFromPayload(payload) {
2573
2786
  * phases.direct.threat_context. Returns the top 5 matches with a confidence
2574
2787
  * score (matched tokens / total tokens).
2575
2788
  */
2789
+ /**
2790
+ * `ask "<question>"` — plain-English routing to playbook(s).
2791
+ *
2792
+ * v0.11.2 rewrite (#58 / #67): the v0.11.0 implementation only indexed
2793
+ * domain.name + attack_class + first sentence of threat_context, with a
2794
+ * length>3 token filter that dropped short but-meaningful words like "PQC"
2795
+ * or "MCP". The richer index now includes:
2796
+ * - playbook id
2797
+ * - domain.name + domain.attack_class
2798
+ * - domain.attack_refs (T-numbers) + atlas_refs (AML-numbers)
2799
+ * - domain.cwe_refs + frameworks_in_scope
2800
+ * - phases.govern.theater_fingerprints[].claim
2801
+ * - phases.direct.threat_context (full, not first sentence)
2802
+ * - phases.direct.framework_lag_declaration
2803
+ * - skill_chain skill names
2804
+ * - phases.look.collection_scope.asset_scope
2805
+ *
2806
+ * Token filter dropped to length >= 2 (was > 3) so "PQC" / "MCP" / "CI"
2807
+ * tokens match. Synonym map handles common operator phrasings ("API
2808
+ * keys" → secrets, "supply chain" → sbom / library-author, etc).
2809
+ *
2810
+ * Threshold: top match must have score >= 1 (was > 0; same). When no
2811
+ * playbook scores >= 1, fall back to substring match on playbook ID
2812
+ * itself ("secrets" → secrets playbook).
2813
+ */
2576
2814
  function cmdAsk(runner, args, runOpts, pretty) {
2577
2815
  const question = (args._ || []).join(" ").trim();
2578
2816
  if (!question) {
@@ -2580,42 +2818,97 @@ function cmdAsk(runner, args, runOpts, pretty) {
2580
2818
  }
2581
2819
  const ids = runner.listPlaybooks();
2582
2820
  const q = question.toLowerCase();
2583
- const tokens = q.split(/\W+/).filter(t => t.length > 3);
2821
+
2822
+ // Synonym expansion — common operator phrasings → playbook-relevant tokens.
2823
+ // Keeps cmdAsk dependency-free; rich enough to cover the 80% of natural
2824
+ // queries listed in the operator report.
2825
+ const SYNONYMS = {
2826
+ "credential": ["secret", "key", "token", "password", "cred"],
2827
+ "credentials": ["secret", "key", "token", "password", "cred"],
2828
+ "api key": ["secret", "credential"],
2829
+ "api keys": ["secret", "credential"],
2830
+ "supply chain": ["sbom", "dependency", "vendor", "package", "library", "publish"],
2831
+ "supply-chain": ["sbom", "dependency", "vendor", "package", "library", "publish"],
2832
+ "npm package": ["sbom", "dependency", "library", "publish"],
2833
+ "npm packages": ["sbom", "dependency", "library", "publish"],
2834
+ "pqc": ["post-quantum", "quantum", "crypto", "ml-kem", "ml-dsa", "kyber", "dilithium"],
2835
+ "quantum": ["pqc", "post-quantum"],
2836
+ "audit": ["scan", "review", "check", "validate", "verify"],
2837
+ "mcp": ["model context protocol", "tool", "ai-tool"],
2838
+ "ai": ["llm", "model", "anthropic", "openai", "claude"],
2839
+ "compliance": ["framework", "audit", "soc", "iso", "nist", "gdpr", "dora", "nis2", "regulator"],
2840
+ "kernel": ["lpe", "linux", "privilege", "escalation", "cve", "uname"],
2841
+ "container": ["docker", "kubernetes", "k8s", "compose", "image"],
2842
+ "secret": ["credential", "key", "token", "env", "leak"],
2843
+ "secrets": ["credential", "key", "token", "env", "leak", "repo"],
2844
+ "config": ["configuration", "settings"],
2845
+ };
2846
+
2847
+ // Tokenize question (length >= 2, lowercase) + expand via synonyms.
2848
+ const baseTokens = q.split(/\W+/).filter(t => t.length >= 2);
2849
+ const expanded = new Set(baseTokens);
2850
+ // multi-word synonym keys
2851
+ for (const [phrase, syns] of Object.entries(SYNONYMS)) {
2852
+ if (q.includes(phrase)) for (const s of syns) expanded.add(s);
2853
+ }
2854
+ // single-word synonym keys
2855
+ for (const t of baseTokens) {
2856
+ if (SYNONYMS[t]) for (const s of SYNONYMS[t]) expanded.add(s);
2857
+ }
2858
+ const tokens = [...expanded];
2859
+
2584
2860
  const scored = [];
2585
2861
  for (const id of ids) {
2586
2862
  let pb;
2587
2863
  try { pb = runner.loadPlaybook(id); } catch { continue; }
2588
- const threat = pb.phases?.direct?.threat_context || "";
2589
- const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
2590
2864
  const haystack = [
2865
+ pb._meta?.id || id,
2591
2866
  pb.domain?.name || "",
2592
2867
  pb.domain?.attack_class || "",
2593
- firstSentence,
2868
+ ...(pb.domain?.attack_refs || []),
2869
+ ...(pb.domain?.atlas_refs || []),
2870
+ ...(pb.domain?.cwe_refs || []),
2871
+ ...(pb.domain?.frameworks_in_scope || []),
2872
+ ...((pb.phases?.govern?.theater_fingerprints || []).map(t => t.claim || "")),
2873
+ ...((pb.phases?.govern?.theater_fingerprints || []).map(t => t.pattern_id || "")),
2874
+ pb.phases?.direct?.threat_context || "",
2875
+ pb.phases?.direct?.framework_lag_declaration || "",
2876
+ ...((pb.phases?.direct?.skill_chain || []).map(s => s.skill || "")),
2877
+ pb.phases?.look?.collection_scope?.asset_scope || "",
2878
+ pb.phases?.look?.collection_scope?.time_window || "",
2594
2879
  ].join(" ").toLowerCase();
2595
- const score = tokens.filter(t => haystack.includes(t)).length;
2880
+ let score = 0;
2881
+ for (const t of tokens) if (haystack.includes(t)) score++;
2882
+ // ID match counts double — "secrets" should map to the secrets playbook.
2883
+ if (tokens.some(t => (pb._meta?.id || id) === t)) score += 3;
2596
2884
  scored.push({ id: pb._meta?.id || id, score });
2597
2885
  }
2598
2886
  scored.sort((a, b) => b.score - a.score);
2599
2887
  const top = scored.filter(s => s.score > 0).slice(0, 5);
2600
2888
 
2889
+ // v0.11.2: default human-readable; --json for machine.
2601
2890
  if (top.length === 0) {
2602
- emit({
2891
+ const result = {
2603
2892
  verb: "ask",
2604
2893
  question,
2605
- matched: [],
2894
+ routed_to: [],
2606
2895
  hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
2607
- }, pretty);
2896
+ };
2897
+ if (args.json) return emit(result, pretty);
2898
+ process.stdout.write(`ask: ${question}\n no playbook matched.\n try: exceptd discover (auto-detect what's in your cwd)\n`);
2608
2899
  return;
2609
2900
  }
2610
2901
 
2611
- emit({
2902
+ const result = {
2612
2903
  verb: "ask",
2613
2904
  question,
2614
2905
  routed_to: top.map(t => t.id),
2615
- confidence: top[0].score / Math.max(1, tokens.length),
2906
+ confidence: Math.min(1, top[0].score / Math.max(2, tokens.length)),
2616
2907
  next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
2617
2908
  full_match_list: top,
2618
- }, pretty);
2909
+ };
2910
+ if (args.json) return emit(result, pretty);
2911
+ process.stdout.write(`ask: ${question}\n top match: ${top[0].id} (score ${top[0].score})\n next: ${result.next_step}\n alternates: ${top.slice(1).map(t => t.id).join(", ") || "(none)"}\n`);
2619
2912
  }
2620
2913
 
2621
2914
  /**
@@ -2632,11 +2925,29 @@ function cmdCi(runner, args, runOpts, pretty) {
2632
2925
  const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
2633
2926
  const blockOnClock = !!args["block-on-jurisdiction-clock"];
2634
2927
 
2928
+ // v0.11.2 bug #63: align with discover. ci now includes cross-cutting
2929
+ // playbooks (framework) automatically, and when --scope code is set on a
2930
+ // cwd that has lockfiles, also includes sbom (which is scope:system but
2931
+ // applies to any repo). Operators expect ci to run "what discover would
2932
+ // recommend"; pre-0.11.2 ci ran scope=X only, which dropped framework +
2933
+ // missed sbom-on-repo.
2635
2934
  let ids;
2636
2935
  if (args.all) {
2637
2936
  ids = runner.listPlaybooks();
2638
2937
  } else if (scope) {
2639
2938
  ids = filterPlaybooksByScope(runner, scope);
2939
+ // Always include cross-cutting playbooks regardless of scope choice.
2940
+ const cross = filterPlaybooksByScope(runner, "cross-cutting");
2941
+ ids = [...new Set([...ids, ...cross])];
2942
+ // For code-scope on a repo: also include sbom (system-scope but
2943
+ // repo-relevant) so ci output matches discover.
2944
+ if (scope === "code" && fs.existsSync(path.join(process.cwd(), ".git"))) {
2945
+ const hasLockfile = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "Pipfile.lock", "Cargo.lock", "go.sum"]
2946
+ .some(f => fs.existsSync(path.join(process.cwd(), f)));
2947
+ if (hasLockfile && runner.listPlaybooks().includes("sbom") && !ids.includes("sbom")) {
2948
+ ids.push("sbom");
2949
+ }
2950
+ }
2640
2951
  } else {
2641
2952
  const scopes = detectScopes();
2642
2953
  ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-12T14:47:57.871Z",
3
+ "generated_at": "2026-05-12T15:32:44.669Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "f4243591af627c02011ae29d97eb702764e495522188a4d8fcab7b58950d941c",
7
+ "manifest.json": "13b2aa2d552d684a06d9865d94e7233438ba95129914b4fd89e15968cf544ff5",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
@@ -143,16 +143,69 @@ function preflight(playbook, runOpts = {}) {
143
143
  }
144
144
  }
145
145
 
146
- // 3. Mutex
146
+ // 3. Mutex — both intra-process (in-memory Set) AND cross-process
147
+ // (filesystem lockfile under .exceptd/locks/<playbook>.lock). v0.11.0 only
148
+ // enforced intra-process; v0.11.1 adds cross-process so two parallel CLI
149
+ // invocations of mutex-conflicting playbooks correctly race-detect.
147
150
  for (const conflictId of meta.mutex || []) {
148
151
  if (_activeRuns.has(conflictId)) {
149
- return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict: playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
152
+ return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict (intra-process): playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
153
+ }
154
+ const lockPath = lockFilePath(conflictId);
155
+ if (lockPath && fs.existsSync(lockPath)) {
156
+ // Stale-lock detection: if the recorded PID is dead, ignore the lock.
157
+ try {
158
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
159
+ if (lock.pid && !pidAlive(lock.pid)) {
160
+ fs.unlinkSync(lockPath); // GC stale
161
+ } else {
162
+ return {
163
+ ok: false,
164
+ blocked_by: 'mutex',
165
+ reason: `Mutex conflict (cross-process): playbook ${conflictId} has an active lock at ${lockPath} (pid ${lock.pid}, started ${lock.started_at}).`,
166
+ issues,
167
+ };
168
+ }
169
+ } catch { /* malformed lockfile — treat as stale and remove */
170
+ try { fs.unlinkSync(lockPath); } catch {}
171
+ }
150
172
  }
151
173
  }
152
174
 
153
175
  return { ok: true, issues };
154
176
  }
155
177
 
178
+ function lockDir() {
179
+ const dir = path.join(process.cwd(), '.exceptd', 'locks');
180
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
181
+ return dir;
182
+ }
183
+
184
+ function lockFilePath(playbookId) {
185
+ try { return path.join(lockDir(), `${playbookId}.lock`); }
186
+ catch { return null; }
187
+ }
188
+
189
+ function acquireLock(playbookId) {
190
+ const p = lockFilePath(playbookId);
191
+ if (!p) return null;
192
+ try {
193
+ fs.writeFileSync(p, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2), { flag: 'wx' });
194
+ return p;
195
+ } catch { return null; /* already locked or unwritable */ }
196
+ }
197
+
198
+ function releaseLock(lockPath) {
199
+ if (!lockPath) return;
200
+ try { fs.unlinkSync(lockPath); } catch {}
201
+ }
202
+
203
+ function pidAlive(pid) {
204
+ if (typeof pid !== 'number') return false;
205
+ try { process.kill(pid, 0); return true; }
206
+ catch (e) { return e.code !== 'ESRCH'; }
207
+ }
208
+
156
209
  // --- phase 1: govern ---
157
210
 
158
211
  /**
@@ -915,6 +968,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
915
968
  }
916
969
 
917
970
  _activeRuns.add(playbookId);
971
+ // Cross-process mutex lock for this run. preflight verified no other lock
972
+ // exists; we acquire ours and release in the finally block.
973
+ const lockPath = acquireLock(playbookId);
918
974
  try {
919
975
  const phases = {
920
976
  govern: govern(playbookId, directiveId, runOpts),
@@ -947,6 +1003,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
947
1003
  };
948
1004
  } finally {
949
1005
  _activeRuns.delete(playbookId);
1006
+ releaseLock(lockPath);
950
1007
  }
951
1008
  }
952
1009
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-12T14:46:11.640Z",
3
+ "_generated_at": "2026-05-12T15:31:43.757Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [