@blamejs/exceptd-skills 0.10.3 → 0.11.1

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
@@ -100,10 +100,38 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
100
100
  ]);
101
101
 
102
102
  // Seven-phase playbook verbs handled in-process (no subprocess dispatch).
103
+ // v0.11.0 introduces: brief (collapses plan/govern/direct/look), discover (scan + dispatch),
104
+ // doctor (currency + verify + validate-cves + validate-rfcs), ci (CI gate),
105
+ // ai-run (streaming JSONL), ask (plain-English routing).
103
106
  const PLAYBOOK_VERBS = new Set([
104
- "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations", "attest",
107
+ // v0.11.0 canonical surface:
108
+ "brief", "run", "ai-run", "attest", "discover", "doctor", "ci", "ask",
109
+ "verify-attestation", "run-all", "lint",
110
+ // v0.10.x legacy verbs — kept as aliases with deprecation banner, removed in v0.12+:
111
+ "plan", "govern", "direct", "look", "ingest", "reattest", "list-attestations",
105
112
  ]);
106
113
 
114
+ // Map legacy verb names to their v0.11.0 replacement so the dispatcher can
115
+ // emit a single deprecation banner per session.
116
+ const LEGACY_VERB_REPLACEMENTS = {
117
+ plan: "brief --all",
118
+ govern: "brief <pb> --phase govern",
119
+ direct: "brief <pb> --phase direct",
120
+ look: "brief <pb> --phase look",
121
+ ingest: "run",
122
+ reattest: "attest diff",
123
+ "list-attestations": "attest list",
124
+ scan: "discover --scan-only",
125
+ dispatch: "discover",
126
+ currency: "doctor --currency",
127
+ verify: "doctor --signatures",
128
+ "validate-cves": "doctor --cves",
129
+ "validate-rfcs": "doctor --rfcs",
130
+ watchlist: "watch",
131
+ prefetch: "refresh --no-network",
132
+ "build-indexes": "refresh --indexes-only",
133
+ };
134
+
107
135
  function readPkgVersion() {
108
136
  try {
109
137
  return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
@@ -112,82 +140,133 @@ function readPkgVersion() {
112
140
  }
113
141
  }
114
142
 
143
+ function printWelcome() {
144
+ // v0.11.0 redesign #4 — first-run experience. `exceptd` with no args used to
145
+ // print full help (a wall of text). Now it shows two ways in and where to
146
+ // go from there.
147
+ console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
148
+
149
+ Welcome. Two ways to start:
150
+
151
+ exceptd discover # scan this directory + recommend playbooks
152
+ exceptd ask "<question>" # plain-English routing to a playbook
153
+
154
+ If you know what you want:
155
+
156
+ exceptd brief <playbook> # what does this playbook check?
157
+ exceptd run <playbook> # run it
158
+ exceptd ci --scope code # CI gate against every code-scoped playbook
159
+
160
+ Common starting playbooks
161
+ code repos: secrets, sbom, library-author, crypto-codebase
162
+ Linux hosts: kernel, hardening, runtime, cred-stores
163
+ AI / service: ai-api, mcp, crypto
164
+
165
+ Full reference: exceptd help
166
+ Per-verb help: exceptd <verb> --help
167
+ `);
168
+ }
169
+
115
170
  function printHelp() {
116
171
  console.log(`exceptd — @blamejs/exceptd-skills v${readPkgVersion()}
117
172
 
118
173
  Usage: exceptd <command> [args]
119
174
  npx @blamejs/exceptd-skills <command> [args]
120
175
 
121
- Discovery:
122
- path Print absolute path to the installed package.
123
- Point your AI assistant here:
124
- $(exceptd path)/AGENTS.md
125
- $(exceptd path)/data/_indexes/summary-cards.json
126
-
127
- External data:
128
- prefetch [args] Warm local cache of upstream artifacts
129
- (KEV / NVD / EPSS / IETF / GitHub releases).
130
- Try: exceptd prefetch --no-network --quiet
131
- refresh [args] Refresh against cache + apply upserts.
132
- Try: exceptd refresh --from-cache --swarm
133
-
134
- Build / verify:
135
- build-indexes [args] Regenerate data/_indexes/*.json.
136
- Try: exceptd build-indexes --changed
137
- verify Verify every skill's Ed25519 signature.
138
-
139
- Analyst:
140
- scan Scan environment for findings.
141
- dispatch Scan then route findings to skills.
176
+ v0.11.0 canonical surface
177
+ ─────────────────────────
178
+
179
+ brief [playbook] Unified info doc — jurisdictions + threat context
180
+ + preconditions + artifacts + indicators. Replaces
181
+ plan + govern + direct + look.
182
+ --all every playbook
183
+ --scope <type> system | code | service | cross-cutting
184
+ --directives expand directive metadata
185
+ --phase <name> emit only one phase (legacy compat)
186
+
187
+ run [playbook] Phases 4-7. Auto-detects cwd context when no
188
+ playbook positional.
189
+ --scope <type> | --all | run-all (alias)
190
+ --evidence <file|-> flat or nested submission
191
+ --evidence-dir <dir> per-playbook submission files
192
+ --vex <file> CycloneDX / OpenVEX filter
193
+ --format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary
194
+ --diff-from-latest drift vs prior attestation
195
+ --ci exit-code gate (use \`exceptd ci\` instead)
196
+ --operator <name> bind attestation to identity
197
+ --ack explicit jurisdiction-consent
198
+ --session-id <id> reuse session id (collision refused)
199
+ --force-overwrite override session collision refusal
200
+ --session-key <hex> HMAC sign evidence_package
201
+ --force-stale override threat_currency_score<50 gate
202
+ --air-gap honor air_gap_alternative paths
203
+
204
+ ai-run <playbook> JSONL streaming variant of run. AI emits events
205
+ back on stdin; runner streams phase events on stdout.
206
+ --no-stream single-shot mode
207
+
208
+ attest <subverb> <sid> Auditor-facing operations:
209
+ attest show full attestation
210
+ attest list inventory all sessions
211
+ attest export redacted bundle (--format csaf)
212
+ attest verify Ed25519 signature check
213
+ attest diff drift vs prior or --against <other-sid>
214
+
215
+ discover Scan cwd → recommend playbooks. Replaces scan + dispatch.
216
+
217
+ doctor Health check: signatures + currency + cve catalog
218
+ + rfc catalog + attestation-signing status.
219
+ --signatures | --currency | --cves | --rfcs
220
+
221
+ ci One-shot CI gate. Exits 2 on detected or rwep≥escalate.
222
+ --all | --scope <type> | (auto-detect)
223
+ --max-rwep <n> cap below playbook default
224
+ --block-on-jurisdiction-clock
225
+ --evidence-dir <dir>
226
+
227
+ ask "<question>" Plain-English routing to playbook(s).
228
+
229
+ lint <pb> <evidence> Pre-flight check submission shape vs playbook
230
+ (preconditions / artifacts / indicators) without
231
+ executing phases 4-7.
232
+
233
+ verify-attestation <sid> Alias for \`attest verify\`.
234
+ run-all Alias for \`run --all\`.
235
+
142
236
  skill <name> Show context for a specific skill.
143
- currency Skill currency report.
144
- report [format] Compliance / executive / technical report.
145
- validate-cves [args] Cross-check CVE catalog vs NVD/KEV/EPSS.
146
- Add --from-cache to read from prefetch cache.
147
- validate-rfcs [args] Cross-check RFC catalog vs IETF Datatracker.
148
- watchlist [args] Forward-watch aggregator across skills.
149
-
150
- Playbook runner — seven-phase contract
151
- (govern → direct → look → detect → analyze → validate → close):
152
- plan [--playbook id]... List playbooks + directives, grouped by scope.
153
- [--scope system|code|service|cross-cutting|all]
154
- [--flat] [--mode m] [--session-id id] [--pretty]
155
- govern <playbook> Phase 1: GRC context (jurisdictions, theater,
156
- framework gaps, skill_preload).
157
- [--directive id] [--mode m] [--air-gap]
158
- direct <playbook> Phase 2: scope (threat_context, rwep_threshold,
159
- skill_chain, token_budget).
160
- [--directive id]
161
- look <playbook> Phase 3: artifact-collection spec the host AI
162
- should execute.
163
- [--directive id] [--air-gap]
164
- run [playbook] Phases 4-7: detect → analyze → validate → close.
165
- Three invocation modes:
166
- run <playbook> single playbook (explicit)
167
- run --scope <type> run all playbooks of that scope
168
- run --all run every playbook
169
- run auto-detect from cwd:
170
- .git/ → code
171
- /proc + os-release → system
172
- [--directive id] [--evidence file|-]
173
- [--session-id id] [--session-key hex]
174
- [--force-stale] [--air-gap]
175
- ingest Alias for 'run' matching AGENTS.md terminology.
176
- [--domain id] [--directive id] [--evidence f|-]
177
- reattest <session-id> Re-run prior attestation, diff evidence_hash,
178
- report unchanged | drifted | resolved.
179
-
180
- Output flags (playbook verbs): default JSON one-line; --pretty for indented.
181
-
182
- Common:
183
- help This help.
237
+ framework-gap <fw> <ref> Programmatic gap analysis (one framework, one CVE/scenario).
238
+ path Absolute path to the installed package.
184
239
  version Package version.
185
240
 
241
+ refresh [args] Refresh upstream catalogs + indexes. Replaces
242
+ prefetch + refresh + build-indexes.
243
+
244
+ v0.10.x compatibility (will be removed in v0.12)
245
+ ────────────────────────────────────────────────
246
+
247
+ These verbs still work but emit a one-time deprecation banner. Migrate to
248
+ the v0.11.0 verb shown:
249
+
250
+ plan → brief --all govern → brief <pb> --phase govern
251
+ direct → brief <pb> --phase direct look → brief <pb> --phase look
252
+ ingest → run reattest → attest diff
253
+ list-attestations → attest list scan → discover --scan-only
254
+ dispatch → discover currency → doctor --currency
255
+ verify → doctor --signatures validate-cves → doctor --cves
256
+ validate-rfcs → doctor --rfcs watchlist → watch
257
+ prefetch → refresh --no-network build-indexes → refresh --indexes-only
258
+
259
+ Output: default human-readable (v0.11.0). --json for machine output.
260
+ --pretty for indented JSON.
261
+
186
262
  Examples:
187
- npx @blamejs/exceptd-skills path
188
- npx @blamejs/exceptd-skills prefetch
189
- npx @blamejs/exceptd-skills validate-cves --from-cache --no-fail
190
- npx @blamejs/exceptd-skills skill kernel-lpe-triage
263
+ exceptd discover # what's in this dir?
264
+ exceptd brief secrets --pretty # what does secrets check?
265
+ exceptd run secrets --evidence ev.json --ci # run + CI gate
266
+ exceptd attest list --playbook secrets # prior attestations
267
+ exceptd attest verify <session-id> # tamper check
268
+ exceptd ci --scope code --max-rwep 70 # gate every code playbook
269
+ exceptd ask "I think someone replaced npm packages" # natural-language route
191
270
 
192
271
  Full documentation: ${PKG_ROOT}/README.md
193
272
  Project rules: ${PKG_ROOT}/AGENTS.md
@@ -196,8 +275,27 @@ Project rules: ${PKG_ROOT}/AGENTS.md
196
275
 
197
276
  function main() {
198
277
  const argv = process.argv.slice(2);
278
+
279
+ // --json-stdout-only: silence ALL stderr emissions (deprecation banners,
280
+ // unsigned-attestation warnings, hook output). Operators piping the JSON
281
+ // result through `jq` or scripting around exit codes want clean stdout
282
+ // exclusively. Handled here at top of main so the deprecation banner +
283
+ // unsigned warning are suppressed before they fire.
284
+ if (argv.includes("--json-stdout-only")) {
285
+ process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
286
+ process.env.EXCEPTD_UNSIGNED_WARNED = "1";
287
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
288
+ process.stderr.write = (chunk, encoding, cb) => {
289
+ // Let actual error frames through (uncaught exceptions need to surface
290
+ // for debugging); suppress framework stderr.
291
+ if (typeof chunk === "string" && chunk.startsWith("Error")) return origStderrWrite(chunk, encoding, cb);
292
+ if (typeof cb === "function") cb();
293
+ return true;
294
+ };
295
+ }
296
+
199
297
  if (argv.length === 0) {
200
- printHelp();
298
+ printWelcome();
201
299
  process.exit(0);
202
300
  }
203
301
  const cmd = argv[0];
@@ -219,6 +317,24 @@ function main() {
219
317
  // Seven-phase playbook verbs run in-process — they emit JSON to stdout
220
318
  // rather than dispatch to a script.
221
319
  if (PLAYBOOK_VERBS.has(cmd)) {
320
+ // One-time deprecation banner per process when a legacy verb is invoked.
321
+ if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
322
+ // Mention the installed version explicitly so an operator on v0.10.x
323
+ // who reads "Prefer brief..." doesn't go looking for a verb that
324
+ // doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
325
+ // users see this with the explicit "upgrade to v0.11.0 first" note.
326
+ const ver = readPkgVersion();
327
+ const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
328
+ process.stderr.write(
329
+ `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
330
+ (haveBrief
331
+ ? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
332
+ : `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
333
+ `Legacy verbs remain functional through this release; they will be removed in v0.12. ` +
334
+ `Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
335
+ );
336
+ process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
337
+ }
222
338
  dispatchPlaybook(cmd, rest);
223
339
  return;
224
340
  }
@@ -340,7 +456,9 @@ function dispatchPlaybook(cmd, argv) {
340
456
 
341
457
  const args = parseArgs(argv, {
342
458
  bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
343
- "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack"],
459
+ "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
460
+ "force-overwrite", "no-stream", "block-on-jurisdiction-clock",
461
+ "json-stdout-only"],
344
462
  multi: ["playbook", "format"],
345
463
  });
346
464
  const pretty = !!args.pretty;
@@ -349,8 +467,28 @@ function dispatchPlaybook(cmd, argv) {
349
467
  forceStale: !!args["force-stale"],
350
468
  };
351
469
  if (args["session-id"]) runOpts.session_id = args["session-id"];
352
- if (args["session-key"]) runOpts.session_key = args["session-key"];
353
- if (args.mode) runOpts.mode = args.mode;
470
+ if (args["attestation-root"]) runOpts.attestationRoot = args["attestation-root"];
471
+ if (args["session-key"]) {
472
+ // Bug #33: validate that --session-key is hex. Previously any string was
473
+ // silently accepted; HMAC signing then either failed silently or produced
474
+ // an unverifiable signature.
475
+ if (!/^[0-9a-fA-F]+$/.test(args["session-key"])) {
476
+ return emitError("run: --session-key must be hex characters only (0-9, a-f). Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", { provided_length: args["session-key"].length }, pretty);
477
+ }
478
+ if (args["session-key"].length < 16) {
479
+ return emitError("run: --session-key is too short (need at least 16 hex chars / 64 bits of entropy).", { provided_length: args["session-key"].length }, pretty);
480
+ }
481
+ runOpts.session_key = args["session-key"];
482
+ }
483
+ if (args.mode) {
484
+ // Bug #32: validate --mode against the accepted set. Previously
485
+ // `--mode garbage` was silently accepted.
486
+ const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
487
+ if (!VALID_MODES.includes(args.mode)) {
488
+ return emitError(`run: --mode "${args.mode}" not in accepted set ${JSON.stringify(VALID_MODES)}.`, { provided: args.mode }, pretty);
489
+ }
490
+ runOpts.mode = args.mode;
491
+ }
354
492
  // Multi-operator teams need attestations bound to a specific human or
355
493
  // service identity. --operator <name> persists into the attestation file
356
494
  // for audit-trail accountability. Free-form string; no validation.
@@ -380,6 +518,15 @@ function dispatchPlaybook(cmd, argv) {
380
518
  case "reattest": return cmdReattest(runner, args, runOpts, pretty);
381
519
  case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
382
520
  case "attest": return cmdAttest(runner, args, runOpts, pretty);
521
+ case "brief": return cmdBrief(runner, args, runOpts, pretty);
522
+ case "run-all": return cmdRunAll(runner, args, runOpts, pretty);
523
+ case "verify-attestation": return cmdVerifyAttestation(runner, args, runOpts, pretty);
524
+ case "lint": return cmdLint(runner, args, runOpts, pretty);
525
+ case "discover": return cmdDiscover(runner, args, runOpts, pretty);
526
+ case "doctor": return cmdDoctor(runner, args, runOpts, pretty);
527
+ case "ai-run": return cmdAiRun(runner, args, runOpts, pretty);
528
+ case "ask": return cmdAsk(runner, args, runOpts, pretty);
529
+ case "ci": return cmdCi(runner, args, runOpts, pretty);
383
530
  }
384
531
  } catch (e) {
385
532
  emitError(e.message, { verb: cmd }, pretty);
@@ -517,10 +664,261 @@ Subverbs:
517
664
  Reports tamper status per attestation file.
518
665
 
519
666
  All subverbs honor --pretty for indented JSON output.`,
667
+ discover: `discover — context-aware playbook recommender (v0.11.0).
668
+
669
+ Replaces: scan + dispatch + recommend.
670
+
671
+ Sniffs the cwd (.git/, package.json, pyproject.toml, requirements.txt,
672
+ Cargo.toml, go.mod, Dockerfile, docker-compose.yml, *.tf, k8s/, .env) and
673
+ on Linux reads /etc/os-release to detect host distro. Emits a list of
674
+ recommended exceptd playbooks tailored to what was found.
675
+
676
+ Flags:
677
+ --scan-only Also include legacy \`scan\` output under legacy_scan.
678
+ --json Emit JSON (default is human-readable text).
679
+ --pretty Indented JSON output (implies --json).
680
+
681
+ Output: context + recommended_playbooks[] + next_steps[].`,
682
+ doctor: `doctor — one-shot health check (v0.11.0).
683
+
684
+ Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
685
+
686
+ Subchecks:
687
+ --signatures Ed25519 signature verification across all skills.
688
+ --currency Skill currency report (last_threat_review).
689
+ --cves CVE catalog validation (offline view).
690
+ --rfcs RFC catalog validation (offline view).
691
+ (no flag) All four, plus signing-status (private key presence).
692
+
693
+ Flags:
694
+ --json Emit JSON (default is human-readable text).
695
+ --pretty Indented JSON output (implies --json).
696
+
697
+ Output: checks{} per subcheck + summary{all_green, issues_count}.`,
698
+ "ai-run": `ai-run <playbook> — streaming JSONL contract for AI-driven runs (v0.11.0).
699
+
700
+ Emits one JSON event per line as the seven phases progress, and reads
701
+ evidence events back on stdin. Single pipe instead of brief → look → run.
702
+
703
+ Flags:
704
+ <playbook> Required positional.
705
+ --directive <id> Specific directive (default: first one).
706
+ --no-stream Single-shot mode: emit all phases as one JSON doc
707
+ without reading stdin (uses runner.run directly).
708
+ --pretty Indented JSON output (single-shot only).
709
+
710
+ Stdin event grammar (one JSON object per line):
711
+ {"event":"evidence","payload":{"observations":{},"verdict":{}}}
712
+
713
+ Emits phases: govern → direct → look → await_evidence → detect → analyze
714
+ → validate → close, then {"event":"done","ok":true,"session_id":"..."}.
715
+ Errors emit {"event":"error","reason":"..."} and exit non-zero.`,
716
+ ask: `ask "<plain-English question>" — keyword routing to playbooks (v0.11.0).
717
+
718
+ Tokenises the question (words > 3 chars), scores every playbook by overlap
719
+ against domain.name + domain.attack_class + the first sentence of
720
+ phases.direct.threat_context, returns the top 5 matches with a confidence
721
+ score.
722
+
723
+ Args / flags:
724
+ "<question>" Plain-English question. Wrap in quotes.
725
+ --pretty Indented JSON output.
726
+
727
+ Output: { verb, question, routed_to:[ids], confidence, next_step,
728
+ full_match_list }. Empty match list when no token overlap — surfaces a
729
+ hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.`,
730
+ ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
731
+
732
+ Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
733
+ exit-code contract designed for one-line .github/workflows entries.
734
+
735
+ Flags:
736
+ --all Run every playbook.
737
+ --scope <type> Filter: system | code | service | cross-cutting.
738
+ (no flag) Auto-detect scopes from cwd (same logic as run).
739
+ --evidence <file> Submission bundle (multi-playbook shape).
740
+ --evidence-dir <dir> Read <playbook-id>.json files from a directory.
741
+ --max-rwep <int> Override RWEP escalate threshold (default: per-playbook).
742
+ --block-on-jurisdiction-clock
743
+ Fail when any close.notification_actions started a
744
+ regulatory clock (GDPR 72h, HIPAA breach, etc.).
745
+ --pretty Indented JSON output.
746
+
747
+ Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
748
+ Output: verb, session_id, playbooks_run, summary{total, detected,
749
+ max_rwep_observed, jurisdiction_clocks_started, verdict}, results[].`,
520
750
  };
521
751
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
522
752
  }
523
753
 
754
+ /**
755
+ * `brief` — collapses plan + govern + direct + look into one informational
756
+ * document. Phases 1-3 of the seven-phase contract are entirely informational
757
+ * (no state mutation), so the AI reads ONE document instead of three CLI
758
+ * round-trips.
759
+ *
760
+ * Modes:
761
+ * brief <playbook> → one playbook, all three info phases unified
762
+ * brief --all → every playbook (replaces `plan`)
763
+ * brief <playbook> --phase <name>
764
+ * → emit only the named phase (compat with
765
+ * legacy `govern`/`direct`/`look` callers)
766
+ */
767
+ /**
768
+ * `lint <playbook> <evidence-file>` — pre-flight check the submission shape
769
+ * against the playbook's expected indicators / preconditions / artifacts
770
+ * WITHOUT executing detect/analyze/validate/close. Lets the AI iterate on
771
+ * its evidence JSON before going through phases 4-7. Returns a categorized
772
+ * list: ok / missing_required / unknown_keys / type_mismatch / suggestions.
773
+ */
774
+ function cmdLint(runner, args, runOpts, pretty) {
775
+ const playbookId = args._[0];
776
+ const evidencePath = args._[1] || args.evidence;
777
+ if (!playbookId || !evidencePath) {
778
+ return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
779
+ }
780
+ let pb;
781
+ try { pb = runner.loadPlaybook(playbookId); }
782
+ catch (e) { return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty); }
783
+
784
+ let submission;
785
+ try { submission = readEvidence(evidencePath); }
786
+ catch (e) { return emitError(`lint: failed to read evidence: ${e.message}`, { evidence: evidencePath }, pretty); }
787
+
788
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
789
+ const resolved = runner._resolvedPhase;
790
+ const lookPhase = pb.phases?.look || {};
791
+ const detectPhase = pb.phases?.detect || {};
792
+
793
+ const requiredArtifacts = (lookPhase.artifacts || []).filter(a => a.required).map(a => a.id);
794
+ const knownArtifacts = new Set((lookPhase.artifacts || []).map(a => a.id));
795
+ const knownIndicators = new Set((detectPhase.indicators || []).map(i => i.id));
796
+ const knownPreconditions = new Set((pb._meta?.preconditions || []).map(p => p.id));
797
+
798
+ // Support both flat (observations) and nested (artifacts/signal_overrides) shapes.
799
+ const flat = submission.observations || null;
800
+ const artifactsKey = flat ? flat : (submission.artifacts || {});
801
+ const signalsKey = flat ? flat : (submission.signal_overrides || {});
802
+
803
+ const missingRequired = requiredArtifacts.filter(id => {
804
+ const a = artifactsKey[id];
805
+ return !a || (flat ? !a.captured : !a.captured);
806
+ });
807
+
808
+ const unknownArtifactKeys = Object.keys(submission.artifacts || {})
809
+ .filter(k => !knownArtifacts.has(k));
810
+ const unknownSignalKeys = Object.keys(submission.signal_overrides || {})
811
+ .filter(k => !knownIndicators.has(k));
812
+ const unknownObservationKeys = flat
813
+ ? Object.keys(flat).filter(k => !knownArtifacts.has(k) && !knownIndicators.has(k) && !knownPreconditions.has(k))
814
+ : [];
815
+
816
+ const unsuppliedPreconditions = [...knownPreconditions].filter(
817
+ p => !((submission.precondition_checks || {}).hasOwnProperty(p) || (flat || {}).hasOwnProperty(p))
818
+ );
819
+
820
+ const issues = [];
821
+ for (const id of missingRequired) {
822
+ issues.push({ severity: "error", kind: "missing_required_artifact", artifact_id: id, hint: `Add to submission.artifacts.${id} = { value, captured: true } (or under observations in the flat shape).` });
823
+ }
824
+ for (const k of unknownArtifactKeys) {
825
+ issues.push({ severity: "warn", kind: "unknown_artifact_key", key: k, hint: `Not in playbook ${playbookId} look.artifacts[]. Recognized: ${[...knownArtifacts].slice(0, 10).join(", ")}…` });
826
+ }
827
+ for (const k of unknownSignalKeys) {
828
+ issues.push({ severity: "warn", kind: "unknown_signal_override_key", key: k, hint: `Not in playbook ${playbookId} detect.indicators[]. Run \`exceptd run ${playbookId} --signal-list\` to enumerate.` });
829
+ }
830
+ for (const p of unsuppliedPreconditions) {
831
+ issues.push({ severity: "info", kind: "precondition_unverified", precondition_id: p, hint: `Add submission.precondition_checks.${p} = true|false (or under observations in the flat shape).` });
832
+ }
833
+ for (const k of unknownObservationKeys) {
834
+ issues.push({ severity: "warn", kind: "unknown_observation_key", key: k });
835
+ }
836
+
837
+ const ok = issues.every(i => i.severity !== "error");
838
+ emit({
839
+ verb: "lint",
840
+ ok,
841
+ playbook_id: playbookId,
842
+ directive_id: directiveId,
843
+ submission_shape: flat ? "flat (v0.11.0)" : "nested (v0.10.x)",
844
+ summary: {
845
+ errors: issues.filter(i => i.severity === "error").length,
846
+ warnings: issues.filter(i => i.severity === "warn").length,
847
+ info: issues.filter(i => i.severity === "info").length,
848
+ },
849
+ issues,
850
+ }, pretty);
851
+ if (!ok) process.exitCode = 1;
852
+ }
853
+
854
+ function cmdBrief(runner, args, runOpts, pretty) {
855
+ const playbookId = args._[0];
856
+ const onlyPhase = args.phase || null;
857
+
858
+ if (!playbookId || args.all) {
859
+ // Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
860
+ return cmdPlan(runner, args, runOpts, pretty);
861
+ }
862
+
863
+ const pb = runner.loadPlaybook(playbookId);
864
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
865
+
866
+ const govern = runner.govern(playbookId, directiveId, runOpts);
867
+ const direct = runner.direct(playbookId, directiveId);
868
+ const look = runner.look(playbookId, directiveId, runOpts);
869
+
870
+ // If --phase was passed, emit only that phase to ease legacy migration.
871
+ if (onlyPhase === "govern") return emit(govern, pretty);
872
+ if (onlyPhase === "direct") return emit(direct, pretty);
873
+ if (onlyPhase === "look") return emit(look, pretty);
874
+
875
+ emit({
876
+ verb: "brief",
877
+ playbook_id: playbookId,
878
+ directive_id: directiveId,
879
+ scope: pb._meta?.scope || null,
880
+ threat_currency_score: pb._meta?.threat_currency_score,
881
+
882
+ // From govern phase:
883
+ jurisdiction_obligations: govern.jurisdiction_obligations,
884
+ theater_fingerprints: govern.theater_fingerprints,
885
+ framework_context: govern.framework_context,
886
+ skill_preload: govern.skill_preload,
887
+
888
+ // From direct phase:
889
+ threat_context: direct.threat_context,
890
+ rwep_threshold: direct.rwep_threshold,
891
+ framework_lag_declaration: direct.framework_lag_declaration,
892
+ skill_chain: direct.skill_chain,
893
+ token_budget: direct.token_budget,
894
+
895
+ // From look phase:
896
+ preconditions: look.preconditions,
897
+ precondition_submission_shape: look.precondition_submission_shape,
898
+ artifacts: look.artifacts,
899
+ collection_scope: look.collection_scope,
900
+ environment_assumptions: look.environment_assumptions,
901
+ fallback_if_unavailable: look.fallback_if_unavailable,
902
+
903
+ // Forward references — what the AI will see during run:
904
+ detect_indicators_preview: (pb.phases?.detect?.indicators || []).map(i => ({
905
+ id: i.id, type: i.type, confidence: i.confidence, deterministic: !!i.deterministic
906
+ })),
907
+ }, pretty);
908
+ }
909
+
910
+ /** `run-all` alias for `run --all`. */
911
+ function cmdRunAll(runner, args, runOpts, pretty) {
912
+ args.all = true;
913
+ return cmdRun(runner, args, runOpts, pretty);
914
+ }
915
+
916
+ /** `verify-attestation <sid>` alias for `attest verify <sid>`. */
917
+ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
918
+ args._ = ["verify", ...(args._ || [])];
919
+ return cmdAttest(runner, args, runOpts, pretty);
920
+ }
921
+
524
922
  function cmdPlan(runner, args, runOpts, pretty) {
525
923
  let playbookIds = args.playbook
526
924
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
@@ -711,6 +1109,13 @@ function cmdRun(runner, args, runOpts, pretty) {
711
1109
  }
712
1110
 
713
1111
  let submission = {};
1112
+ // v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
1113
+ // something is piping into us). If no --evidence flag and stdin is a pipe,
1114
+ // assume `--evidence -`. Operators forgetting the flag previously got a
1115
+ // confusing precondition halt; now the common case "just works."
1116
+ if (!args.evidence && process.stdin.isTTY === false) {
1117
+ args.evidence = "-";
1118
+ }
714
1119
  if (args.evidence) {
715
1120
  try {
716
1121
  submission = readEvidence(args.evidence);
@@ -751,27 +1156,41 @@ function cmdRun(runner, args, runOpts, pretty) {
751
1156
 
752
1157
  // Persist attestation for reattest cycles when the run succeeded.
753
1158
  if (result && result.ok && result.session_id) {
754
- try {
755
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
756
- fs.mkdirSync(dir, { recursive: true });
757
- const attestation = {
758
- session_id: result.session_id,
759
- playbook_id: result.playbook_id,
760
- directive_id: result.directive_id,
761
- evidence_hash: result.evidence_hash,
762
- operator: runOpts.operator || null,
763
- operator_consent: runOpts.operator_consent || null,
764
- submission,
765
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
766
- captured_at: new Date().toISOString(),
1159
+ const persistResult = persistAttestation({
1160
+ sessionId: result.session_id,
1161
+ playbookId: result.playbook_id,
1162
+ directiveId: result.directive_id,
1163
+ evidenceHash: result.evidence_hash,
1164
+ operator: runOpts.operator,
1165
+ operatorConsent: runOpts.operator_consent,
1166
+ submission,
1167
+ runOpts,
1168
+ forceOverwrite: !!args["force-overwrite"],
1169
+ filename: "attestation.json",
1170
+ });
1171
+ if (!persistResult.ok) {
1172
+ // Session-id collision without --force-overwrite. Refuse, surface the
1173
+ // existing path so the operator can decide, and emit JSON to stderr
1174
+ // matching the unified error shape. Exit non-zero — a silent overwrite
1175
+ // is a tamper-evidence violation.
1176
+ const err = {
1177
+ ok: false,
1178
+ error: persistResult.error,
1179
+ existing_attestation: persistResult.existingPath,
1180
+ hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
1181
+ verb: "run",
767
1182
  };
768
- fs.writeFileSync(path.join(dir, "attestation.json"), JSON.stringify(attestation, null, 2));
769
- // Feature #27: Ed25519-sign the attestation if .keys/private.pem exists
770
- // (signing infrastructure is already shared with skill signing). Without
771
- // a private key, write an unsigned `.sig` placeholder so tooling can tell
772
- // the difference between "unsigned" and "tampered post-hoc".
773
- maybeSignAttestation(path.join(dir, "attestation.json"));
774
- } catch { /* non-fatal attestation persistence is best-effort */ }
1183
+ process.stderr.write(JSON.stringify(err) + "\n");
1184
+ process.exit(3);
1185
+ }
1186
+ if (persistResult.prior_session_id) {
1187
+ // Force-overwrite happened surface the prior_session_id in the
1188
+ // returned result so the operator/AI can see what the new attestation
1189
+ // replaced and link back via the prior_session_id field persisted on
1190
+ // disk.
1191
+ result.prior_session_id = persistResult.prior_session_id;
1192
+ result.overwrote_at = persistResult.overwrote_at;
1193
+ }
775
1194
  }
776
1195
 
777
1196
  if (result && result.ok === false) {
@@ -899,23 +1318,26 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
899
1318
 
900
1319
  // Persist per-playbook attestation under the shared session.
901
1320
  if (result && result.ok) {
902
- try {
903
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
904
- fs.mkdirSync(dir, { recursive: true });
905
- const filePath = path.join(dir, `${id}.json`);
906
- fs.writeFileSync(filePath, JSON.stringify({
907
- session_id: sessionId,
908
- playbook_id: id,
909
- directive_id: directiveId,
910
- evidence_hash: result.evidence_hash,
911
- operator: perRunOpts.operator || null,
912
- operator_consent: perRunOpts.operator_consent || null,
913
- submission,
914
- run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
915
- captured_at: new Date().toISOString(),
916
- }, null, 2));
917
- maybeSignAttestation(filePath);
918
- } catch { /* non-fatal */ }
1321
+ const persisted = persistAttestation({
1322
+ sessionId,
1323
+ playbookId: id,
1324
+ directiveId,
1325
+ evidenceHash: result.evidence_hash,
1326
+ operator: perRunOpts.operator,
1327
+ operatorConsent: perRunOpts.operator_consent,
1328
+ submission,
1329
+ runOpts: perRunOpts,
1330
+ forceOverwrite: !!args["force-overwrite"],
1331
+ filename: `${id}.json`,
1332
+ });
1333
+ if (!persisted.ok) {
1334
+ // Multi-run collision: don't abort the whole bundle; surface in the
1335
+ // per-playbook result so the operator can see exactly which
1336
+ // playbook's attestation refused to overwrite.
1337
+ result.attestation_persist = { ok: false, error: persisted.error };
1338
+ } else if (persisted.prior_session_id) {
1339
+ result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
1340
+ }
919
1341
  }
920
1342
  results.push(result);
921
1343
  }
@@ -971,7 +1393,7 @@ function cmdIngest(runner, args, runOpts, pretty) {
971
1393
 
972
1394
  if (result && result.ok && result.session_id) {
973
1395
  try {
974
- const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
1396
+ const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
975
1397
  fs.mkdirSync(dir, { recursive: true });
976
1398
  fs.writeFileSync(
977
1399
  path.join(dir, "attestation.json"),
@@ -995,6 +1417,113 @@ function cmdIngest(runner, args, runOpts, pretty) {
995
1417
  emit(result, pretty);
996
1418
  }
997
1419
 
1420
+ /**
1421
+ * Resolve the attestation root for a given run. Resolution order (most-specific
1422
+ * first):
1423
+ * 1. --attestation-root <path> explicit caller override
1424
+ * 2. EXCEPTD_HOME env var operator-level configuration
1425
+ * 3. ~/.exceptd/attestations/<repo-or-host-tag>/ default (v0.11.0+)
1426
+ * 4. .exceptd/attestations/ legacy cwd-relative fallback when ~/.exceptd
1427
+ * can't be created (read-only home / sandbox)
1428
+ *
1429
+ * Repo tag is derived from `git config --get remote.origin.url` + branch when
1430
+ * available, else a hostname tag. This means `attest list` works regardless of
1431
+ * which directory you happened to run from. Operators can override via env.
1432
+ */
1433
+ function resolveAttestationRoot(runOpts) {
1434
+ if (runOpts && runOpts.attestationRoot) return runOpts.attestationRoot;
1435
+ if (process.env.EXCEPTD_HOME) return path.join(process.env.EXCEPTD_HOME, "attestations");
1436
+ const home = require("os").homedir();
1437
+ if (!home) return path.join(process.cwd(), ".exceptd", "attestations");
1438
+ const root = path.join(home, ".exceptd", "attestations", deriveRunTag());
1439
+ try {
1440
+ fs.mkdirSync(root, { recursive: true });
1441
+ return root;
1442
+ } catch {
1443
+ return path.join(process.cwd(), ".exceptd", "attestations");
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Derive a stable tag for attestations: `<repo-name>@<branch>` when in a git
1449
+ * repo, else `host:<hostname>`. Used as the per-context directory under
1450
+ * ~/.exceptd/attestations/ so multi-repo operators don't conflate sessions.
1451
+ */
1452
+ function deriveRunTag() {
1453
+ const { spawnSync } = require("child_process");
1454
+ try {
1455
+ const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" });
1456
+ if (remote.status === 0 && remote.stdout.trim()) {
1457
+ const url = remote.stdout.trim();
1458
+ const repoName = (url.match(/[\/:]([^/]+?)(?:\.git)?$/) || [, "unknown"])[1];
1459
+ const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
1460
+ const branchName = branch.status === 0 ? branch.stdout.trim() : "head";
1461
+ return `${repoName}@${branchName}`.replace(/[^A-Za-z0-9._@-]/g, "_");
1462
+ }
1463
+ } catch {}
1464
+ return `host:${require("os").hostname()}`.replace(/[^A-Za-z0-9._@:-]/g, "_");
1465
+ }
1466
+
1467
+ /**
1468
+ * Persist an attestation file. Refuses to overwrite an existing file unless
1469
+ * `forceOverwrite` is true. When force-overwriting, the new attestation
1470
+ * records `prior_session_id` (== current session_id; the prior content is
1471
+ * what's being replaced) plus a `prior_evidence_hash` link extracted from
1472
+ * the file on disk before clobbering — so the audit-trail chain survives.
1473
+ *
1474
+ * Returns { ok: true, prior_session_id?, overwrote_at?, persist_path } on
1475
+ * success; or { ok: false, error, existingPath } when the operator hit a
1476
+ * collision without --force-overwrite.
1477
+ */
1478
+ function persistAttestation(args) {
1479
+ const { sessionId, playbookId, directiveId, evidenceHash, operator,
1480
+ operatorConsent, submission, runOpts, forceOverwrite, filename } = args;
1481
+ const root = resolveAttestationRoot(runOpts);
1482
+ const dir = path.join(root, sessionId);
1483
+ const filePath = path.join(dir, filename);
1484
+
1485
+ let prior = null;
1486
+ if (fs.existsSync(filePath)) {
1487
+ try { prior = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
1488
+ if (!forceOverwrite) {
1489
+ return {
1490
+ ok: false,
1491
+ error: `Attestation already exists at ${path.relative(process.cwd(), filePath)}. Session-id collision (${sessionId}) — refusing to overwrite to preserve audit trail.`,
1492
+ existingPath: path.relative(process.cwd(), filePath),
1493
+ };
1494
+ }
1495
+ }
1496
+
1497
+ try {
1498
+ fs.mkdirSync(dir, { recursive: true });
1499
+ const attestation = {
1500
+ session_id: sessionId,
1501
+ playbook_id: playbookId,
1502
+ directive_id: directiveId,
1503
+ evidence_hash: evidenceHash,
1504
+ operator: operator || null,
1505
+ operator_consent: operatorConsent || null,
1506
+ submission,
1507
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
1508
+ captured_at: new Date().toISOString(),
1509
+ // When overwriting (with --force-overwrite), link to the prior content
1510
+ // by evidence_hash + capture timestamp. session_id is the same (that's
1511
+ // why we collided), so it's the hash + timestamp that distinguish.
1512
+ prior_evidence_hash: prior ? (prior.evidence_hash || null) : null,
1513
+ prior_captured_at: prior ? (prior.captured_at || null) : null,
1514
+ };
1515
+ fs.writeFileSync(filePath, JSON.stringify(attestation, null, 2));
1516
+ maybeSignAttestation(filePath);
1517
+ return {
1518
+ ok: true,
1519
+ prior_session_id: prior ? sessionId : null,
1520
+ overwrote_at: prior ? prior.captured_at : null,
1521
+ };
1522
+ } catch (e) {
1523
+ return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
1524
+ }
1525
+ }
1526
+
998
1527
  /**
999
1528
  * Ed25519-sign an attestation file when .keys/private.pem is available
1000
1529
  * (matches lib/sign.js convention for skill signing). Writes a sidecar
@@ -1010,6 +1539,19 @@ function maybeSignAttestation(filePath) {
1010
1539
  const sigPath = filePath + ".sig";
1011
1540
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
1012
1541
  const content = fs.readFileSync(filePath, "utf8");
1542
+ // One-time-per-process unsigned warning so cron jobs don't spam stderr.
1543
+ // Operators who set `.keys/private.pem` get tamper-evident attestations;
1544
+ // operators without the keypair get a single nudge per session telling them
1545
+ // exactly how to enable signing.
1546
+ if (!fs.existsSync(privKeyPath) && !process.env.EXCEPTD_UNSIGNED_WARNED) {
1547
+ process.stderr.write(
1548
+ "[attest] attestation will be written UNSIGNED (no private key at .keys/private.pem). " +
1549
+ "Operators reading the attestation later can verify the SHA-256 hash but not authenticity. " +
1550
+ "Enable Ed25519 signing: `node lib/sign.js generate-keypair`. " +
1551
+ "Suppress this notice: export EXCEPTD_UNSIGNED_WARNED=1.\n"
1552
+ );
1553
+ process.env.EXCEPTD_UNSIGNED_WARNED = "1";
1554
+ }
1013
1555
  try {
1014
1556
  if (fs.existsSync(privKeyPath)) {
1015
1557
  const privateKey = fs.readFileSync(privKeyPath, "utf8");
@@ -1037,21 +1579,49 @@ function maybeSignAttestation(filePath) {
1037
1579
  } catch { /* non-fatal — signing failure shouldn't block the run */ }
1038
1580
  }
1039
1581
 
1582
+ /**
1583
+ * Resolve a session-id to its on-disk directory. Searches both the v0.11.0
1584
+ * default root and the legacy cwd-relative root; returns whichever exists.
1585
+ * Returns null if neither has the session.
1586
+ */
1587
+ function findSessionDir(sessionId, runOpts) {
1588
+ const candidates = [
1589
+ path.join(resolveAttestationRoot(runOpts), sessionId),
1590
+ path.join(process.cwd(), ".exceptd", "attestations", sessionId),
1591
+ ];
1592
+ for (const c of candidates) if (fs.existsSync(c)) return c;
1593
+ return null;
1594
+ }
1595
+
1040
1596
  /**
1041
1597
  * Find the latest attestation file under .exceptd/attestations/.
1042
1598
  * Filters: optional playbook ID and optional "since" ISO timestamp.
1043
1599
  * Returns { sessionId, playbookId, file, parsed } or null.
1044
1600
  */
1045
1601
  function findLatestAttestation(opts = {}) {
1046
- const root = path.join(process.cwd(), ".exceptd", "attestations");
1047
- if (!fs.existsSync(root)) return null;
1602
+ // Search both the v0.11.0 default root (~/.exceptd/) and the legacy cwd-
1603
+ // relative root so operators with prior attestations don't lose their
1604
+ // history when the default moved.
1605
+ const roots = [resolveAttestationRoot(opts), path.join(process.cwd(), ".exceptd", "attestations")];
1606
+ const seen = new Set();
1607
+ const candidates = [];
1608
+ for (const root of roots) {
1609
+ if (seen.has(root) || !fs.existsSync(root)) continue;
1610
+ seen.add(root);
1611
+ walkAttestationDir(root, opts, candidates);
1612
+ }
1613
+ candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
1614
+ return candidates[0] || null;
1615
+ }
1616
+
1617
+ function walkAttestationDir(root, opts, candidates) {
1618
+ if (!fs.existsSync(root)) return;
1048
1619
  const sessions = fs.readdirSync(root, { withFileTypes: true })
1049
1620
  .filter(d => d.isDirectory())
1050
1621
  .map(d => d.name);
1051
- const candidates = [];
1052
1622
  for (const sid of sessions) {
1053
1623
  const sdir = path.join(root, sid);
1054
- for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
1624
+ for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json") && !x.endsWith(".sig"))) {
1055
1625
  try {
1056
1626
  const p = path.join(sdir, f);
1057
1627
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -1062,8 +1632,6 @@ function findLatestAttestation(opts = {}) {
1062
1632
  } catch { /* skip malformed */ }
1063
1633
  }
1064
1634
  }
1065
- candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
1066
- return candidates[0] || null;
1067
1635
  }
1068
1636
 
1069
1637
  function cmdReattest(runner, args, runOpts, pretty) {
@@ -1081,10 +1649,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
1081
1649
  attFile = found.file;
1082
1650
  }
1083
1651
  if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
1084
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1652
+ const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
1085
1653
  if (!attFile) attFile = path.join(dir, "attestation.json");
1086
1654
  if (!fs.existsSync(attFile)) {
1087
- return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
1655
+ return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
1088
1656
  }
1089
1657
  let prior;
1090
1658
  try {
@@ -1170,14 +1738,18 @@ function cmdAttest(runner, args, runOpts, pretty) {
1170
1738
  const subverb = args._[0];
1171
1739
  const sessionId = args._[1];
1172
1740
  if (!subverb) {
1173
- return emitError("attest: missing subverb. Usage: attest export|verify|show <session-id>", null, pretty);
1741
+ return emitError("attest: missing subverb. Usage: attest list | show <sid> | export <sid> | verify <sid> | diff <sid>", null, pretty);
1742
+ }
1743
+ // `list` doesn't require a session-id positional.
1744
+ if (subverb === "list") {
1745
+ return cmdListAttestations(runner, args, runOpts, pretty);
1174
1746
  }
1175
1747
  if (!sessionId) {
1176
1748
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
1177
1749
  }
1178
- const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1179
- if (!fs.existsSync(dir)) {
1180
- return emitError(`attest ${subverb}: no session dir at ${path.relative(process.cwd(), dir)}`, { session_id: sessionId }, pretty);
1750
+ const dir = findSessionDir(sessionId, runOpts);
1751
+ if (!dir) {
1752
+ return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
1181
1753
  }
1182
1754
 
1183
1755
  const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
@@ -1191,6 +1763,46 @@ function cmdAttest(runner, args, runOpts, pretty) {
1191
1763
  return;
1192
1764
  }
1193
1765
 
1766
+ if (subverb === "diff") {
1767
+ // `attest diff <session-id> [--against <other-session-id>]` — drift
1768
+ // comparison. Without --against, replays current state against prior
1769
+ // session (= reattest). With --against, compares two sessions A vs B
1770
+ // by evidence_hash + artifact-level field diff.
1771
+ if (args.against) {
1772
+ const otherDir = findSessionDir(args.against, runOpts);
1773
+ if (!otherDir) {
1774
+ return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
1775
+ }
1776
+ const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
1777
+ if (otherFiles.length === 0) {
1778
+ return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
1779
+ }
1780
+ const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
1781
+ const self = attestations[0];
1782
+ emit({
1783
+ verb: "attest diff",
1784
+ a_session: sessionId,
1785
+ b_session: args.against,
1786
+ a_captured: self.captured_at,
1787
+ b_captured: other.captured_at,
1788
+ a_evidence_hash: self.evidence_hash,
1789
+ b_evidence_hash: other.evidence_hash,
1790
+ status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
1791
+ artifact_diff: diffArtifacts((self.submission || {}).artifacts, (other.submission || {}).artifacts),
1792
+ signal_override_diff: diffSignalOverrides((self.submission || {}).signal_overrides, (other.submission || {}).signal_overrides),
1793
+ }, pretty);
1794
+ return;
1795
+ }
1796
+ // Fall through to reattest-style replay below by setting subverb to a
1797
+ // sentinel and re-dispatching via cmdReattest.
1798
+ args._ = [sessionId];
1799
+ return cmdReattest(runner, args, {}, pretty);
1800
+ }
1801
+
1802
+ if (subverb === "list") {
1803
+ return cmdListAttestations(runner, args, {}, pretty);
1804
+ }
1805
+
1194
1806
  if (subverb === "verify") {
1195
1807
  const crypto = require("crypto");
1196
1808
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
@@ -1267,33 +1879,483 @@ function cmdAttest(runner, args, runOpts, pretty) {
1267
1879
  return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
1268
1880
  }
1269
1881
 
1270
- function cmdListAttestations(runner, args, runOpts, pretty) {
1271
- const root = path.join(process.cwd(), ".exceptd", "attestations");
1272
- if (!fs.existsSync(root)) {
1273
- return emit({ ok: true, attestations: [], note: `No attestations directory at ${path.relative(process.cwd(), root)}` }, pretty);
1882
+ /**
1883
+ * Per-artifact diff between two submissions. Returns { added, removed, changed }
1884
+ * keyed by artifact id. Used by `attest diff` (bug #34 fix) so operators get
1885
+ * field-level context instead of a binary evidence_hash signal.
1886
+ */
1887
+ function diffArtifacts(a, b) {
1888
+ a = a || {}; b = b || {};
1889
+ const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
1890
+ const out = { added: [], removed: [], changed: [], unchanged_count: 0 };
1891
+ for (const id of allIds) {
1892
+ const av = a[id], bv = b[id];
1893
+ if (!av && bv) out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
1894
+ else if (av && !bv) out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
1895
+ else if (JSON.stringify(av) !== JSON.stringify(bv)) {
1896
+ out.changed.push({
1897
+ id,
1898
+ a_captured: !!av.captured, b_captured: !!bv.captured,
1899
+ a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
1900
+ });
1901
+ } else { out.unchanged_count++; }
1274
1902
  }
1275
- const sessions = fs.readdirSync(root, { withFileTypes: true })
1276
- .filter(d => d.isDirectory())
1277
- .map(d => d.name);
1903
+ return out;
1904
+ }
1905
+
1906
+ function diffSignalOverrides(a, b) {
1907
+ a = a || {}; b = b || {};
1908
+ const allIds = new Set([...Object.keys(a), ...Object.keys(b)]);
1909
+ const out = { changed: [], unchanged_count: 0 };
1910
+ for (const id of allIds) {
1911
+ if (a[id] !== b[id]) out.changed.push({ id, a: a[id] || null, b: b[id] || null });
1912
+ else out.unchanged_count++;
1913
+ }
1914
+ return out;
1915
+ }
1916
+
1917
+ function previewValue(v) {
1918
+ if (v === null || v === undefined) return null;
1919
+ const s = typeof v === "string" ? v : JSON.stringify(v);
1920
+ return s.length > 80 ? s.slice(0, 80) + "…" : s;
1921
+ }
1922
+
1923
+ // ---------------------------------------------------------------------------
1924
+ // v0.11.0: cmdDiscover — context-aware playbook recommender.
1925
+ // Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
1926
+ // /etc/os-release on Linux, and outputs a list of recommended playbooks.
1927
+ // ---------------------------------------------------------------------------
1928
+ function cmdDiscover(runner, args, runOpts, pretty) {
1929
+ const cwd = process.cwd();
1930
+ const wantJson = !!args.json || !!args.pretty;
1931
+ const indent = !!args.pretty;
1932
+
1933
+ // File-presence sniffer. Each probe is independently fault-tolerant so a
1934
+ // permission error on one path can't poison the whole detection.
1935
+ const detected = [];
1936
+ function probe(rel, label) {
1937
+ try {
1938
+ if (fs.existsSync(path.join(cwd, rel))) detected.push(label || rel);
1939
+ } catch { /* swallow */ }
1940
+ }
1941
+ probe(".git", ".git/");
1942
+ probe("package.json");
1943
+ probe("package-lock.json");
1944
+ probe("yarn.lock");
1945
+ probe("pnpm-lock.yaml");
1946
+ probe("pyproject.toml");
1947
+ probe("requirements.txt");
1948
+ probe("Pipfile");
1949
+ probe("Cargo.toml");
1950
+ probe("go.mod");
1951
+ probe("Dockerfile");
1952
+ probe("docker-compose.yml");
1953
+ probe("docker-compose.yaml");
1954
+ probe("kustomization.yaml");
1955
+ probe("k8s", "k8s/");
1956
+ probe(".env");
1957
+ probe(".envrc");
1958
+
1959
+ // Terraform / IaC — glob the top level for *.tf.
1960
+ try {
1961
+ const tfFiles = fs.readdirSync(cwd).filter(f => f.endsWith(".tf"));
1962
+ if (tfFiles.length) detected.push(`*.tf (${tfFiles.length})`);
1963
+ } catch { /* swallow */ }
1964
+
1965
+ // Git remote (best-effort, never fatal).
1966
+ let gitRemote = null;
1967
+ if (detected.includes(".git/")) {
1968
+ try {
1969
+ const headPath = path.join(cwd, ".git", "config");
1970
+ if (fs.existsSync(headPath)) {
1971
+ const cfg = fs.readFileSync(headPath, "utf8");
1972
+ const m = cfg.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(\S+)/);
1973
+ if (m) gitRemote = m[1];
1974
+ }
1975
+ } catch { /* swallow */ }
1976
+ }
1977
+
1978
+ // Host platform / distro.
1979
+ const hostPlatform = process.platform;
1980
+ let hostDistro = null;
1981
+ if (hostPlatform === "linux") {
1982
+ try {
1983
+ const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
1984
+ if (res.status === 0 && res.stdout) {
1985
+ const idMatch = res.stdout.match(/^ID=(.+)$/m);
1986
+ const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
1987
+ const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
1988
+ hostDistro = {
1989
+ id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
1990
+ version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
1991
+ pretty_name: prettyMatch ? prettyMatch[1].replace(/^"|"$/g, "") : null,
1992
+ };
1993
+ }
1994
+ } catch { /* swallow */ }
1995
+ }
1996
+
1997
+ // Build recommendation set. Dedup by playbook id so multi-trigger rules
1998
+ // don't double-list.
1999
+ const isRepo = detected.includes(".git/");
2000
+ const hasNode = detected.includes("package.json") || detected.includes("package-lock.json")
2001
+ || detected.includes("yarn.lock") || detected.includes("pnpm-lock.yaml");
2002
+ const hasPython = detected.includes("pyproject.toml") || detected.includes("requirements.txt")
2003
+ || detected.includes("Pipfile");
2004
+ const hasRust = detected.includes("Cargo.toml");
2005
+ const hasGo = detected.includes("go.mod");
2006
+ const hasLockfile = hasNode || hasPython || hasRust || hasGo;
2007
+ const hasContainers = detected.includes("Dockerfile") || detected.includes("docker-compose.yml")
2008
+ || detected.includes("docker-compose.yaml");
2009
+ const isLinux = hostPlatform === "linux";
2010
+
2011
+ const recs = [];
2012
+ const seen = new Set();
2013
+ function recommend(id, reason) {
2014
+ if (seen.has(id)) return;
2015
+ seen.add(id);
2016
+ recs.push({ id, reason });
2017
+ }
2018
+
2019
+ if (isRepo && hasLockfile) {
2020
+ const langs = [hasNode && "node", hasPython && "python", hasRust && "rust", hasGo && "go"]
2021
+ .filter(Boolean).join("/");
2022
+ recommend("secrets", `git repo + ${langs} lockfile → check for committed credentials`);
2023
+ recommend("sbom", `git repo + ${langs} lockfile → SBOM + supply-chain integrity`);
2024
+ recommend("library-author", `git repo + ${langs} lockfile → publisher-side audit`);
2025
+ recommend("crypto-codebase", `git repo + ${langs} lockfile → cryptographic primitive review`);
2026
+ }
2027
+ if (hasContainers) {
2028
+ recommend("containers", "Dockerfile / docker-compose present → container security review");
2029
+ }
2030
+ if (isLinux) {
2031
+ recommend("kernel", "Linux host detected → kernel LPE / privilege escalation triage");
2032
+ recommend("hardening", "Linux host detected → system hardening review");
2033
+ recommend("runtime", "Linux host detected → runtime behavior review");
2034
+ recommend("cred-stores", "Linux host detected → credential store review");
2035
+ }
2036
+ // Always include cross-cutting framework correlation.
2037
+ recommend("framework", "cross-cutting: framework correlation always applicable");
2038
+
2039
+ const nextSteps = [
2040
+ "exceptd brief <playbook> # learn what a playbook checks",
2041
+ "exceptd run <playbook> # run it",
2042
+ "exceptd run --scope code # run all code-scoped playbooks (auto-detected)",
2043
+ "exceptd ci --scope code # CI-gate against all code-scoped playbooks",
2044
+ ];
2045
+
2046
+ const out = {
2047
+ verb: "discover",
2048
+ context: {
2049
+ cwd,
2050
+ git_remote: gitRemote,
2051
+ detected_files: detected,
2052
+ host_platform: hostPlatform,
2053
+ host_distro: hostDistro,
2054
+ },
2055
+ recommended_playbooks: recs,
2056
+ next_steps: nextSteps,
2057
+ };
1278
2058
 
2059
+ // --scan-only: also run legacy `scan` and embed under legacy_scan. Use
2060
+ // spawnSync against orchestrator/index.js — the orchestrator was designed
2061
+ // to be invoked as a subprocess, and isolating it via spawn prevents one
2062
+ // bad scanner from killing the whole discover verb.
2063
+ if (args["scan-only"]) {
2064
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2065
+ try {
2066
+ const res = spawnSync(process.execPath, [orchPath, "scan", "--json"], {
2067
+ encoding: "utf8",
2068
+ cwd,
2069
+ timeout: 30000,
2070
+ });
2071
+ if (res.status === 0 && res.stdout) {
2072
+ try { out.legacy_scan = JSON.parse(res.stdout); }
2073
+ catch { out.legacy_scan = { ok: false, raw: res.stdout.slice(0, 2000), parse_error: true }; }
2074
+ } else {
2075
+ out.legacy_scan = {
2076
+ ok: false,
2077
+ exit_code: res.status,
2078
+ stderr: (res.stderr || "").slice(0, 2000),
2079
+ };
2080
+ }
2081
+ } catch (e) {
2082
+ out.legacy_scan = { ok: false, error: e.message };
2083
+ }
2084
+ }
2085
+
2086
+ if (wantJson) {
2087
+ emit(out, indent);
2088
+ return;
2089
+ }
2090
+
2091
+ // Default: human-readable text. (v0.11.0 redesign #5 — flipped defaults.)
2092
+ const lines = [];
2093
+ lines.push("exceptd discover");
2094
+ lines.push(` cwd: ${cwd}`);
2095
+ if (gitRemote) lines.push(` git remote: ${gitRemote}`);
2096
+ lines.push(` platform: ${hostPlatform}${hostDistro && hostDistro.pretty_name ? " (" + hostDistro.pretty_name + ")" : ""}`);
2097
+ lines.push(` detected: ${detected.length ? detected.join(", ") : "(nothing recognized)"}`);
2098
+ lines.push("");
2099
+ lines.push(`Recommended playbooks (${recs.length}):`);
2100
+ for (const r of recs) {
2101
+ lines.push(` - ${r.id.padEnd(20)} ${r.reason}`);
2102
+ }
2103
+ lines.push("");
2104
+ lines.push("Next steps:");
2105
+ for (const s of nextSteps) lines.push(` ${s}`);
2106
+ if (out.legacy_scan) {
2107
+ lines.push("");
2108
+ lines.push(`legacy scan: ${out.legacy_scan.ok === false ? "FAILED" : "ok"}`);
2109
+ }
2110
+ process.stdout.write(lines.join("\n") + "\n");
2111
+ }
2112
+
2113
+ // ---------------------------------------------------------------------------
2114
+ // v0.11.0: cmdDoctor — one-shot health check.
2115
+ // Collapses verify + currency + validate-cves + validate-rfcs + signing-status.
2116
+ // Each subcheck is independently fault-tolerant: a single failure surfaces
2117
+ // in the JSON but never crashes the verb.
2118
+ // ---------------------------------------------------------------------------
2119
+ function cmdDoctor(runner, args, runOpts, pretty) {
2120
+ const wantJson = !!args.json || !!args.pretty;
2121
+ const indent = !!args.pretty;
2122
+
2123
+ // Selective subchecks. If any of the four flags is passed, run only those.
2124
+ // If none are passed, run all four plus signing-status.
2125
+ const onlySigs = !!args.signatures;
2126
+ const onlyCurrency = !!args.currency;
2127
+ const onlyCves = !!args.cves;
2128
+ const onlyRfcs = !!args.rfcs;
2129
+ const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs;
2130
+ const runSigs = !anySelected || onlySigs;
2131
+ const runCurrency = !anySelected || onlyCurrency;
2132
+ const runCves = !anySelected || onlyCves;
2133
+ const runRfcs = !anySelected || onlyRfcs;
2134
+ const runSigning = !anySelected;
2135
+
2136
+ const checks = {};
2137
+ const issues = [];
2138
+
2139
+ if (runSigs) {
2140
+ try {
2141
+ const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
2142
+ const res = spawnSync(process.execPath, [verifyPath], {
2143
+ encoding: "utf8",
2144
+ cwd: PKG_ROOT,
2145
+ timeout: 30000,
2146
+ });
2147
+ const text = (res.stdout || "") + (res.stderr || "");
2148
+ const okMatch = text.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
2149
+ const fpMatch = text.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
2150
+ const ok = res.status === 0;
2151
+ checks.signatures = {
2152
+ ok,
2153
+ skills_passed: okMatch ? Number(okMatch[1]) : null,
2154
+ skills_total: okMatch ? Number(okMatch[2]) : null,
2155
+ fingerprint_sha256: fpMatch ? fpMatch[1] : null,
2156
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2157
+ };
2158
+ if (!ok) issues.push("signatures");
2159
+ } catch (e) {
2160
+ checks.signatures = { ok: false, error: e.message };
2161
+ issues.push("signatures");
2162
+ }
2163
+ }
2164
+
2165
+ if (runCurrency) {
2166
+ try {
2167
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2168
+ const res = spawnSync(process.execPath, [orchPath, "currency", "--json"], {
2169
+ encoding: "utf8",
2170
+ cwd: PKG_ROOT,
2171
+ timeout: 30000,
2172
+ });
2173
+ let parsed = null;
2174
+ if (res.stdout) {
2175
+ const m = res.stdout.match(/\{[\s\S]*\}\s*$/);
2176
+ if (m) {
2177
+ try { parsed = JSON.parse(m[0]); } catch { /* fall through */ }
2178
+ }
2179
+ }
2180
+ if (parsed && Array.isArray(parsed.currency_report)) {
2181
+ const stale = parsed.currency_report.filter(s => s.action_required || s.currency_label !== "current");
2182
+ const critical = parsed.currency_report.filter(s => s.currency_score !== undefined && s.currency_score < 50);
2183
+ const ok = stale.length === 0 && !parsed.action_required;
2184
+ checks.currency = {
2185
+ ok,
2186
+ total_skills: parsed.currency_report.length,
2187
+ stale_skills: stale.map(s => s.skill),
2188
+ critical_stale: critical.map(s => s.skill),
2189
+ critical_count: parsed.critical_count || 0,
2190
+ };
2191
+ if (!ok) issues.push("currency");
2192
+ } else {
2193
+ checks.currency = {
2194
+ ok: res.status === 0,
2195
+ exit_code: res.status,
2196
+ raw: (res.stdout || res.stderr || "").slice(0, 500),
2197
+ parse_error: true,
2198
+ };
2199
+ if (res.status !== 0) issues.push("currency");
2200
+ }
2201
+ } catch (e) {
2202
+ checks.currency = { ok: false, error: e.message };
2203
+ issues.push("currency");
2204
+ }
2205
+ }
2206
+
2207
+ if (runCves) {
2208
+ try {
2209
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2210
+ // validate-cves doesn't emit JSON; parse text for row count + drift.
2211
+ const res = spawnSync(process.execPath, [orchPath, "validate-cves", "--offline"], {
2212
+ encoding: "utf8",
2213
+ cwd: PKG_ROOT,
2214
+ timeout: 30000,
2215
+ });
2216
+ const text = (res.stdout || "") + (res.stderr || "");
2217
+ const totalMatch = text.match(/(\d+)\s+CVEs?\s+in\s+catalog/i);
2218
+ const driftMatch = text.match(/drift[:\s]+(\d+)/i);
2219
+ const ok = res.status === 0;
2220
+ checks.cves = {
2221
+ ok,
2222
+ total: totalMatch ? Number(totalMatch[1]) : null,
2223
+ drift: driftMatch ? Number(driftMatch[1]) : 0,
2224
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2225
+ };
2226
+ if (!ok) issues.push("cves");
2227
+ } catch (e) {
2228
+ checks.cves = { ok: false, error: e.message };
2229
+ issues.push("cves");
2230
+ }
2231
+ }
2232
+
2233
+ if (runRfcs) {
2234
+ try {
2235
+ const orchPath = path.join(PKG_ROOT, "orchestrator", "index.js");
2236
+ const res = spawnSync(process.execPath, [orchPath, "validate-rfcs", "--offline"], {
2237
+ encoding: "utf8",
2238
+ cwd: PKG_ROOT,
2239
+ timeout: 30000,
2240
+ });
2241
+ const text = (res.stdout || "") + (res.stderr || "");
2242
+ const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
2243
+ const driftMatch = text.match(/drift[:\s]+(\d+)/i);
2244
+ const ok = res.status === 0;
2245
+ checks.rfcs = {
2246
+ ok,
2247
+ total: rfcRows,
2248
+ drift: driftMatch ? Number(driftMatch[1]) : 0,
2249
+ ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2250
+ };
2251
+ if (!ok) issues.push("rfcs");
2252
+ } catch (e) {
2253
+ checks.rfcs = { ok: false, error: e.message };
2254
+ issues.push("rfcs");
2255
+ }
2256
+ }
2257
+
2258
+ if (runSigning) {
2259
+ try {
2260
+ const keyPath = path.join(process.cwd(), ".keys", "private.pem");
2261
+ const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
2262
+ const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
2263
+ checks.signing = {
2264
+ ok: true, // signing-status is informational, never "fails"
2265
+ private_key_present: present,
2266
+ can_sign_attestations: present,
2267
+ ...(present ? {} : { hint: "run `node lib/sign.js generate-keypair` to enable attestation signing" }),
2268
+ };
2269
+ } catch (e) {
2270
+ checks.signing = { ok: false, error: e.message };
2271
+ }
2272
+ }
2273
+
2274
+ const allGreen = issues.length === 0;
2275
+ const out = {
2276
+ verb: "doctor",
2277
+ checks,
2278
+ summary: { all_green: allGreen, issues_count: issues.length, failed_checks: issues },
2279
+ };
2280
+
2281
+ if (wantJson) {
2282
+ emit(out, indent);
2283
+ if (!allGreen) process.exitCode = 1;
2284
+ return;
2285
+ }
2286
+
2287
+ // Default: human checklist. v0.11.0 redesign #5.
2288
+ const lines = [];
2289
+ lines.push("exceptd doctor");
2290
+ function mark(c, render) {
2291
+ if (!c) return;
2292
+ const icon = c.ok ? "[ok]" : "[!!]";
2293
+ lines.push(` ${icon} ${render(c)}`);
2294
+ }
2295
+ mark(checks.signatures, c =>
2296
+ c.ok
2297
+ ? `skill signatures verified (${c.skills_passed ?? "?"}/${c.skills_total ?? "?"})`
2298
+ : `skill signatures FAILED (exit=${c.exit_code ?? "?"})`
2299
+ );
2300
+ mark(checks.currency, c =>
2301
+ c.ok
2302
+ ? `skill currency: all green (${c.total_skills ?? "?"} skills)`
2303
+ : `skill currency: ${c.stale_skills?.length || "?"} stale, ${c.critical_count ?? 0} critical`
2304
+ );
2305
+ mark(checks.cves, c =>
2306
+ c.ok
2307
+ ? `CVE catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
2308
+ : `CVE catalog FAILED (exit=${c.exit_code ?? "?"})`
2309
+ );
2310
+ mark(checks.rfcs, c =>
2311
+ c.ok
2312
+ ? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
2313
+ : `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
2314
+ );
2315
+ if (checks.signing) {
2316
+ if (checks.signing.private_key_present) {
2317
+ lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
2318
+ } else {
2319
+ lines.push(` [!!] attestation signing: private key MISSING (.keys/private.pem) — run \`node lib/sign.js generate-keypair\` to enable`);
2320
+ }
2321
+ }
2322
+ lines.push("");
2323
+ lines.push(allGreen ? `summary: all checks green` : `summary: ${issues.length} issue(s) — ${issues.join(", ")}`);
2324
+ process.stdout.write(lines.join("\n") + "\n");
2325
+ if (!allGreen) process.exitCode = 1;
2326
+ }
2327
+
2328
+ function cmdListAttestations(runner, args, runOpts, pretty) {
2329
+ // Enumerate sessions across both v0.11.0 default root and legacy cwd-
2330
+ // relative root, so operators with prior attestations still see them.
2331
+ const roots = [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")];
1279
2332
  const entries = [];
1280
- for (const sid of sessions) {
1281
- const sdir = path.join(root, sid);
1282
- const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json"));
1283
- for (const f of files) {
1284
- try {
1285
- const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
1286
- // Apply --playbook filter if supplied.
1287
- if (args.playbook && j.playbook_id !== args.playbook) continue;
1288
- entries.push({
1289
- session_id: sid,
1290
- playbook_id: j.playbook_id,
1291
- directive_id: j.directive_id,
1292
- evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
1293
- captured_at: j.captured_at || null,
1294
- file: path.relative(process.cwd(), path.join(sdir, f)),
1295
- });
1296
- } catch { /* skip malformed */ }
2333
+ const seenRoots = new Set();
2334
+ for (const root of roots) {
2335
+ if (seenRoots.has(root) || !fs.existsSync(root)) continue;
2336
+ seenRoots.add(root);
2337
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
2338
+ .filter(d => d.isDirectory())
2339
+ .map(d => d.name);
2340
+ for (const sid of sessions) {
2341
+ const sdir = path.join(root, sid);
2342
+ const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
2343
+ for (const f of files) {
2344
+ try {
2345
+ const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
2346
+ if (args.playbook && j.playbook_id !== args.playbook) continue;
2347
+ if (args.since && (j.captured_at || "") < args.since) continue;
2348
+ entries.push({
2349
+ session_id: sid,
2350
+ playbook_id: j.playbook_id,
2351
+ directive_id: j.directive_id,
2352
+ evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
2353
+ captured_at: j.captured_at || null,
2354
+ attestation_root: root,
2355
+ file: path.join(sdir, f),
2356
+ });
2357
+ } catch { /* skip malformed */ }
2358
+ }
1297
2359
  }
1298
2360
  }
1299
2361
  entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
@@ -1301,10 +2363,423 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
1301
2363
  ok: true,
1302
2364
  attestations: entries,
1303
2365
  count: entries.length,
1304
- filter: { playbook: args.playbook || null },
2366
+ filter: { playbook: args.playbook || null, since: args.since || null },
2367
+ roots_searched: [...seenRoots],
1305
2368
  }, pretty);
1306
2369
  }
1307
2370
 
2371
+ // ---------------------------------------------------------------------------
2372
+ // v0.11.0 verbs: ai-run, ask, ci
2373
+ // ---------------------------------------------------------------------------
2374
+
2375
+ /**
2376
+ * `ai-run <playbook>` — streaming JSONL contract for AI-driven runs.
2377
+ *
2378
+ * Emits one JSON object per line over stdout as the seven phases progress;
2379
+ * reads {"event":"evidence","payload":{observations,verdict}} from stdin
2380
+ * once it's announced the await_evidence phase. Designed so a host AI can
2381
+ * pipe one bidirectional channel instead of doing brief → look → run as
2382
+ * three CLI round-trips with an intermediate evidence file.
2383
+ *
2384
+ * --no-stream falls back to a single JSON document combining every phase
2385
+ * for callers that don't want event-driven I/O (smoke tests, batch jobs).
2386
+ */
2387
+ function cmdAiRun(runner, args, runOpts, pretty) {
2388
+ const playbookId = args._[0];
2389
+ if (!playbookId) {
2390
+ return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
2391
+ }
2392
+ let pb;
2393
+ try { pb = runner.loadPlaybook(playbookId); }
2394
+ catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
2395
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2396
+ if (!directiveId) {
2397
+ return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
2398
+ }
2399
+
2400
+ // Compute the informational phases up front — both stream and no-stream
2401
+ // modes share them.
2402
+ let governPhase, directPhase, lookPhase;
2403
+ try {
2404
+ governPhase = runner.govern(playbookId, directiveId, runOpts);
2405
+ directPhase = runner.direct(playbookId, directiveId);
2406
+ lookPhase = runner.look(playbookId, directiveId, runOpts);
2407
+ } catch (e) {
2408
+ process.stdout.write(JSON.stringify({ event: "error", reason: e.message, phase: "info" }) + "\n");
2409
+ process.exit(1);
2410
+ }
2411
+
2412
+ const governEvent = {
2413
+ phase: "govern",
2414
+ playbook_id: playbookId,
2415
+ directive_id: directiveId,
2416
+ jurisdiction_obligations: governPhase.jurisdiction_obligations || [],
2417
+ theater_fingerprints: governPhase.theater_fingerprints || [],
2418
+ framework_context: governPhase.framework_context || null,
2419
+ skill_preload: governPhase.skill_preload || [],
2420
+ };
2421
+ const directEvent = {
2422
+ phase: "direct",
2423
+ threat_context: directPhase.threat_context || null,
2424
+ rwep_threshold: directPhase.rwep_threshold || null,
2425
+ framework_lag_declaration: directPhase.framework_lag_declaration || null,
2426
+ skill_chain: directPhase.skill_chain || [],
2427
+ token_budget: directPhase.token_budget || null,
2428
+ };
2429
+ const lookEvent = {
2430
+ phase: "look",
2431
+ artifacts_required: (lookPhase.artifacts || []).filter(a => a.required),
2432
+ artifacts_optional: (lookPhase.artifacts || []).filter(a => !a.required),
2433
+ preconditions: lookPhase.preconditions || [],
2434
+ precondition_submission_shape: lookPhase.precondition_submission_shape || null,
2435
+ collection_scope: lookPhase.collection_scope || null,
2436
+ };
2437
+ const submissionShape = {
2438
+ observations: {},
2439
+ verdict: {},
2440
+ note: "Send back as {\"event\":\"evidence\",\"payload\":{\"observations\":{...},\"verdict\":{...}}}.",
2441
+ };
2442
+
2443
+ // ----- single-shot path -----
2444
+ if (args["no-stream"]) {
2445
+ // Read any pre-supplied evidence from stdin OR from --evidence flag.
2446
+ let payload = { observations: {}, verdict: {} };
2447
+ if (args.evidence) {
2448
+ try { payload = readEvidence(args.evidence); }
2449
+ catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
2450
+ } else if (!process.stdin.isTTY) {
2451
+ // Drain stdin for any evidence event.
2452
+ try {
2453
+ const buf = fs.readFileSync(0, "utf8");
2454
+ if (buf.trim()) {
2455
+ // Accept either a bare submission object or a single evidence event.
2456
+ for (const line of buf.split(/\r?\n/)) {
2457
+ const t = line.trim();
2458
+ if (!t) continue;
2459
+ try {
2460
+ const parsed = JSON.parse(t);
2461
+ if (parsed && parsed.event === "evidence" && parsed.payload) {
2462
+ payload = parsed.payload;
2463
+ break;
2464
+ }
2465
+ // Bare submission fallback.
2466
+ if (parsed && (parsed.observations || parsed.artifacts || parsed.signal_overrides)) {
2467
+ payload = parsed.observations
2468
+ ? parsed
2469
+ : { observations: { ...(parsed.artifacts || {}), ...(parsed.signal_overrides || {}) }, verdict: parsed.signals || {} };
2470
+ break;
2471
+ }
2472
+ } catch { /* skip non-JSON lines */ }
2473
+ }
2474
+ }
2475
+ } catch { /* stdin empty / unreadable — fall through with empty payload */ }
2476
+ }
2477
+ const submission = buildSubmissionFromPayload(payload);
2478
+ let result;
2479
+ try {
2480
+ result = runner.run(playbookId, directiveId, submission, runOpts);
2481
+ } catch (e) {
2482
+ return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
2483
+ }
2484
+ if (!result || result.ok === false) {
2485
+ process.stderr.write((pretty ? JSON.stringify(result || {}, null, 2) : JSON.stringify(result || {})) + "\n");
2486
+ process.exit(1);
2487
+ }
2488
+ emit({
2489
+ verb: "ai-run",
2490
+ mode: "no-stream",
2491
+ playbook_id: playbookId,
2492
+ directive_id: directiveId,
2493
+ govern: governEvent,
2494
+ direct: directEvent,
2495
+ look: lookEvent,
2496
+ detect: result.phases?.detect || null,
2497
+ analyze: result.phases?.analyze || null,
2498
+ validate: result.phases?.validate || null,
2499
+ close: result.phases?.close || null,
2500
+ session_id: result.session_id,
2501
+ evidence_hash: result.evidence_hash,
2502
+ }, pretty);
2503
+ return;
2504
+ }
2505
+
2506
+ // ----- streaming path -----
2507
+ // Emit info phases immediately, then wait for an evidence event on stdin.
2508
+ const writeLine = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
2509
+ writeLine(governEvent);
2510
+ writeLine(directEvent);
2511
+ writeLine(lookEvent);
2512
+ writeLine({ phase: "await_evidence", submission_shape: submissionShape });
2513
+
2514
+ let handled = false;
2515
+ let buf = "";
2516
+
2517
+ const handleLine = (line) => {
2518
+ if (handled) return;
2519
+ let parsed;
2520
+ try { parsed = JSON.parse(line); }
2521
+ catch (e) {
2522
+ writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
2523
+ process.exit(1);
2524
+ }
2525
+ if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
2526
+ // Ignore non-evidence chatter so the host AI can interleave its own
2527
+ // status events; only an "evidence" event triggers phases 4-7.
2528
+ return;
2529
+ }
2530
+ handled = true;
2531
+ const submission = buildSubmissionFromPayload(parsed.payload);
2532
+ let result;
2533
+ try {
2534
+ result = runner.run(playbookId, directiveId, submission, runOpts);
2535
+ } catch (e) {
2536
+ writeLine({ event: "error", reason: `runner threw: ${e.message}` });
2537
+ process.exit(1);
2538
+ }
2539
+ if (!result || result.ok === false) {
2540
+ writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
2541
+ process.exit(1);
2542
+ }
2543
+ writeLine({ phase: "detect", ...result.phases?.detect });
2544
+ writeLine({ phase: "analyze", ...result.phases?.analyze });
2545
+ writeLine({ phase: "validate", ...result.phases?.validate });
2546
+ writeLine({ phase: "close", ...result.phases?.close });
2547
+ writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
2548
+ process.exit(0);
2549
+ };
2550
+
2551
+ // Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
2552
+ // calling ai-run without piping anything see a useful message rather than
2553
+ // a hung process.
2554
+ if (process.stdin.isTTY) {
2555
+ writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
2556
+ process.exit(1);
2557
+ }
2558
+
2559
+ process.stdin.on("data", (chunk) => {
2560
+ buf += chunk.toString();
2561
+ let nl;
2562
+ while ((nl = buf.indexOf("\n")) !== -1) {
2563
+ const line = buf.slice(0, nl).trim();
2564
+ buf = buf.slice(nl + 1);
2565
+ if (line) handleLine(line);
2566
+ }
2567
+ });
2568
+ process.stdin.on("end", () => {
2569
+ // Final flush — handle a trailing line without a newline.
2570
+ const tail = buf.trim();
2571
+ if (tail) handleLine(tail);
2572
+ if (!handled) {
2573
+ writeLine({ event: "error", reason: "stdin closed without an evidence event." });
2574
+ process.exit(1);
2575
+ }
2576
+ });
2577
+ }
2578
+
2579
+ /**
2580
+ * Coerce a stdin payload into the runner submission shape. Accepts both the
2581
+ * v0.11.0 ai-run shape (observations + verdict) and the nested v0.10.x shape
2582
+ * (artifacts + signal_overrides + signals) for forward/back compat.
2583
+ */
2584
+ function buildSubmissionFromPayload(payload) {
2585
+ if (!payload || typeof payload !== "object") return { artifacts: {}, signal_overrides: {}, signals: {} };
2586
+ // Nested v0.10.x shape passthrough.
2587
+ if (payload.artifacts || payload.signal_overrides || payload.signals) {
2588
+ return {
2589
+ artifacts: payload.artifacts || {},
2590
+ signal_overrides: payload.signal_overrides || {},
2591
+ signals: payload.signals || {},
2592
+ precondition_checks: payload.precondition_checks || undefined,
2593
+ };
2594
+ }
2595
+ // v0.11.0 flat shape: observations becomes the artifacts+signal_overrides
2596
+ // union (the runner normalises both via normalizeSubmission), verdict
2597
+ // becomes signals.
2598
+ return {
2599
+ artifacts: payload.observations || {},
2600
+ signal_overrides: payload.observations || {},
2601
+ signals: payload.verdict || {},
2602
+ precondition_checks: payload.precondition_checks || undefined,
2603
+ };
2604
+ }
2605
+
2606
+ /**
2607
+ * `ask "<question>"` — plain-English routing. Scores every playbook by token
2608
+ * overlap against domain.name + domain.attack_class + first sentence of
2609
+ * phases.direct.threat_context. Returns the top 5 matches with a confidence
2610
+ * score (matched tokens / total tokens).
2611
+ */
2612
+ function cmdAsk(runner, args, runOpts, pretty) {
2613
+ const question = (args._ || []).join(" ").trim();
2614
+ if (!question) {
2615
+ return emitError("ask: usage: exceptd ask \"<plain-English question>\"", null, pretty);
2616
+ }
2617
+ const ids = runner.listPlaybooks();
2618
+ const q = question.toLowerCase();
2619
+ const tokens = q.split(/\W+/).filter(t => t.length > 3);
2620
+ const scored = [];
2621
+ for (const id of ids) {
2622
+ let pb;
2623
+ try { pb = runner.loadPlaybook(id); } catch { continue; }
2624
+ const threat = pb.phases?.direct?.threat_context || "";
2625
+ const firstSentence = threat.split(/(?<=[.!?])\s+/)[0] || "";
2626
+ const haystack = [
2627
+ pb.domain?.name || "",
2628
+ pb.domain?.attack_class || "",
2629
+ firstSentence,
2630
+ ].join(" ").toLowerCase();
2631
+ const score = tokens.filter(t => haystack.includes(t)).length;
2632
+ scored.push({ id: pb._meta?.id || id, score });
2633
+ }
2634
+ scored.sort((a, b) => b.score - a.score);
2635
+ const top = scored.filter(s => s.score > 0).slice(0, 5);
2636
+
2637
+ if (top.length === 0) {
2638
+ emit({
2639
+ verb: "ask",
2640
+ question,
2641
+ matched: [],
2642
+ hint: "No playbook matched. Try `exceptd brief --all` to see what's available, or `exceptd discover` to detect what's in your cwd.",
2643
+ }, pretty);
2644
+ return;
2645
+ }
2646
+
2647
+ emit({
2648
+ verb: "ask",
2649
+ question,
2650
+ routed_to: top.map(t => t.id),
2651
+ confidence: top[0].score / Math.max(1, tokens.length),
2652
+ next_step: `exceptd run ${top[0].id} # or: exceptd brief ${top[0].id} to learn first`,
2653
+ full_match_list: top,
2654
+ }, pretty);
2655
+ }
2656
+
2657
+ /**
2658
+ * `ci [--all|--scope <type>]` — top-level CI gate. Effectively
2659
+ * `run --all --ci` packaged as a verb so .github/workflows lines are short.
2660
+ *
2661
+ * Exit codes:
2662
+ * 0 PASS — no detected findings, no rwep ≥ cap, no clock started (when
2663
+ * --block-on-jurisdiction-clock is set).
2664
+ * 2 FAIL — any of the above tripped.
2665
+ */
2666
+ function cmdCi(runner, args, runOpts, pretty) {
2667
+ const scope = args.scope;
2668
+ const maxRwep = args["max-rwep"] !== undefined ? Number(args["max-rwep"]) : null;
2669
+ const blockOnClock = !!args["block-on-jurisdiction-clock"];
2670
+
2671
+ let ids;
2672
+ if (args.all) {
2673
+ ids = runner.listPlaybooks();
2674
+ } else if (scope) {
2675
+ ids = filterPlaybooksByScope(runner, scope);
2676
+ } else {
2677
+ const scopes = detectScopes();
2678
+ ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
2679
+ ids = [...new Set(ids)];
2680
+ }
2681
+ if (!ids || ids.length === 0) {
2682
+ return emitError("ci: no playbooks matched. Pass --all, --scope <type>, or run from a repo/Linux-host context.", null, pretty);
2683
+ }
2684
+
2685
+ const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
2686
+
2687
+ // Evidence: --evidence <file> or --evidence-dir <dir>. Both produce a
2688
+ // bundle keyed by playbook id; ids without a key get an empty submission.
2689
+ let bundle = {};
2690
+ if (args.evidence) {
2691
+ try { bundle = readEvidence(args.evidence); }
2692
+ catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
2693
+ }
2694
+ if (args["evidence-dir"]) {
2695
+ const dir = args["evidence-dir"];
2696
+ if (!fs.existsSync(dir)) {
2697
+ return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
2698
+ }
2699
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
2700
+ try {
2701
+ bundle[f.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
2702
+ } catch (e) {
2703
+ return emitError(`ci: failed to parse evidence-dir entry ${f}: ${e.message}`, null, pretty);
2704
+ }
2705
+ }
2706
+ }
2707
+
2708
+ const results = [];
2709
+ let fail = false;
2710
+ let failReasons = [];
2711
+
2712
+ for (const id of ids) {
2713
+ let pb;
2714
+ try { pb = runner.loadPlaybook(id); }
2715
+ catch (e) { results.push({ playbook_id: id, ok: false, error: e.message }); fail = true; continue; }
2716
+ const directiveId = (pb.directives[0] && pb.directives[0].id);
2717
+ if (!directiveId) {
2718
+ results.push({ playbook_id: id, ok: false, error: "no directives" });
2719
+ fail = true;
2720
+ continue;
2721
+ }
2722
+ const submission = bundle[id] || {};
2723
+ const perOpts = { ...runOpts, session_id: sessionId };
2724
+ if (submission.precondition_checks) perOpts.precondition_checks = submission.precondition_checks;
2725
+ let result;
2726
+ try { result = runner.run(id, directiveId, submission, perOpts); }
2727
+ catch (e) { result = { ok: false, error: e.message, playbook_id: id }; }
2728
+ results.push(result);
2729
+ if (!result || result.ok === false) {
2730
+ fail = true;
2731
+ failReasons.push(`${id}: blocked (${result?.reason || result?.error || "unknown"})`);
2732
+ continue;
2733
+ }
2734
+ const cls = result.phases?.detect?.classification;
2735
+ const rwepAdj = result.phases?.analyze?.rwep?.adjusted ?? 0;
2736
+ const cap = maxRwep !== null
2737
+ ? maxRwep
2738
+ : (result.phases?.analyze?.rwep?.threshold?.escalate ?? 90);
2739
+ const clockStarted = (result.phases?.close?.notification_actions || [])
2740
+ .some(n => n && n.clock_started_at != null);
2741
+ if (cls === "detected") {
2742
+ fail = true;
2743
+ failReasons.push(`${id}: classification=detected`);
2744
+ }
2745
+ if (cls !== "not_detected" && cls !== "clean" && rwepAdj >= cap) {
2746
+ fail = true;
2747
+ failReasons.push(`${id}: rwep=${rwepAdj} >= cap=${cap} (classification=${cls})`);
2748
+ }
2749
+ if (blockOnClock && clockStarted) {
2750
+ fail = true;
2751
+ failReasons.push(`${id}: jurisdiction clock started`);
2752
+ }
2753
+ }
2754
+
2755
+ const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
2756
+ const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
2757
+
2758
+ emit({
2759
+ verb: "ci",
2760
+ session_id: sessionId,
2761
+ playbooks_run: ids,
2762
+ summary: {
2763
+ total: results.length,
2764
+ detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
2765
+ inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
2766
+ not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
2767
+ blocked: results.filter(r => r && r.ok === false).length,
2768
+ max_rwep_observed: maxRwepObserved,
2769
+ jurisdiction_clocks_started: results
2770
+ .flatMap(r => r.phases?.close?.notification_actions || [])
2771
+ .filter(n => n && n.clock_started_at != null).length,
2772
+ verdict: fail ? "FAIL" : "PASS",
2773
+ fail_reasons: failReasons,
2774
+ },
2775
+ results,
2776
+ }, pretty);
2777
+ if (fail) {
2778
+ process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
2779
+ process.exit(2);
2780
+ }
2781
+ }
2782
+
1308
2783
  if (require.main === module) main();
1309
2784
 
1310
2785
  module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };