crewhaus 0.2.1 → 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
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,8 +265,6 @@ 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";
263
- // Adaptive model routing — `crewhaus route status|reset` inspects/clears the
264
- // per-(routeKey, model) reward scoreboard behind `agent.model_pool`.
265
268
  import { runRoute } from "./route";
266
269
  // Item 13 — `crewhaus scaffold-evals` + `init --with-evals`: day-one eval
267
270
  // assets generated from the spec (deterministic template / one-shot model
@@ -1425,6 +1428,7 @@ function usageText() {
1425
1428
  " [--factory-root <p>] [--docs-root <p>] [--demos-root <p>]",
1426
1429
  " cost-summary --session <id> summarize cost_accrual events for a session",
1427
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",
1428
1432
  " route reset [--dir <root>] wipe the scoreboard (default root .crewhaus)",
1429
1433
  " advise [--session <id> | --all] mine session logs for spec advice (item 14)",
1430
1434
  " [--json] [-o <dir>] writes suggestions.json + report.html (default .crewhaus/advice)",
@@ -3442,6 +3446,16 @@ async function runRunCli(args, ir, specPath) {
3442
3446
  ir.agent.modelTiers !== undefined
3443
3447
  ? { modelTiers: ir.agent.modelTiers }
3444
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
+ : {}),
3445
3459
  // Section 55 / item 23 — thread the spec's failure_taxonomy so
3446
3460
  // recovery-engine consults the user's named error classes (including
3447
3461
  // the `switch-model` verdict) before its built-in flow.
@@ -7668,11 +7682,13 @@ async function runAdvise(args) {
7668
7682
  if (args.flags["help"]) {
7669
7683
  process.stdout.write("usage: crewhaus advise [--session <id> | --all] [--json] [-o <dir>]\n" +
7670
7684
  "\n" +
7671
- "mines .crewhaus/sessions (+ .crewhaus/audit) for spec advice:\n" +
7685
+ "mines .crewhaus/sessions (+ .crewhaus/audit + .crewhaus/routing) for spec advice:\n" +
7672
7686
  " repeated tool failures, max_tokens truncation pressure, compaction\n" +
7673
7687
  " thrash, permission-ask churn, stop-reason anomalies, learned\n" +
7674
7688
  " failure_taxonomy + loop-break rules, sub-agent splits under\n" +
7675
- " 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" +
7676
7692
  "\n" +
7677
7693
  " --session <id> mine one session (default: all sessions)\n" +
7678
7694
  " --json print machine-readable findings to stdout\n" +
@@ -7722,17 +7738,34 @@ async function runAdvise(args) {
7722
7738
  // The cwd spec enables patch suggestions; without one (or with a broken
7723
7739
  // one) rules fall back to advice text — mining must not block on it.
7724
7740
  let spec;
7741
+ let specText;
7725
7742
  const specPath = join(process.cwd(), "crewhaus.yaml");
7726
7743
  if (existsSync(specPath)) {
7727
7744
  try {
7728
- spec = parseSpec(readFileSync(specPath, "utf-8"));
7745
+ specText = readFileSync(specPath, "utf-8");
7746
+ spec = parseSpec(specText);
7729
7747
  }
7730
7748
  catch (err) {
7731
7749
  process.stderr.write(`[advise] crewhaus.yaml did not parse (${err.message}) — patch suggestions downgraded to advice\n`);
7732
7750
  }
7733
7751
  }
7734
- const ctx = buildAdviceContext(sessions, auditObjects);
7735
- 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
+ });
7736
7769
  const generatedAt = new Date().toISOString();
7737
7770
  const suggestions = buildSuggestionsFile(findings, ctx.sessionIds, generatedAt);
7738
7771
  const outDir = strFlag(args, "out") ?? join(process.cwd(), ".crewhaus", "advice");
package/dist/route.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { type ArmStats } from "@crewhaus/routing-store";
2
2
  export type RouteArgs = {
3
- readonly sub: "status" | "reset";
3
+ readonly sub: "status" | "reset" | "explain";
4
4
  readonly dir: string;
5
+ /** Session id — required for `explain`. */
6
+ readonly session?: string;
5
7
  };
6
- /** Parse `route <sub> [--dir <root>]` argv (everything after `route`). */
8
+ /** Parse `route <sub> [<session>] [--dir <root>]` argv (everything after `route`). */
7
9
  export declare function parseRouteArgs(argv: readonly string[]): RouteArgs;
8
10
  /** Snapshot the scoreboard arms (sorted routeKey then model). */
9
11
  export declare function loadArms(rootDir: string): ArmStats[];
@@ -15,5 +17,23 @@ export declare function loadArms(rootDir: string): ArmStats[];
15
17
  export declare function formatRouteStatus(arms: readonly ArmStats[]): string;
16
18
  /** Wipe the whole scoreboard. Returns the number of arms removed. */
17
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;
18
38
  /** Run `crewhaus route …`, returning the text to print. */
19
39
  export declare function runRoute(argv: readonly string[]): string;
package/dist/route.js CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
- * `crewhaus route` — inspect and reset the adaptive-model-routing scoreboard
3
- * (the durable per-`(routeKey, model)` reward store behind `agent.model_pool`).
2
+ * `crewhaus route` — inspect, explain, and reset adaptive model routing
3
+ * (the `agent.model_pool` reward scoreboard + per-turn routing decisions).
4
4
  *
5
- * route status [--dir <root>] show the learned arms, best-per-bucket first
6
- * route reset [--dir <root>] wipe the scoreboard (kill switch)
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)
7
8
  *
8
- * `--dir` points at the `.crewhaus` root (default `.crewhaus`); the store lives
9
- * at `<root>/routing/arms.jsonl`. Live per-turn `model_route` decisions are
10
- * already visible in a run's structured trace; this command surfaces the
11
- * ACCUMULATED learning that a `learned` policy exploits.
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).
12
14
  */
13
- import { existsSync, rmSync } from "node:fs";
15
+ import { existsSync, readFileSync, rmSync } from "node:fs";
14
16
  import { join } from "node:path";
15
17
  import { openScoreboard } from "@crewhaus/routing-store";
16
18
  const DEFAULT_ROOT = ".crewhaus";
17
- /** Parse `route <sub> [--dir <root>]` argv (everything after `route`). */
19
+ /** Parse `route <sub> [<session>] [--dir <root>]` argv (everything after `route`). */
18
20
  export function parseRouteArgs(argv) {
19
21
  let dir = DEFAULT_ROOT;
20
22
  let sub;
23
+ let session;
21
24
  for (let i = 0; i < argv.length; i++) {
22
25
  const a = argv[i];
26
+ if (a === undefined)
27
+ continue;
23
28
  if (a === "--dir") {
24
29
  const v = argv[i + 1];
25
30
  if (v === undefined)
@@ -27,16 +32,26 @@ export function parseRouteArgs(argv) {
27
32
  dir = v;
28
33
  i++;
29
34
  }
30
- else if (a === "status" || a === "reset") {
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`).
31
39
  sub = a;
32
40
  }
41
+ else if (sub === "explain" && session === undefined && !a.startsWith("--")) {
42
+ session = a; // the session id positional (only `explain` takes one)
43
+ }
33
44
  else {
34
- throw new Error(`route: unknown argument "${a}" (expected: status | reset [--dir <root>])`);
45
+ throw new Error(`route: unknown argument "${a}" (expected: status | reset | explain <session> [--dir <root>])`);
35
46
  }
36
47
  }
37
- if (sub === undefined)
38
- throw new Error("route: expected a subcommand (status | reset)");
39
- return { sub, dir };
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 } : {}) };
40
55
  }
41
56
  /** Snapshot the scoreboard arms (sorted routeKey then model). */
42
57
  export function loadArms(rootDir) {
@@ -80,11 +95,74 @@ export function resetRouting(rootDir) {
80
95
  rmSync(path, { force: true });
81
96
  return removed;
82
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
+ }
83
157
  /** Run `crewhaus route …`, returning the text to print. */
84
158
  export function runRoute(argv) {
85
159
  const args = parseRouteArgs(argv);
86
160
  if (args.sub === "status")
87
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
+ }
88
166
  const removed = resetRouting(args.dir);
89
167
  return `Reset routing scoreboard at ${join(args.dir, "routing", "arms.jsonl")} (${removed} arm${removed === 1 ? "" : "s"} removed).`;
90
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewhaus",
3
- "version": "0.2.1",
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,94 +31,94 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@crewhaus/adapter-anthropic": "0.2.1",
35
- "@crewhaus/agent-context-isolation": "0.2.1",
36
- "@crewhaus/audit-log": "0.2.1",
37
- "@crewhaus/eval-optimizer-orchestrator": "0.2.1",
38
- "@crewhaus/prompt-optimizer": "0.2.1",
39
- "@crewhaus/prompt-optimizer-claude": "0.2.1",
40
- "@crewhaus/justification-judge-claude": "0.2.1",
41
- "@crewhaus/spec-patch": "0.2.1",
42
- "@crewhaus/canary-controller": "0.2.1",
43
- "@crewhaus/compiler": "0.2.1",
44
- "@crewhaus/computer-use-driver": "0.2.1",
45
- "@crewhaus/context-bundle": "0.2.1",
46
- "@crewhaus/cost-tracker": "0.2.1",
47
- "@crewhaus/data-retention-engine": "0.2.1",
48
- "@crewhaus/dataset-registry": "0.2.1",
49
- "@crewhaus/deployment-controller": "0.2.1",
50
- "@crewhaus/egress-classifier": "0.2.1",
51
- "@crewhaus/egress-matcher-semantic": "0.2.1",
52
- "@crewhaus/embedder": "0.2.1",
53
- "@crewhaus/errors": "0.2.1",
54
- "@crewhaus/eval-dataset": "0.2.1",
55
- "@crewhaus/eval-grader": "0.2.1",
56
- "@crewhaus/eval-judge": "0.2.1",
57
- "@crewhaus/eval-report": "0.2.1",
58
- "@crewhaus/eval-runner": "0.2.1",
59
- "@crewhaus/event-log": "0.2.1",
60
- "@crewhaus/gateway-server": "0.2.1",
61
- "@crewhaus/grader-safety-classifiers": "0.2.1",
62
- "@crewhaus/hooks-engine": "0.2.1",
63
- "@crewhaus/infra-utils": "0.2.1",
64
- "@crewhaus/ir": "0.2.1",
65
- "@crewhaus/ir-passes": "0.2.1",
66
- "@crewhaus/logging": "0.2.1",
67
- "@crewhaus/mcp-host": "0.2.1",
68
- "@crewhaus/memory-store": "0.2.1",
69
- "@crewhaus/model-router": "0.2.1",
70
- "@crewhaus/module-marketplace-client": "0.2.1",
71
- "@crewhaus/migration-engine": "0.2.1",
72
- "@crewhaus/migration-runner": "0.2.1",
73
- "@crewhaus/plugin-registry": "0.2.1",
74
- "@crewhaus/plugin-sdk": "0.2.1",
75
- "@crewhaus/permission-engine": "0.2.1",
76
- "@crewhaus/pii-redactor": "0.2.1",
77
- "@crewhaus/prompt-injection-detector": "0.2.1",
78
- "@crewhaus/regression-runner": "0.2.1",
79
- "@crewhaus/routing-store": "0.2.1",
80
- "@crewhaus/run-context": "0.2.1",
81
- "@crewhaus/runtime-core": "0.2.1",
82
- "@crewhaus/secrets-manager": "0.2.1",
83
- "@crewhaus/session-store": "0.2.1",
84
- "@crewhaus/spec-registry": "0.2.1",
85
- "@crewhaus/skills-registry": "0.2.1",
86
- "@crewhaus/slash-commands": "0.2.1",
87
- "@crewhaus/smoke-harness": "0.2.1",
88
- "@crewhaus/spec": "0.2.1",
89
- "@crewhaus/sub-agent-spawner": "0.2.1",
90
- "@crewhaus/template-marketplace-client": "0.2.1",
91
- "@crewhaus/template-registry": "0.2.1",
92
- "@crewhaus/tenancy": "0.2.1",
93
- "@crewhaus/trace-event-bus": "0.2.1",
94
- "@crewhaus/tool-bash": "0.2.1",
95
- "@crewhaus/tool-builder": "0.2.1",
96
- "@crewhaus/target-cli": "0.2.1",
97
- "@crewhaus/target-eval-bundle": "0.2.1",
98
- "@crewhaus/tool-catalog": "0.2.1",
99
- "@crewhaus/tool-code-execution": "0.2.1",
100
- "@crewhaus/tool-codegraph": "0.2.1",
101
- "@crewhaus/tool-fetch": "0.2.1",
102
- "@crewhaus/tool-fs": "0.2.1",
103
- "@crewhaus/tool-image": "0.2.1",
104
- "@crewhaus/tool-image-generation": "0.2.1",
105
- "@crewhaus/tool-document-ingest": "0.2.1",
106
- "@crewhaus/tool-mcp": "0.2.1",
107
- "@crewhaus/tool-memory": "0.2.1",
108
- "@crewhaus/tool-mouse-keyboard": "0.2.1",
109
- "@crewhaus/tool-navigate": "0.2.1",
110
- "@crewhaus/tool-permission-matcher": "0.2.1",
111
- "@crewhaus/tool-screen-capture": "0.2.1",
112
- "@crewhaus/tool-task": "0.2.1",
113
- "@crewhaus/tool-todo": "0.2.1",
114
- "@crewhaus/tool-vision-grounding": "0.2.1",
115
- "@crewhaus/tool-web": "0.2.1",
116
- "@crewhaus/docker-images": "0.2.1",
117
- "@crewhaus/crewhaus-cloud": "0.2.1",
118
- "@crewhaus/federation-discovery": "0.2.1",
119
- "@crewhaus/sandbox": "0.2.1",
120
- "@crewhaus/sandbox-image-registry": "0.2.1",
121
- "@crewhaus/compliance-controls": "0.2.1",
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",
122
122
  "yaml": "^2.6.0"
123
123
  },
124
124
  "devDependencies": {