crewhaus 0.2.0 → 0.2.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.
@@ -15,6 +15,7 @@
15
15
  * context slice for each missing kind and simply produce no findings — the
16
16
  * rules only fire where the data now exists.
17
17
  */
18
+ import type { ArmStats } from "@crewhaus/routing-store";
18
19
  import type { Spec } from "@crewhaus/spec";
19
20
  import { type SpecPatch } from "@crewhaus/spec-patch";
20
21
  export type AdviceSeverity = "info" | "warn";
@@ -108,6 +109,13 @@ export type AdviceContext = {
108
109
  readonly totalToolBytes: number;
109
110
  /** `.crewhaus/audit` record kinds → counts (report context + future rules). */
110
111
  readonly auditKindCounts: ReadonlyMap<string, number>;
112
+ /**
113
+ * Adaptive model routing — the `model_pool` reward scoreboard arms from
114
+ * `.crewhaus/routing/arms.jsonl` (empty when the harness has never routed a
115
+ * pool). The routing rules mine these into pool-POLICY suggestions; the
116
+ * candidate roster itself stays human-owned.
117
+ */
118
+ readonly routingArms: ReadonlyArray<ArmStats>;
111
119
  };
112
120
  export type SessionEvents = {
113
121
  readonly sessionId: string;
@@ -140,7 +148,7 @@ export declare function payloadOf(obj: unknown, kind: string): Record<string, un
140
148
  * old-vintage logs (no advisor kinds) and future kinds both pass through
141
149
  * cleanly.
142
150
  */
143
- export declare function buildAdviceContext(sessions: ReadonlyArray<SessionEvents>, auditObjects?: ReadonlyArray<unknown>): AdviceContext;
151
+ export declare function buildAdviceContext(sessions: ReadonlyArray<SessionEvents>, auditObjects?: ReadonlyArray<unknown>, routingArms?: ReadonlyArray<ArmStats>): AdviceContext;
144
152
  export type AdviceThresholds = {
145
153
  /** Minimum calls before a tool's error rate is judged at all. */
146
154
  toolFailureMinCalls: number;
@@ -171,6 +179,13 @@ export type AdviceThresholds = {
171
179
  /** Item 21 — minimum total tool bytes before dominance is judged at all
172
180
  * (a handful of tiny calls shouldn't trip a restructure). */
173
181
  toolByteMinTotal: number;
182
+ /** Adaptive routing — samples an arm needs before the routing rules judge
183
+ * it, when the spec doesn't set `model_pool.learning.minSamplesPerArm`.
184
+ * Mirrors the PolicyRouter's own default floor. */
185
+ poolMinSamples: number;
186
+ /** Adaptive routing — mean-reward gap below the band best at/above which a
187
+ * candidate is called consistently losing (demotion advice). */
188
+ poolDemotionGap: number;
174
189
  };
175
190
  /**
176
191
  * Canonical rule thresholds. runtime-core's in-run digest
@@ -187,6 +202,16 @@ export type RuleOptions = {
187
202
  /** The cwd spec, when one exists — enables patch suggestions. */
188
203
  readonly spec?: Spec;
189
204
  readonly thresholds?: Partial<AdviceThresholds>;
205
+ /**
206
+ * Whether the RAW spec YAML textually carries a key at `path` (the driver
207
+ * builds this from `specHasPath` in `@crewhaus/spec-patch`). Zod-defaulted
208
+ * fields are always present on the parsed `spec`, but `applySpecPatch`
209
+ * requires `add` for absent keys and `replace` for present ones — a rule
210
+ * proposing a patch on a defaultable field needs this to pick the op.
211
+ * Absent → such rules assume the key is absent (the common case for
212
+ * defaulted fields) and emit `add`.
213
+ */
214
+ readonly specHasPath?: (path: ReadonlyArray<string>) => boolean;
190
215
  };
191
216
  export type AdviceRule = (ctx: AdviceContext, opts?: RuleOptions) => AdviceFinding[];
192
217
  /**
@@ -315,6 +340,35 @@ export declare function subAgentFragment(toolName: string): string;
315
340
  * rule stays silent, since there's nowhere to paste it.
316
341
  */
317
342
  export declare const ruleSubAgentSplit: AdviceRule;
343
+ /**
344
+ * Pool policy upgrade: the spec declares a `model_pool` that is NOT yet
345
+ * `learned`, and the reward scoreboard already has full-coverage data (every
346
+ * candidate past the sample floor in ≥1 difficulty band) — the harness has
347
+ * earned the right to exploit what it measured. Proposes flipping
348
+ * `model_pool.policy` to `learned` (whitelisted; `optimize --from-advice`
349
+ * eval-gates the flip). The candidate ROSTER is never touched.
350
+ */
351
+ export declare const rulePoolPolicyUpgrade: AdviceRule;
352
+ /**
353
+ * Pool candidate demotion: a candidate that is past the sample floor and
354
+ * loses to the band best by ≥ `poolDemotionGap` mean reward in EVERY band
355
+ * with full coverage is consistently wasted spend. ADVICE-ONLY by design:
356
+ * the candidate roster is human-owned (mirroring the `["agent","model"]`
357
+ * OPTIMIZABLE_PATHS exclusion), so this never emits a patch — it names the
358
+ * loser and lets a human edit `model_pool.candidates`.
359
+ */
360
+ export declare const rulePoolCandidateDemotion: AdviceRule;
361
+ /**
362
+ * Stale exploitation: a `learned` pool whose scoreboard has full coverage but
363
+ * whose config never explores again — `explorationRate` unset/0 and the
364
+ * bandit is not `thompson` (which self-explores via posterior sampling). Once
365
+ * every arm clears the floor, such a pool hard-commits to the argmax forever
366
+ * and can never notice model drift or a candidate that improved. Proposes a
367
+ * small ε (0.05) via a whole-`learning`-block replace that PRESERVES the
368
+ * spec's other learning fields (whitelisted path; `optimize --from-advice`
369
+ * eval-gates it).
370
+ */
371
+ export declare const rulePoolStaleExploitation: AdviceRule;
318
372
  export declare const ADVICE_RULES: ReadonlyArray<AdviceRule>;
319
373
  /**
320
374
  * Run every rule and rank the findings: warn before info, then by the
@@ -64,7 +64,7 @@ export function payloadOf(obj, kind) {
64
64
  * old-vintage logs (no advisor kinds) and future kinds both pass through
65
65
  * cleanly.
66
66
  */
67
- export function buildAdviceContext(sessions, auditObjects = []) {
67
+ export function buildAdviceContext(sessions, auditObjects = [], routingArms = []) {
68
68
  const toolStats = new Map();
69
69
  const recoveriesByAction = new Map();
70
70
  const recoveriesByErrorName = new Map();
@@ -197,6 +197,7 @@ export function buildAdviceContext(sessions, auditObjects = []) {
197
197
  perToolBytes,
198
198
  totalToolBytes,
199
199
  auditKindCounts,
200
+ routingArms,
200
201
  };
201
202
  }
202
203
  /**
@@ -218,6 +219,8 @@ export const DEFAULT_ADVICE_THRESHOLDS = {
218
219
  chronicCompactionSessions: 2,
219
220
  toolByteDominance: 0.6,
220
221
  toolByteMinTotal: 50_000,
222
+ poolMinSamples: 25,
223
+ poolDemotionGap: 0.3,
221
224
  };
222
225
  /** Stop reasons that are part of healthy operation; everything else
223
226
  * (max_tokens, refusal, pause_turn, …) counts as anomalous. Mirrored by
@@ -798,6 +801,164 @@ export const ruleSubAgentSplit = (ctx, opts) => {
798
801
  },
799
802
  ];
800
803
  };
804
+ function agentModelPool(spec) {
805
+ const agent = specField(spec, "agent");
806
+ const pool = agent?.["model_pool"];
807
+ if (pool === undefined || pool === null || typeof pool !== "object")
808
+ return undefined;
809
+ return pool;
810
+ }
811
+ /** The bands (routeKeys) where EVERY pool candidate has ≥ `floor` samples. */
812
+ function bandsWithFullCoverage(arms, candidates, floor) {
813
+ const bands = [...new Set(arms.map((a) => a.routeKey))];
814
+ return bands.filter((band) => candidates.every((c) => arms.some((a) => a.routeKey === band && a.model === c.model && a.n >= floor)));
815
+ }
816
+ /**
817
+ * Pool policy upgrade: the spec declares a `model_pool` that is NOT yet
818
+ * `learned`, and the reward scoreboard already has full-coverage data (every
819
+ * candidate past the sample floor in ≥1 difficulty band) — the harness has
820
+ * earned the right to exploit what it measured. Proposes flipping
821
+ * `model_pool.policy` to `learned` (whitelisted; `optimize --from-advice`
822
+ * eval-gates the flip). The candidate ROSTER is never touched.
823
+ */
824
+ export const rulePoolPolicyUpgrade = (ctx, opts) => {
825
+ const t = resolveThresholds(opts);
826
+ const spec = opts?.spec;
827
+ const pool = agentModelPool(spec);
828
+ if (pool === undefined || pool.policy === "learned")
829
+ return [];
830
+ if (ctx.routingArms.length === 0 || pool.candidates.length < 2)
831
+ return [];
832
+ const floor = pool.learning?.minSamplesPerArm ?? t.poolMinSamples;
833
+ const ready = bandsWithFullCoverage(ctx.routingArms, pool.candidates, floor);
834
+ if (ready.length === 0)
835
+ return [];
836
+ const totalSamples = ctx.routingArms.reduce((acc, a) => acc + a.n, 0);
837
+ const adviceText = `The model_pool reward scoreboard has ≥${floor} samples for every candidate in band(s) ${ready.join(", ")} — set model_pool.policy: learned so routing exploits the measured per-band winners instead of the fixed heuristic.`;
838
+ const policyKeyPresent = opts?.specHasPath?.(["agent", "model_pool", "policy"]) ?? false;
839
+ const patch = {
840
+ target: spec?.target ?? "cli",
841
+ path: ["agent", "model_pool", "policy"],
842
+ op: policyKeyPresent ? "replace" : "add",
843
+ value: "learned",
844
+ rationale: `advise: scoreboard full coverage (every candidate ≥${floor} samples) in band(s) ${ready.join(", ")} across ${totalSamples} routed decisions`,
845
+ };
846
+ return [
847
+ {
848
+ id: "pool-policy-upgrade",
849
+ severity: "info",
850
+ summary: "model_pool scoreboard is ready — flip policy to learned",
851
+ evidence: [
852
+ `bands with every candidate ≥${floor} samples: ${ready.join(", ")}`,
853
+ `${totalSamples} routed decisions recorded across ${ctx.routingArms.length} arm(s)`,
854
+ `current policy: ${pool.policy}`,
855
+ ],
856
+ counts: { readyBands: ready.length, totalSamples },
857
+ suggestion: patchOrAdvice(spec, patch, adviceText),
858
+ },
859
+ ];
860
+ };
861
+ /**
862
+ * Pool candidate demotion: a candidate that is past the sample floor and
863
+ * loses to the band best by ≥ `poolDemotionGap` mean reward in EVERY band
864
+ * with full coverage is consistently wasted spend. ADVICE-ONLY by design:
865
+ * the candidate roster is human-owned (mirroring the `["agent","model"]`
866
+ * OPTIMIZABLE_PATHS exclusion), so this never emits a patch — it names the
867
+ * loser and lets a human edit `model_pool.candidates`.
868
+ */
869
+ export const rulePoolCandidateDemotion = (ctx, opts) => {
870
+ const t = resolveThresholds(opts);
871
+ const pool = agentModelPool(opts?.spec);
872
+ if (pool === undefined || ctx.routingArms.length === 0 || pool.candidates.length < 3) {
873
+ // With only 2 candidates, "demote the loser" would collapse the pool —
874
+ // routing a 1-model pool is meaningless; leave that call entirely human.
875
+ return [];
876
+ }
877
+ const floor = pool.learning?.minSamplesPerArm ?? t.poolMinSamples;
878
+ const fullBands = bandsWithFullCoverage(ctx.routingArms, pool.candidates, floor);
879
+ if (fullBands.length === 0)
880
+ return [];
881
+ const losers = pool.candidates.filter((c) => fullBands.every((band) => {
882
+ const bandArms = ctx.routingArms.filter((a) => a.routeKey === band);
883
+ const best = Math.max(...bandArms.map((a) => a.meanReward));
884
+ const own = bandArms.find((a) => a.model === c.model);
885
+ return own !== undefined && own.meanReward <= best - t.poolDemotionGap;
886
+ }));
887
+ return losers.map((loser) => {
888
+ const detail = fullBands
889
+ .map((band) => {
890
+ const bandArms = ctx.routingArms.filter((a) => a.routeKey === band);
891
+ const best = Math.max(...bandArms.map((a) => a.meanReward));
892
+ const own = bandArms.find((a) => a.model === loser.model);
893
+ return `${band}: ${own?.meanReward.toFixed(3)} vs best ${best.toFixed(3)}`;
894
+ })
895
+ .join("; ");
896
+ return {
897
+ id: `pool-candidate-demotion:${loser.model}`,
898
+ severity: "info",
899
+ summary: `model_pool candidate ${loser.model} consistently loses`,
900
+ evidence: [
901
+ `mean reward trails the band best by ≥${t.poolDemotionGap} in every full-coverage band (${detail})`,
902
+ "the candidate roster is human-owned — remove it from model_pool.candidates by hand if the trend holds",
903
+ ],
904
+ counts: { fullBands: fullBands.length },
905
+ suggestion: {
906
+ kind: "advice",
907
+ text: `Candidate ${loser.model} has enough samples and still trails the best arm by ≥${t.poolDemotionGap} mean reward in every measured band (${detail}). Consider removing it from model_pool.candidates — fewer arms also means less exploration spend. This is a roster change, so it is never auto-applied.`,
908
+ },
909
+ };
910
+ });
911
+ };
912
+ /**
913
+ * Stale exploitation: a `learned` pool whose scoreboard has full coverage but
914
+ * whose config never explores again — `explorationRate` unset/0 and the
915
+ * bandit is not `thompson` (which self-explores via posterior sampling). Once
916
+ * every arm clears the floor, such a pool hard-commits to the argmax forever
917
+ * and can never notice model drift or a candidate that improved. Proposes a
918
+ * small ε (0.05) via a whole-`learning`-block replace that PRESERVES the
919
+ * spec's other learning fields (whitelisted path; `optimize --from-advice`
920
+ * eval-gates it).
921
+ */
922
+ export const rulePoolStaleExploitation = (ctx, opts) => {
923
+ const t = resolveThresholds(opts);
924
+ const spec = opts?.spec;
925
+ const pool = agentModelPool(spec);
926
+ if (pool === undefined || pool.policy !== "learned")
927
+ return [];
928
+ if (ctx.routingArms.length === 0 || pool.candidates.length < 2)
929
+ return [];
930
+ const learning = pool.learning ?? {};
931
+ // Already exploring (ε > 0) or self-exploring (thompson) → healthy.
932
+ if ((learning.explorationRate ?? 0) > 0 || learning.bandit === "thompson")
933
+ return [];
934
+ const floor = learning.minSamplesPerArm ?? t.poolMinSamples;
935
+ const ready = bandsWithFullCoverage(ctx.routingArms, pool.candidates, floor);
936
+ if (ready.length === 0)
937
+ return []; // still warming up — the floor explores for it
938
+ const proposedLearning = { ...learning, explorationRate: 0.05 };
939
+ const learningKeyPresent = opts?.specHasPath?.(["agent", "model_pool", "learning"]) ?? false;
940
+ const adviceText = "This learned model_pool has cleared its sample floor in every measured band and now exploits the argmax forever — it can no longer notice model drift or an improved candidate. Set model_pool.learning.explorationRate (e.g. 0.05) to keep sampling occasionally, or switch learning.bandit to thompson, which self-balances exploration.";
941
+ const patch = {
942
+ target: spec?.target ?? "cli",
943
+ path: ["agent", "model_pool", "learning"],
944
+ op: learningKeyPresent ? "replace" : "add",
945
+ value: proposedLearning,
946
+ rationale: `advise: learned pool converged (full coverage in band(s) ${ready.join(", ")}) with explorationRate 0 and a non-thompson bandit — pure exploitation goes stale`,
947
+ };
948
+ return [
949
+ {
950
+ id: "pool-stale-exploitation",
951
+ severity: "info",
952
+ summary: "converged learned model_pool never explores — add explorationRate",
953
+ evidence: [
954
+ `full-coverage band(s) at floor ${floor}: ${ready.join(", ")}`,
955
+ `learning.explorationRate is ${learning.explorationRate ?? "unset"} and bandit is ${learning.bandit ?? "epsilon-greedy"}`,
956
+ ],
957
+ counts: { readyBands: ready.length },
958
+ suggestion: patchOrAdvice(spec, patch, adviceText),
959
+ },
960
+ ];
961
+ };
801
962
  export const ADVICE_RULES = [
802
963
  ruleRepeatedToolFailures,
803
964
  ruleTruncationPressure,
@@ -807,6 +968,9 @@ export const ADVICE_RULES = [
807
968
  ruleFailureTaxonomy,
808
969
  ruleLoopBreak,
809
970
  ruleSubAgentSplit,
971
+ rulePoolPolicyUpgrade,
972
+ rulePoolCandidateDemotion,
973
+ rulePoolStaleExploitation,
810
974
  ];
811
975
  /**
812
976
  * Run every rule and rank the findings: warn before info, then by the
@@ -102,8 +102,9 @@ export type EgressBlock = {
102
102
  };
103
103
  /**
104
104
  * Extract egress-block candidates from parsed audit records. The audit log MAY
105
- * carry no `egress_decision` records at all (no writer yet) this returns []
106
- * in that case. A block is any record whose verdict is not an allow. Because
105
+ * carry no `egress_decision` records at all (they are written only on warn/block
106
+ * verdicts) — this returns [] in that case. A block is any record whose verdict
107
+ * is not an allow. Because
107
108
  * the audit payload does not reliably carry a turn input, these become
108
109
  * standalone candidates keyed by a best-effort session id + the lineage
109
110
  * summary; the CLI attaches them to the matching session's LAST turn input
@@ -173,8 +173,9 @@ export function mineSession(sessionId, events) {
173
173
  }
174
174
  /**
175
175
  * Extract egress-block candidates from parsed audit records. The audit log MAY
176
- * carry no `egress_decision` records at all (no writer yet) this returns []
177
- * in that case. A block is any record whose verdict is not an allow. Because
176
+ * carry no `egress_decision` records at all (they are written only on warn/block
177
+ * verdicts) — this returns [] in that case. A block is any record whose verdict
178
+ * is not an allow. Because
178
179
  * the audit payload does not reliably carry a turn input, these become
179
180
  * standalone candidates keyed by a best-effort session id + the lineage
180
181
  * summary; the CLI attaches them to the matching session's LAST turn input
package/dist/index.js CHANGED
@@ -23,11 +23,16 @@ import { createLogger } from "@crewhaus/logging";
23
23
  import { McpHost } from "@crewhaus/mcp-host";
24
24
  import { captureFacts, createMemoryStore, deriveMemoryDecision, summarizeDurableFacts, } from "@crewhaus/memory-store";
25
25
  import { BUILTIN_DEFAULT_RULES, PermissionConfigError, parsePermissionsConfig, tagRules, } from "@crewhaus/permission-engine";
26
+ // Adaptive model routing — `crewhaus route status|reset` inspects/clears the
27
+ // per-(routeKey, model) reward scoreboard behind `agent.model_pool`; advise
28
+ // mines the same scoreboard into pool-policy suggestions.
29
+ import { openScoreboard } from "@crewhaus/routing-store";
26
30
  import { runChatLoop } from "@crewhaus/runtime-core";
27
31
  import { createSessionStore, evictExpiredSessions } from "@crewhaus/session-store";
28
32
  import { createSkillTool, discoverSkills } from "@crewhaus/skills-registry";
29
33
  import { loadCommands } from "@crewhaus/slash-commands";
30
34
  import { parseSpec } from "@crewhaus/spec";
35
+ import { specHasPath } from "@crewhaus/spec-patch";
31
36
  import { spawnSubAgent } from "@crewhaus/sub-agent-spawner";
32
37
  import { ToolCatalog } from "@crewhaus/tool-catalog";
33
38
  import { registerMcpServer } from "@crewhaus/tool-mcp";
@@ -260,6 +265,7 @@ import { RetireError, buildRetirementPlan, formatPlan, formatRetirementResult, r
260
265
  // Item 25 — model right-sizing downshift search core (pure enumeration + cost
261
266
  // projection + $/score ranking); side-effect-free so it is unit-testable.
262
267
  import { buildRightSizeReport, enumerateSlotCandidates, } from "./right-size";
268
+ import { runRoute } from "./route";
263
269
  // Item 13 — `crewhaus scaffold-evals` + `init --with-evals`: day-one eval
264
270
  // assets generated from the spec (deterministic template / one-shot model
265
271
  // sample stubs + a single spec-goal llm_judge or floor grader), in a
@@ -453,6 +459,12 @@ const INIT_SCHEMA = {
453
459
  // Overwrite an existing scaffolded workflow or eval assets (never the
454
460
  // spec).
455
461
  { name: "force", takesValue: false },
462
+ // Item 39 — interactive spec authoring: interview the user (via the
463
+ // resolved model, or a scripted stdin questionnaire when no credentials)
464
+ // and emit a parseSpec-validated crewhaus.yaml. Composes with --detect to
465
+ // prefill the model from what's reachable.
466
+ { name: "interactive", short: "i", takesValue: false },
467
+ { name: "detect", takesValue: false },
456
468
  { name: "help", short: "h" },
457
469
  ],
458
470
  };
@@ -491,12 +503,6 @@ const GRADERS_SUGGEST_SCHEMA = {
491
503
  { name: "min-score", takesValue: true },
492
504
  // Overwrite an existing review file (default: refuse).
493
505
  { name: "force", takesValue: false },
494
- // Item 39 — interactive spec authoring: interview the user (via the
495
- // resolved model, or a scripted stdin questionnaire when no credentials)
496
- // and emit a parseSpec-validated crewhaus.yaml. Composes with --detect to
497
- // prefill the model from what's reachable.
498
- { name: "interactive", short: "i", takesValue: false },
499
- { name: "detect", takesValue: false },
500
506
  { name: "help", short: "h" },
501
507
  ],
502
508
  };
@@ -529,6 +535,17 @@ const DOCTOR_SCHEMA = {
529
535
  // Container-HEALTHCHECK exit semantics (0 within/no-data, 1 breach).
530
536
  { name: "slo", takesValue: false },
531
537
  { name: "ttft", takesValue: false },
538
+ // Item 40 — `--detect`: read-only inventory of reachable providers, the
539
+ // local Ollama/vLLM endpoint's models, and MCP servers from
540
+ // .mcp.json / Claude Desktop config. `--no-probe` skips the localhost HTTP
541
+ // probe (offline / CI).
542
+ { name: "detect", takesValue: false },
543
+ { name: "no-probe", takesValue: false },
544
+ // Item 40 — `--fix`: apply the mechanical remediations doctor otherwise
545
+ // only prints (scaffold crewhaus.yaml, create .crewhaus/, mark outward
546
+ // tools scope:external, append commented .env stubs). Dry-run is the
547
+ // DEFAULT: without --fix, doctor prints the diff it WOULD apply.
548
+ { name: "fix", takesValue: false },
532
549
  { name: "help", short: "h" },
533
550
  ],
534
551
  };
@@ -1343,7 +1360,7 @@ function usageText() {
1343
1360
  " [--watch] re-validate on change (authoring aid; does not re-launch)",
1344
1361
  " [--resume <id>] resume a specific session (cli targets only)",
1345
1362
  " [--continue] resume the most-recent session (cli targets only)",
1346
- " [--prompt <text>] initial user prompt (browser targets; defaults to stdin)",
1363
+ " [--prompt <text>] run one turn and exit, printing the reply (cli: no REPL; browser: the single-turn input, else stdin)",
1347
1364
  " [--justification-judge rule-based|claude] Pillar 3 intent-gate judge (FR-004)",
1348
1365
  " [--egress-matcher substring|semantic] Pillar 3 sink-side matcher (FR-006)",
1349
1366
  " [--egress-embedder <model>] embedder for --egress-matcher semantic",
@@ -1410,6 +1427,9 @@ function usageText() {
1410
1427
  " context --bundle [-o <file>] emit a single-markdown orientation manifest",
1411
1428
  " [--factory-root <p>] [--docs-root <p>] [--demos-root <p>]",
1412
1429
  " cost-summary --session <id> summarize cost_accrual events for a session",
1430
+ " route status [--dir <root>] show the adaptive model_pool reward scoreboard",
1431
+ " route explain <session> [--dir <r>] replay a run's per-turn model_route decisions",
1432
+ " route reset [--dir <root>] wipe the scoreboard (default root .crewhaus)",
1413
1433
  " advise [--session <id> | --all] mine session logs for spec advice (item 14)",
1414
1434
  " [--json] [-o <dir>] writes suggestions.json + report.html (default .crewhaus/advice)",
1415
1435
  " tools list list every builtin tool + its metadata (item 18)",
@@ -2709,6 +2729,8 @@ async function loadToolMap() {
2709
2729
  glob: fs.glob,
2710
2730
  grep: fs.grep,
2711
2731
  bash: bash.bash,
2732
+ bashOutput: bash.bashOutput,
2733
+ killShell: bash.killShell,
2712
2734
  todoWrite: todo.todoWrite,
2713
2735
  webFetch: web.webFetch,
2714
2736
  webSearch: web.webSearch,
@@ -2887,6 +2909,7 @@ async function resolveEgressMatcher(args, irEgressMatcher) {
2887
2909
  async function runRun(args) {
2888
2910
  if (args.flags["help"]) {
2889
2911
  process.stdout.write("usage: crewhaus run <spec.yaml> [--model <model>] [--permission-mode <default|plan|auto|bypass>] [--resume <sessionId> | --continue] [--prompt <text>] [--justification-judge rule-based|claude] [--egress-matcher substring|semantic] [--egress-embedder <model>]\n" +
2912
+ " --prompt <text> runs a single turn non-interactively and prints the reply, then exits (no REPL) — for scripting/CI; composes with --resume/--continue\n" +
2890
2913
  " --model accepts the full router grammar: claude-* (Anthropic), openai/<m>, gemini/<m>, bedrock/<id> (geo prefixes tolerated), local/<m>@<url>\n" +
2891
2914
  " A spec with a feedback: block asks `rate this session? [g]ood / [b]ad / [enter] skip`\n" +
2892
2915
  " on clean REPL exit (one keystroke, 10s timeout, TTY only — never when piped). Opt out\n" +
@@ -2962,6 +2985,19 @@ async function runRunCli(args, ir, specPath) {
2962
2985
  resumeId = match.id;
2963
2986
  process.stdout.write(`[continue] resuming session ${match.id} (last updated ${match.updatedAt})\n`);
2964
2987
  }
2988
+ // `--prompt <text>` runs ONE turn non-interactively and exits (no REPL) —
2989
+ // the cli analogue of the browser target's --prompt and `claude -p`, for
2990
+ // scripting/CI/pipelines. The final assistant message is written to stdout.
2991
+ // Composes with --resume/--continue (one more turn on a resumed session).
2992
+ const promptFlag = args.flags["prompt"];
2993
+ let oneShotPrompt;
2994
+ if (typeof promptFlag === "string") {
2995
+ const trimmed = promptFlag.trim();
2996
+ if (trimmed.length === 0) {
2997
+ die("--prompt requires non-empty text");
2998
+ }
2999
+ oneShotPrompt = trimmed;
3000
+ }
2965
3001
  let tools = [];
2966
3002
  if (ir.tools.length > 0) {
2967
3003
  // Section 14 — apply per-tool config (e.g. registerFetchConfig) before
@@ -3356,8 +3392,9 @@ async function runRunCli(args, ir, specPath) {
3356
3392
  process.stdout.write(`[sandbox] backend "${process.env["CREWHAUS_SANDBOX"]}" — python/javascript/shell enabled (still require an alwaysAllow rule)\n`);
3357
3393
  }
3358
3394
  }
3395
+ let oneShotResult;
3359
3396
  try {
3360
- await runChatLoop({
3397
+ oneShotResult = await runChatLoop({
3361
3398
  model,
3362
3399
  instructions: mcpQuarantineNotice !== undefined
3363
3400
  ? `${ir.agent.instructions}\n\n${mcpQuarantineNotice}`
@@ -3370,11 +3407,24 @@ async function runRunCli(args, ir, specPath) {
3370
3407
  hooks,
3371
3408
  skills,
3372
3409
  slashCommands,
3410
+ // `--prompt` → run exactly one turn from the seeded prompt and return
3411
+ // its final message (no interactive REPL). runChatLoop's singleTurn
3412
+ // mode also suppresses the `you>` / `agent ready` chrome.
3413
+ ...(oneShotPrompt !== undefined
3414
+ ? { singleTurn: true, seedMessages: [{ role: "user", content: oneShotPrompt }] }
3415
+ : {}),
3373
3416
  ...(hasCodeExecTools ? { sandboxAvailable } : {}),
3374
3417
  ...(subAgents !== undefined ? { subAgents, spawnSubAgent } : {}),
3375
3418
  ...(ir.target === "cli" && ir.agent.maxTokens !== undefined
3376
3419
  ? { maxTokens: ir.agent.maxTokens }
3377
3420
  : {}),
3421
+ // Thread `compaction.model` so auto-compaction summarizes on the
3422
+ // spec's chosen (typically cheaper) model instead of the primary.
3423
+ // The compiler already resolves the `cheapest` sentinel to a concrete
3424
+ // id, so this is a raw model string. Mirrors the target-cli emitter.
3425
+ ...(ir.target === "cli" && ir.compaction.model !== undefined
3426
+ ? { compactionModel: ir.compaction.model }
3427
+ : {}),
3378
3428
  // Item 22 — provider failover chain: thread `agent.model_fallbacks` +
3379
3429
  // `agent.circuit_breaker` through to the runtime, mirroring the
3380
3430
  // target-cli codegen path. Skipped when `--model` overrides the
@@ -3396,6 +3446,16 @@ async function runRunCli(args, ir, specPath) {
3396
3446
  ir.agent.modelTiers !== undefined
3397
3447
  ? { modelTiers: ir.agent.modelTiers }
3398
3448
  : {}),
3449
+ // Adaptive model routing — thread `agent.model_pool` so `crewhaus run`
3450
+ // routes (and learns) exactly like the compiled cli bundle, not just the
3451
+ // single primary. Mutually exclusive with tiers/fallbacks in the spec;
3452
+ // a `--model` override forces a single model, so the pool applies only
3453
+ // without an override.
3454
+ ...(ir.target === "cli" &&
3455
+ typeof modelOverride !== "string" &&
3456
+ ir.agent.modelPool !== undefined
3457
+ ? { modelPool: ir.agent.modelPool }
3458
+ : {}),
3399
3459
  // Section 55 / item 23 — thread the spec's failure_taxonomy so
3400
3460
  // recovery-engine consults the user's named error classes (including
3401
3461
  // the `switch-model` verdict) before its built-in flow.
@@ -3428,6 +3488,12 @@ async function runRunCli(args, ir, specPath) {
3428
3488
  if (mcpHost)
3429
3489
  await mcpHost.disconnectAll();
3430
3490
  }
3491
+ // One-shot (`--prompt`): emit the final assistant message to stdout so it
3492
+ // can be piped/captured. The REPL path streams as it goes and returns the
3493
+ // same trailing text, so only print it in one-shot mode.
3494
+ if (oneShotPrompt !== undefined && oneShotResult !== undefined) {
3495
+ process.stdout.write(`${oneShotResult}\n`);
3496
+ }
3431
3497
  // Item 1 — post-session feedback teardown: the one-keystroke exit rating
3432
3498
  // prompt (TTY only) and the feedback.autoDistill consumer. Runs only on a
3433
3499
  // clean REPL exit (runChatLoop returned; a throw above skips it) and is
@@ -7616,11 +7682,13 @@ async function runAdvise(args) {
7616
7682
  if (args.flags["help"]) {
7617
7683
  process.stdout.write("usage: crewhaus advise [--session <id> | --all] [--json] [-o <dir>]\n" +
7618
7684
  "\n" +
7619
- "mines .crewhaus/sessions (+ .crewhaus/audit) for spec advice:\n" +
7685
+ "mines .crewhaus/sessions (+ .crewhaus/audit + .crewhaus/routing) for spec advice:\n" +
7620
7686
  " repeated tool failures, max_tokens truncation pressure, compaction\n" +
7621
7687
  " thrash, permission-ask churn, stop-reason anomalies, learned\n" +
7622
7688
  " failure_taxonomy + loop-break rules, sub-agent splits under\n" +
7623
- " chronic context pressure\n" +
7689
+ " chronic context pressure, and model_pool scoreboard mining\n" +
7690
+ " (flip policy to learned once every candidate has enough samples;\n" +
7691
+ " name consistently-losing candidates — roster edits stay human)\n" +
7624
7692
  "\n" +
7625
7693
  " --session <id> mine one session (default: all sessions)\n" +
7626
7694
  " --json print machine-readable findings to stdout\n" +
@@ -7670,17 +7738,34 @@ async function runAdvise(args) {
7670
7738
  // The cwd spec enables patch suggestions; without one (or with a broken
7671
7739
  // one) rules fall back to advice text — mining must not block on it.
7672
7740
  let spec;
7741
+ let specText;
7673
7742
  const specPath = join(process.cwd(), "crewhaus.yaml");
7674
7743
  if (existsSync(specPath)) {
7675
7744
  try {
7676
- spec = parseSpec(readFileSync(specPath, "utf-8"));
7745
+ specText = readFileSync(specPath, "utf-8");
7746
+ spec = parseSpec(specText);
7677
7747
  }
7678
7748
  catch (err) {
7679
7749
  process.stderr.write(`[advise] crewhaus.yaml did not parse (${err.message}) — patch suggestions downgraded to advice\n`);
7680
7750
  }
7681
7751
  }
7682
- const ctx = buildAdviceContext(sessions, auditObjects);
7683
- const findings = runAdviceRules(ctx, spec !== undefined ? { spec } : {});
7752
+ // Adaptive model routing — the pool reward scoreboard, when the harness has
7753
+ // one. A missing/empty store simply produces no routing findings.
7754
+ let routingArms = [];
7755
+ try {
7756
+ routingArms = openScoreboard(join(process.cwd(), ".crewhaus")).snapshot();
7757
+ }
7758
+ catch {
7759
+ // Corrupt store must not block mining the sessions.
7760
+ }
7761
+ const ctx = buildAdviceContext(sessions, auditObjects, routingArms);
7762
+ const specTextForOps = specText;
7763
+ const findings = runAdviceRules(ctx, {
7764
+ ...(spec !== undefined ? { spec } : {}),
7765
+ ...(spec !== undefined && specTextForOps !== undefined
7766
+ ? { specHasPath: (path) => specHasPath(specTextForOps, path) }
7767
+ : {}),
7768
+ });
7684
7769
  const generatedAt = new Date().toISOString();
7685
7770
  const suggestions = buildSuggestionsFile(findings, ctx.sessionIds, generatedAt);
7686
7771
  const outDir = strFlag(args, "out") ?? join(process.cwd(), ".crewhaus", "advice");
@@ -12949,6 +13034,18 @@ switch (subcommand) {
12949
13034
  }
12950
13035
  break;
12951
13036
  }
13037
+ case "route":
13038
+ // Adaptive model routing — inspect/reset the reward scoreboard. `runRoute`
13039
+ // throws a plain Error on a bad argument; surface it through die().
13040
+ try {
13041
+ process.stdout.write(`${runRoute(rest)}\n`);
13042
+ }
13043
+ catch (err) {
13044
+ if (err instanceof Error)
13045
+ die(err.message);
13046
+ throw err;
13047
+ }
13048
+ break;
12952
13049
  case "context":
12953
13050
  runContext(parseFor(rest, CONTEXT_SCHEMA));
12954
13051
  break;
@@ -0,0 +1,39 @@
1
+ import { type ArmStats } from "@crewhaus/routing-store";
2
+ export type RouteArgs = {
3
+ readonly sub: "status" | "reset" | "explain";
4
+ readonly dir: string;
5
+ /** Session id — required for `explain`. */
6
+ readonly session?: string;
7
+ };
8
+ /** Parse `route <sub> [<session>] [--dir <root>]` argv (everything after `route`). */
9
+ export declare function parseRouteArgs(argv: readonly string[]): RouteArgs;
10
+ /** Snapshot the scoreboard arms (sorted routeKey then model). */
11
+ export declare function loadArms(rootDir: string): ArmStats[];
12
+ /**
13
+ * Render the scoreboard as a human table: grouped by routeKey, best mean-reward
14
+ * arm first within each bucket and starred (the arm a `learned` policy would
15
+ * exploit once every arm clears its sample floor).
16
+ */
17
+ export declare function formatRouteStatus(arms: readonly ArmStats[]): string;
18
+ /** Wipe the whole scoreboard. Returns the number of arms removed. */
19
+ export declare function resetRouting(rootDir: string): number;
20
+ /** One persisted `model_route` decision from a session log. */
21
+ export type RouteDecision = {
22
+ readonly turnNumber?: number;
23
+ readonly routeKey: string;
24
+ readonly model: string;
25
+ readonly policy: string;
26
+ readonly reason: string;
27
+ readonly explored?: boolean;
28
+ readonly policyVersion?: string;
29
+ };
30
+ /**
31
+ * Read the `model_route` decisions persisted in a session's event log at
32
+ * `<rootDir>/sessions/<sessionId>.jsonl`, in turn order. A missing log yields
33
+ * `[]`; a malformed line is skipped (the log is best-effort observability).
34
+ */
35
+ export declare function readRouteDecisions(rootDir: string, sessionId: string): RouteDecision[];
36
+ /** Render a run's per-turn routing decisions as a table (turn order). */
37
+ export declare function formatRouteExplain(sessionId: string, decisions: readonly RouteDecision[]): string;
38
+ /** Run `crewhaus route …`, returning the text to print. */
39
+ export declare function runRoute(argv: readonly string[]): string;
package/dist/route.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `crewhaus route` — inspect, explain, and reset adaptive model routing
3
+ * (the `agent.model_pool` reward scoreboard + per-turn routing decisions).
4
+ *
5
+ * route status [--dir <root>] show the learned arms, best-per-bucket first
6
+ * route explain <session> [--dir …] replay a run's per-turn model_route decisions
7
+ * route reset [--dir <root>] wipe the scoreboard (kill switch)
8
+ *
9
+ * `--dir` points at the `.crewhaus` root (default `.crewhaus`); the scoreboard
10
+ * lives at `<root>/routing/arms.jsonl` and session logs at
11
+ * `<root>/sessions/<id>.jsonl`. `status` surfaces the ACCUMULATED learning a
12
+ * `learned` policy exploits; `explain` shows WHY each turn of one run picked
13
+ * the model it did (including ε-greedy exploration draws).
14
+ */
15
+ import { existsSync, readFileSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { openScoreboard } from "@crewhaus/routing-store";
18
+ const DEFAULT_ROOT = ".crewhaus";
19
+ /** Parse `route <sub> [<session>] [--dir <root>]` argv (everything after `route`). */
20
+ export function parseRouteArgs(argv) {
21
+ let dir = DEFAULT_ROOT;
22
+ let sub;
23
+ let session;
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === undefined)
27
+ continue;
28
+ if (a === "--dir") {
29
+ const v = argv[i + 1];
30
+ if (v === undefined)
31
+ throw new Error("route: --dir requires a path");
32
+ dir = v;
33
+ i++;
34
+ }
35
+ else if (sub === undefined && (a === "status" || a === "reset" || a === "explain")) {
36
+ // A subcommand keyword is only the subcommand when it comes FIRST; after
37
+ // that the same word is a plain positional (so `route explain status`
38
+ // explains a session literally named "status", not runs `status`).
39
+ sub = a;
40
+ }
41
+ else if (sub === "explain" && session === undefined && !a.startsWith("--")) {
42
+ session = a; // the session id positional (only `explain` takes one)
43
+ }
44
+ else {
45
+ throw new Error(`route: unknown argument "${a}" (expected: status | reset | explain <session> [--dir <root>])`);
46
+ }
47
+ }
48
+ if (sub === undefined) {
49
+ throw new Error("route: expected a subcommand (status | explain <session> | reset)");
50
+ }
51
+ if (sub === "explain" && session === undefined) {
52
+ throw new Error("route explain: a <session> id is required");
53
+ }
54
+ return { sub, dir, ...(session !== undefined ? { session } : {}) };
55
+ }
56
+ /** Snapshot the scoreboard arms (sorted routeKey then model). */
57
+ export function loadArms(rootDir) {
58
+ return openScoreboard(rootDir).snapshot();
59
+ }
60
+ /**
61
+ * Render the scoreboard as a human table: grouped by routeKey, best mean-reward
62
+ * arm first within each bucket and starred (the arm a `learned` policy would
63
+ * exploit once every arm clears its sample floor).
64
+ */
65
+ export function formatRouteStatus(arms) {
66
+ if (arms.length === 0) {
67
+ return "No routing data yet. Run a harness whose spec declares `agent.model_pool` to accumulate arms.";
68
+ }
69
+ const byKey = new Map();
70
+ for (const a of arms) {
71
+ const g = byKey.get(a.routeKey) ?? [];
72
+ g.push(a);
73
+ byKey.set(a.routeKey, g);
74
+ }
75
+ const lines = [];
76
+ lines.push(`${"routeKey".padEnd(9)} ${"model".padEnd(28)} ${"n".padStart(5)} ${"reward".padStart(7)} ${"latency".padStart(9)} ${"cost".padStart(10)}`);
77
+ for (const key of [...byKey.keys()].sort()) {
78
+ const group = (byKey.get(key) ?? []).slice().sort((x, y) => y.meanReward - x.meanReward);
79
+ group.forEach((a, i) => {
80
+ const star = i === 0 && a.n > 0 ? " *" : "";
81
+ const latency = `${a.meanLatencyMs.toFixed(0)}ms`;
82
+ const cost = a.meanCostUsd > 0 ? `$${a.meanCostUsd.toFixed(5)}` : "-";
83
+ lines.push(`${key.padEnd(9)} ${a.model.padEnd(28)} ${String(a.n).padStart(5)} ${a.meanReward.toFixed(3).padStart(7)} ${latency.padStart(9)} ${cost.padStart(10)}${star}`);
84
+ });
85
+ }
86
+ lines.push("");
87
+ lines.push("* = current best arm in its bucket (what a `learned` policy exploits once every arm clears its sample floor).");
88
+ return lines.join("\n");
89
+ }
90
+ /** Wipe the whole scoreboard. Returns the number of arms removed. */
91
+ export function resetRouting(rootDir) {
92
+ const removed = loadArms(rootDir).length;
93
+ const path = join(rootDir, "routing", "arms.jsonl");
94
+ if (existsSync(path))
95
+ rmSync(path, { force: true });
96
+ return removed;
97
+ }
98
+ /**
99
+ * Read the `model_route` decisions persisted in a session's event log at
100
+ * `<rootDir>/sessions/<sessionId>.jsonl`, in turn order. A missing log yields
101
+ * `[]`; a malformed line is skipped (the log is best-effort observability).
102
+ */
103
+ export function readRouteDecisions(rootDir, sessionId) {
104
+ // Guard against a hostile session id escaping the sessions dir (e.g.
105
+ // `../../etc/passwd`). Real ids are `sess_<hex>`; anything with a path
106
+ // separator or `..` is rejected outright.
107
+ if (/[/\\]/.test(sessionId) || sessionId.includes("..") || sessionId.length === 0) {
108
+ throw new Error(`route explain: invalid session id "${sessionId}"`);
109
+ }
110
+ const path = join(rootDir, "sessions", `${sessionId}.jsonl`);
111
+ if (!existsSync(path))
112
+ return [];
113
+ const out = [];
114
+ for (const line of readFileSync(path, "utf8").split("\n")) {
115
+ const trimmed = line.trim();
116
+ if (trimmed.length === 0)
117
+ continue;
118
+ let rec;
119
+ try {
120
+ rec = JSON.parse(trimmed);
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (rec.kind !== "model_route" || typeof rec.payload !== "object" || rec.payload === null) {
126
+ continue;
127
+ }
128
+ const p = rec.payload;
129
+ if (typeof p["routeKey"] !== "string" || typeof p["model"] !== "string")
130
+ continue;
131
+ out.push({
132
+ ...(typeof p["turnNumber"] === "number" ? { turnNumber: p["turnNumber"] } : {}),
133
+ routeKey: p["routeKey"],
134
+ model: p["model"],
135
+ policy: typeof p["policy"] === "string" ? p["policy"] : "?",
136
+ reason: typeof p["reason"] === "string" ? p["reason"] : "",
137
+ ...(typeof p["explored"] === "boolean" ? { explored: p["explored"] } : {}),
138
+ ...(typeof p["policyVersion"] === "string" ? { policyVersion: p["policyVersion"] } : {}),
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+ /** Render a run's per-turn routing decisions as a table (turn order). */
144
+ export function formatRouteExplain(sessionId, decisions) {
145
+ if (decisions.length === 0) {
146
+ return `No model_route decisions recorded for session ${sessionId}. (Only runs whose spec declares \`agent.model_pool\` persist routing decisions.)`;
147
+ }
148
+ const lines = [`session ${sessionId} — ${decisions.length} routing decision(s):`, ""];
149
+ lines.push(`${"turn".padStart(4)} ${"band".padEnd(5)} ${"model".padEnd(28)} ${"policy".padEnd(10)} ${"pick".padEnd(9)} reason`);
150
+ for (const d of decisions) {
151
+ const turn = d.turnNumber !== undefined ? String(d.turnNumber) : "-";
152
+ const pick = d.explored ? "explore" : "exploit";
153
+ lines.push(`${turn.padStart(4)} ${d.routeKey.padEnd(5)} ${d.model.padEnd(28)} ${d.policy.padEnd(10)} ${pick.padEnd(9)} ${d.reason}`);
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+ /** Run `crewhaus route …`, returning the text to print. */
158
+ export function runRoute(argv) {
159
+ const args = parseRouteArgs(argv);
160
+ if (args.sub === "status")
161
+ return formatRouteStatus(loadArms(args.dir));
162
+ if (args.sub === "explain") {
163
+ const session = args.session; // parseRouteArgs guarantees it for explain
164
+ return formatRouteExplain(session, readRouteDecisions(args.dir, session));
165
+ }
166
+ const removed = resetRouting(args.dir);
167
+ return `Reset routing scoreboard at ${join(args.dir, "routing", "arms.jsonl")} (${removed} arm${removed === 1 ? "" : "s"} removed).`;
168
+ }
@@ -73,7 +73,8 @@ export type SecurityDigest = {
73
73
  readonly malformedLines: number;
74
74
  readonly countsByKind: Readonly<Record<string, number>>;
75
75
  /** Kinds @crewhaus/audit-log declares that produced 0 window records —
76
- * today that inevitably includes the writerless kinds (see header). */
76
+ * always includes the still-writerless kinds, plus any writerful kind
77
+ * (e.g. egress_decision) that simply had no records in the window. */
77
78
  readonly absentDeclaredKinds: ReadonlyArray<string>;
78
79
  };
79
80
  /** Pillar 3 intent gate — from `permission_justification_evaluated`. */
@@ -25,6 +25,12 @@ import { join, resolve } from "node:path";
25
25
  * - policy_decision policy-engine's auditPolicyDecision (payload
26
26
  * { toolName, sideEffect, decision, reason, tenantId, matchedRule }) —
27
27
  * deny/audit-and-allow rows only by default.
28
+ * - egress_decision runtime-core's egress classifier (AUTOMATION-
29
+ * OPPORTUNITIES item 20; payload { sinkId, sinkScope, verdict,
30
+ * originsFound, matchCount }) — appended durably on every NON-PASS
31
+ * (warn/block) verdict when the egress audit sink is wired, so the
32
+ * rollup carries warn/block egress history; pass verdicts stay
33
+ * ephemeral on the trace bus and leave no audit record.
28
34
  * - model_call, gateway_request, secrets_rotation, secrets_access,
29
35
  * deployment_action, retention_enforcement, alert_raised (item-31
30
36
  * alert watchdog's `AlertAuditSink`, see alert-sink.ts; payload
@@ -33,15 +39,14 @@ import { join, resolve } from "node:path";
33
39
  * but builds no verdict rollup from them.
34
40
  *
35
41
  * kinds DECLARED in @crewhaus/audit-log but with NO writer anywhere in the
36
- * tree today: `egress_decision`, `tool_classification`, `session_fork`,
37
- * `tenancy_context`. Egress verdicts currently surface ONLY as ephemeral
38
- * `permission_decision` trace events (outcome: egress-*) runtime-core
39
- * never appends the documented `egress_decision` audit record. The digest
40
- * still aggregates `egress_decision` records by the payload shape the
41
- * audit-log header documents ({ sinkId, sinkScope, verdict, originsFound,
42
- * matchCount }) so the rollup lights up the day a writer lands, and it
43
- * reports these kinds under `absentDeclaredKinds` so "0 egress rows"
44
- * reads as "no durable writer yet", not "no egress happened".
42
+ * tree today: `tool_classification`, `session_fork`, `tenancy_context`. The
43
+ * digest still aggregates records of these kinds by their documented payload
44
+ * shape so the rollup lights up the day a writer lands, and reports them
45
+ * under `absentDeclaredKinds` so "0 rows" reads as "no durable writer yet",
46
+ * not "nothing happened". (egress_decision graduated to the writer list
47
+ * above once runtime-core began appending it on warn/block verdicts; a
48
+ * window with no warn/block egress still lists it as absent meaning "no
49
+ * warn/block egress in the window", not "no writer".)
45
50
  *
46
51
  * Because block-tier verdicts DO leave durable residue in the session event
47
52
  * logs (`.crewhaus/sessions/<id>.jsonl` `tool_result` events carry the
@@ -210,7 +215,8 @@ export function buildSecurityDigest(opts) {
210
215
  let jDenied = 0;
211
216
  const deniedTools = new Map();
212
217
  const judges = new Map();
213
- // Egress rollup accumulators (no writer today see module header).
218
+ // Egress rollup accumulators fed by egress_decision records (written on
219
+ // warn/block verdicts; see module header).
214
220
  let ePassed = 0;
215
221
  let eWarned = 0;
216
222
  let eBlocked = 0;
@@ -480,7 +486,7 @@ export function renderSecurityDigestText(d) {
480
486
  lines.push(` • origin ${o.name}: ${o.count} hit(s)`);
481
487
  }
482
488
  else {
483
- lines.push("egress: no durable egress_decision records the kind is declared but has no writer yet; live verdicts surface per-run via CREWHAUS_SECURITY_DIGEST=1");
489
+ lines.push("egress: no egress_decision records in window records are written on warn/block verdicts; live per-run verdicts via CREWHAUS_SECURITY_DIGEST=1");
484
490
  }
485
491
  lines.push(`policy engine: ${d.policy.decisions} audited decision(s) — ${d.policy.denied} denied, ${d.policy.auditAndAllow} audit-and-allow`);
486
492
  for (const t of d.policy.topDeniedTools)
@@ -576,7 +582,7 @@ ${tableOf(["Tool", "Denials", "Judges", "Mean confidence", "Last reason"], d.jus
576
582
  ${tableOf(["Judge", "Evaluated", "Denied"], d.justification.byJudge.map((j) => [j.judgeModel, String(j.evaluated), String(j.denied)]))}
577
583
  <h2>Egress sinks (warn/block)</h2>
578
584
  ${d.egress.decisions === 0
579
- ? '<p class="note">no durable egress_decision records the kind is declared but has no writer yet; live verdicts surface per-run via CREWHAUS_SECURITY_DIGEST=1</p>'
585
+ ? '<p class="note">no egress_decision records in window records are written on warn/block verdicts; live per-run verdicts via CREWHAUS_SECURITY_DIGEST=1</p>'
580
586
  : tableOf(["Sink", "Warned", "Blocked", "Origins"], d.egress.topSinks.map((s) => [
581
587
  s.sinkId,
582
588
  String(s.warned),
package/dist/tools-cli.js CHANGED
@@ -269,6 +269,8 @@ export const CLI_RUNTIME_TOOL_KEYS = Object.freeze([
269
269
  "glob",
270
270
  "grep",
271
271
  "bash",
272
+ "bashOutput",
273
+ "killShell",
272
274
  "todoWrite",
273
275
  "webFetch",
274
276
  "webSearch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewhaus",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "CrewHaus — the meta-harness compiler for AI agents. Compile one crewhaus.yaml spec into a CLI agent, channel bot, RAG pipeline, multi-agent crew, eval harness, voice or browser agent, and more.",
6
6
  "keywords": [
@@ -31,93 +31,94 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@crewhaus/adapter-anthropic": "0.2.0",
35
- "@crewhaus/agent-context-isolation": "0.2.0",
36
- "@crewhaus/audit-log": "0.2.0",
37
- "@crewhaus/eval-optimizer-orchestrator": "0.2.0",
38
- "@crewhaus/prompt-optimizer": "0.2.0",
39
- "@crewhaus/prompt-optimizer-claude": "0.2.0",
40
- "@crewhaus/justification-judge-claude": "0.2.0",
41
- "@crewhaus/spec-patch": "0.2.0",
42
- "@crewhaus/canary-controller": "0.2.0",
43
- "@crewhaus/compiler": "0.2.0",
44
- "@crewhaus/computer-use-driver": "0.2.0",
45
- "@crewhaus/context-bundle": "0.2.0",
46
- "@crewhaus/cost-tracker": "0.2.0",
47
- "@crewhaus/data-retention-engine": "0.2.0",
48
- "@crewhaus/dataset-registry": "0.2.0",
49
- "@crewhaus/deployment-controller": "0.2.0",
50
- "@crewhaus/egress-classifier": "0.2.0",
51
- "@crewhaus/egress-matcher-semantic": "0.2.0",
52
- "@crewhaus/embedder": "0.2.0",
53
- "@crewhaus/errors": "0.2.0",
54
- "@crewhaus/eval-dataset": "0.2.0",
55
- "@crewhaus/eval-grader": "0.2.0",
56
- "@crewhaus/eval-judge": "0.2.0",
57
- "@crewhaus/eval-report": "0.2.0",
58
- "@crewhaus/eval-runner": "0.2.0",
59
- "@crewhaus/event-log": "0.2.0",
60
- "@crewhaus/gateway-server": "0.2.0",
61
- "@crewhaus/grader-safety-classifiers": "0.2.0",
62
- "@crewhaus/hooks-engine": "0.2.0",
63
- "@crewhaus/infra-utils": "0.2.0",
64
- "@crewhaus/ir": "0.2.0",
65
- "@crewhaus/ir-passes": "0.2.0",
66
- "@crewhaus/logging": "0.2.0",
67
- "@crewhaus/mcp-host": "0.2.0",
68
- "@crewhaus/memory-store": "0.2.0",
69
- "@crewhaus/model-router": "0.2.0",
70
- "@crewhaus/module-marketplace-client": "0.2.0",
71
- "@crewhaus/migration-engine": "0.2.0",
72
- "@crewhaus/migration-runner": "0.2.0",
73
- "@crewhaus/plugin-registry": "0.2.0",
74
- "@crewhaus/plugin-sdk": "0.2.0",
75
- "@crewhaus/permission-engine": "0.2.0",
76
- "@crewhaus/pii-redactor": "0.2.0",
77
- "@crewhaus/prompt-injection-detector": "0.2.0",
78
- "@crewhaus/regression-runner": "0.2.0",
79
- "@crewhaus/run-context": "0.2.0",
80
- "@crewhaus/runtime-core": "0.2.0",
81
- "@crewhaus/secrets-manager": "0.2.0",
82
- "@crewhaus/session-store": "0.2.0",
83
- "@crewhaus/spec-registry": "0.2.0",
84
- "@crewhaus/skills-registry": "0.2.0",
85
- "@crewhaus/slash-commands": "0.2.0",
86
- "@crewhaus/smoke-harness": "0.2.0",
87
- "@crewhaus/spec": "0.2.0",
88
- "@crewhaus/sub-agent-spawner": "0.2.0",
89
- "@crewhaus/template-marketplace-client": "0.2.0",
90
- "@crewhaus/template-registry": "0.2.0",
91
- "@crewhaus/tenancy": "0.2.0",
92
- "@crewhaus/trace-event-bus": "0.2.0",
93
- "@crewhaus/tool-bash": "0.2.0",
94
- "@crewhaus/tool-builder": "0.2.0",
95
- "@crewhaus/target-cli": "0.2.0",
96
- "@crewhaus/target-eval-bundle": "0.2.0",
97
- "@crewhaus/tool-catalog": "0.2.0",
98
- "@crewhaus/tool-code-execution": "0.2.0",
99
- "@crewhaus/tool-codegraph": "0.2.0",
100
- "@crewhaus/tool-fetch": "0.2.0",
101
- "@crewhaus/tool-fs": "0.2.0",
102
- "@crewhaus/tool-image": "0.2.0",
103
- "@crewhaus/tool-image-generation": "0.2.0",
104
- "@crewhaus/tool-document-ingest": "0.2.0",
105
- "@crewhaus/tool-mcp": "0.2.0",
106
- "@crewhaus/tool-memory": "0.2.0",
107
- "@crewhaus/tool-mouse-keyboard": "0.2.0",
108
- "@crewhaus/tool-navigate": "0.2.0",
109
- "@crewhaus/tool-permission-matcher": "0.2.0",
110
- "@crewhaus/tool-screen-capture": "0.2.0",
111
- "@crewhaus/tool-task": "0.2.0",
112
- "@crewhaus/tool-todo": "0.2.0",
113
- "@crewhaus/tool-vision-grounding": "0.2.0",
114
- "@crewhaus/tool-web": "0.2.0",
115
- "@crewhaus/docker-images": "0.2.0",
116
- "@crewhaus/crewhaus-cloud": "0.2.0",
117
- "@crewhaus/federation-discovery": "0.2.0",
118
- "@crewhaus/sandbox": "0.2.0",
119
- "@crewhaus/sandbox-image-registry": "0.2.0",
120
- "@crewhaus/compliance-controls": "0.2.0",
34
+ "@crewhaus/adapter-anthropic": "0.2.2",
35
+ "@crewhaus/agent-context-isolation": "0.2.2",
36
+ "@crewhaus/audit-log": "0.2.2",
37
+ "@crewhaus/eval-optimizer-orchestrator": "0.2.2",
38
+ "@crewhaus/prompt-optimizer": "0.2.2",
39
+ "@crewhaus/prompt-optimizer-claude": "0.2.2",
40
+ "@crewhaus/justification-judge-claude": "0.2.2",
41
+ "@crewhaus/spec-patch": "0.2.2",
42
+ "@crewhaus/canary-controller": "0.2.2",
43
+ "@crewhaus/compiler": "0.2.2",
44
+ "@crewhaus/computer-use-driver": "0.2.2",
45
+ "@crewhaus/context-bundle": "0.2.2",
46
+ "@crewhaus/cost-tracker": "0.2.2",
47
+ "@crewhaus/data-retention-engine": "0.2.2",
48
+ "@crewhaus/dataset-registry": "0.2.2",
49
+ "@crewhaus/deployment-controller": "0.2.2",
50
+ "@crewhaus/egress-classifier": "0.2.2",
51
+ "@crewhaus/egress-matcher-semantic": "0.2.2",
52
+ "@crewhaus/embedder": "0.2.2",
53
+ "@crewhaus/errors": "0.2.2",
54
+ "@crewhaus/eval-dataset": "0.2.2",
55
+ "@crewhaus/eval-grader": "0.2.2",
56
+ "@crewhaus/eval-judge": "0.2.2",
57
+ "@crewhaus/eval-report": "0.2.2",
58
+ "@crewhaus/eval-runner": "0.2.2",
59
+ "@crewhaus/event-log": "0.2.2",
60
+ "@crewhaus/gateway-server": "0.2.2",
61
+ "@crewhaus/grader-safety-classifiers": "0.2.2",
62
+ "@crewhaus/hooks-engine": "0.2.2",
63
+ "@crewhaus/infra-utils": "0.2.2",
64
+ "@crewhaus/ir": "0.2.2",
65
+ "@crewhaus/ir-passes": "0.2.2",
66
+ "@crewhaus/logging": "0.2.2",
67
+ "@crewhaus/mcp-host": "0.2.2",
68
+ "@crewhaus/memory-store": "0.2.2",
69
+ "@crewhaus/model-router": "0.2.2",
70
+ "@crewhaus/module-marketplace-client": "0.2.2",
71
+ "@crewhaus/migration-engine": "0.2.2",
72
+ "@crewhaus/migration-runner": "0.2.2",
73
+ "@crewhaus/plugin-registry": "0.2.2",
74
+ "@crewhaus/plugin-sdk": "0.2.2",
75
+ "@crewhaus/permission-engine": "0.2.2",
76
+ "@crewhaus/pii-redactor": "0.2.2",
77
+ "@crewhaus/prompt-injection-detector": "0.2.2",
78
+ "@crewhaus/regression-runner": "0.2.2",
79
+ "@crewhaus/routing-store": "0.2.2",
80
+ "@crewhaus/run-context": "0.2.2",
81
+ "@crewhaus/runtime-core": "0.2.2",
82
+ "@crewhaus/secrets-manager": "0.2.2",
83
+ "@crewhaus/session-store": "0.2.2",
84
+ "@crewhaus/spec-registry": "0.2.2",
85
+ "@crewhaus/skills-registry": "0.2.2",
86
+ "@crewhaus/slash-commands": "0.2.2",
87
+ "@crewhaus/smoke-harness": "0.2.2",
88
+ "@crewhaus/spec": "0.2.2",
89
+ "@crewhaus/sub-agent-spawner": "0.2.2",
90
+ "@crewhaus/template-marketplace-client": "0.2.2",
91
+ "@crewhaus/template-registry": "0.2.2",
92
+ "@crewhaus/tenancy": "0.2.2",
93
+ "@crewhaus/trace-event-bus": "0.2.2",
94
+ "@crewhaus/tool-bash": "0.2.2",
95
+ "@crewhaus/tool-builder": "0.2.2",
96
+ "@crewhaus/target-cli": "0.2.2",
97
+ "@crewhaus/target-eval-bundle": "0.2.2",
98
+ "@crewhaus/tool-catalog": "0.2.2",
99
+ "@crewhaus/tool-code-execution": "0.2.2",
100
+ "@crewhaus/tool-codegraph": "0.2.2",
101
+ "@crewhaus/tool-fetch": "0.2.2",
102
+ "@crewhaus/tool-fs": "0.2.2",
103
+ "@crewhaus/tool-image": "0.2.2",
104
+ "@crewhaus/tool-image-generation": "0.2.2",
105
+ "@crewhaus/tool-document-ingest": "0.2.2",
106
+ "@crewhaus/tool-mcp": "0.2.2",
107
+ "@crewhaus/tool-memory": "0.2.2",
108
+ "@crewhaus/tool-mouse-keyboard": "0.2.2",
109
+ "@crewhaus/tool-navigate": "0.2.2",
110
+ "@crewhaus/tool-permission-matcher": "0.2.2",
111
+ "@crewhaus/tool-screen-capture": "0.2.2",
112
+ "@crewhaus/tool-task": "0.2.2",
113
+ "@crewhaus/tool-todo": "0.2.2",
114
+ "@crewhaus/tool-vision-grounding": "0.2.2",
115
+ "@crewhaus/tool-web": "0.2.2",
116
+ "@crewhaus/docker-images": "0.2.2",
117
+ "@crewhaus/crewhaus-cloud": "0.2.2",
118
+ "@crewhaus/federation-discovery": "0.2.2",
119
+ "@crewhaus/sandbox": "0.2.2",
120
+ "@crewhaus/sandbox-image-registry": "0.2.2",
121
+ "@crewhaus/compliance-controls": "0.2.2",
121
122
  "yaml": "^2.6.0"
122
123
  },
123
124
  "devDependencies": {