@gethmy/harness 1.1.1 → 1.2.0

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.
@@ -47,12 +47,9 @@ describe("chooseImplementModel", () => {
47
47
  it("honors a pinned override above everything (no escalation)", () => {
48
48
  const r = chooseImplementModel(
49
49
  claude,
50
- card({
51
- model_override: "claude-opus-4-8",
52
- model_tier: "simple",
53
- priority: "urgent",
54
- }),
50
+ card({ model_override: "claude-opus-4-8", priority: "urgent" }),
55
51
  5,
52
+ { tier: "simple", complexity: 1 },
56
53
  );
57
54
  expect(r).toEqual({
58
55
  model: "claude-opus-4-8",
@@ -61,34 +58,81 @@ describe("chooseImplementModel", () => {
61
58
  });
62
59
  });
63
60
 
64
- it("resolves a tier to its configured model on the first attempt", () => {
65
- const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 1);
61
+ it("resolves a tier-shaped override through the operator's own ladder", () => {
62
+ // The board now writes a TIER name, not a model id, so it stops
63
+ // duplicating the daemon's `claude.tiers` map. An operator who remaps a
64
+ // tier gets their own model, not the one the frontend guessed.
65
+ const r = chooseImplementModel(
66
+ claude,
67
+ card({ model_override: "research" }),
68
+ 1,
69
+ { tier: "simple", complexity: 0 },
70
+ );
71
+ expect(r).toEqual({ model: "opus", escalated: false, source: "override" });
72
+ });
73
+
74
+ it("still resolves a legacy model-id override", () => {
75
+ // Cards pinned before the board switched to tier names must keep working.
76
+ const r = chooseImplementModel(
77
+ claude,
78
+ card({ model_override: "sonnet" }),
79
+ 1,
80
+ { tier: "research", complexity: 9 },
81
+ );
82
+ expect(r).toEqual({
83
+ model: "sonnet",
84
+ escalated: false,
85
+ source: "override",
86
+ });
87
+ });
88
+
89
+ it("resolves the preflight tier to its configured model on the first attempt", () => {
90
+ const r = chooseImplementModel(claude, card({}), 1, {
91
+ tier: "simple",
92
+ complexity: 1,
93
+ });
66
94
  expect(r).toEqual({ model: "haiku", escalated: false, source: "tier" });
67
95
  });
68
96
 
69
- it("escalates the tier up one level on a retry", () => {
70
- const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 2);
97
+ it("escalates the preflight tier up one level on a retry", () => {
98
+ const r = chooseImplementModel(claude, card({}), 2, {
99
+ tier: "simple",
100
+ complexity: 1,
101
+ });
71
102
  expect(r).toEqual({ model: "sonnet", escalated: true, source: "tier" });
72
103
  });
73
104
 
74
- it("falls back to global policy when no tier/override is set", () => {
75
- expect(chooseImplementModel(claude, card({}), 1)).toEqual({
105
+ it("falls back to global policy when the preflight produced nothing", () => {
106
+ // This is the degradation contract: a preflight that failed, timed out, or
107
+ // was disabled must leave behaviour exactly as it was before it existed.
108
+ expect(chooseImplementModel(claude, card({}), 1, undefined)).toEqual({
76
109
  model: "opus",
77
110
  escalated: false,
78
111
  source: "policy",
79
112
  });
80
- expect(chooseImplementModel(claude, card({}), 2)).toEqual({
113
+ expect(chooseImplementModel(claude, card({}), 2, undefined)).toEqual({
81
114
  model: "claude-opus-4-8",
82
115
  escalated: true,
83
116
  source: "policy",
84
117
  });
85
- expect(chooseImplementModel(claude, card({ priority: "high" }), 1)).toEqual(
86
- { model: "claude-opus-4-8", escalated: true, source: "policy" },
118
+ expect(
119
+ chooseImplementModel(claude, card({ priority: "high" }), 1, undefined),
120
+ ).toEqual({ model: "claude-opus-4-8", escalated: true, source: "policy" });
121
+ });
122
+
123
+ it("ignores card.model_tier entirely", () => {
124
+ // The column still exists and still holds stale values written by the
125
+ // retired create-time classifier. Nothing may read it.
126
+ const r = chooseImplementModel(
127
+ claude,
128
+ card({ model_tier: "research" }),
129
+ 1,
130
+ undefined,
87
131
  );
132
+ expect(r).toEqual({ model: "opus", escalated: false, source: "policy" });
88
133
  });
89
134
 
90
135
  it("spawns Fable 5 unclamped now that it is available again (#747)", () => {
91
- // Pinned override.
92
136
  expect(
93
137
  chooseImplementModel(
94
138
  claude,
@@ -101,7 +145,6 @@ describe("chooseImplementModel", () => {
101
145
  source: "override",
102
146
  });
103
147
 
104
- // Global policy escalation (the shipped default).
105
148
  const fableEscalate = { ...claude, escalateModel: "claude-fable-5" };
106
149
  expect(chooseImplementModel(fableEscalate, card({}), 2)).toEqual({
107
150
  model: "claude-fable-5",
@@ -109,7 +152,6 @@ describe("chooseImplementModel", () => {
109
152
  source: "policy",
110
153
  });
111
154
 
112
- // Tier mapping.
113
155
  const fableTier = {
114
156
  ...claude,
115
157
  tiers: {
@@ -119,13 +161,14 @@ describe("chooseImplementModel", () => {
119
161
  },
120
162
  };
121
163
  expect(
122
- chooseImplementModel(fableTier, card({ model_tier: "research" }), 1)
123
- .model,
164
+ chooseImplementModel(fableTier, card({}), 1, {
165
+ tier: "research",
166
+ complexity: 9,
167
+ }).model,
124
168
  ).toBe("claude-fable-5");
125
169
  });
126
170
 
127
171
  it("clamps a retired model id up to the ceiling from any source", () => {
128
- // Pinned override naming a retired model.
129
172
  expect(
130
173
  chooseImplementModel(
131
174
  claude,
@@ -138,7 +181,6 @@ describe("chooseImplementModel", () => {
138
181
  source: "override",
139
182
  });
140
183
 
141
- // Stale config still pointing escalateModel at a retired model.
142
184
  const staleConfig = { ...claude, escalateModel: "claude-3-opus-20240229" };
143
185
  expect(chooseImplementModel(staleConfig, card({}), 2)).toEqual({
144
186
  model: MAX_IMPLEMENT_MODEL,
@@ -146,13 +188,25 @@ describe("chooseImplementModel", () => {
146
188
  source: "policy",
147
189
  });
148
190
 
149
- // Tier mapping pointing at a retired model.
150
191
  const retiredTier = {
151
192
  ...claude,
152
193
  tiers: { simple: "claude-2.1", advanced: "sonnet", research: "opus" },
153
194
  };
154
195
  expect(
155
- chooseImplementModel(retiredTier, card({ model_tier: "simple" }), 1)
196
+ chooseImplementModel(retiredTier, card({}), 1, {
197
+ tier: "simple",
198
+ complexity: 1,
199
+ }).model,
200
+ ).toBe(MAX_IMPLEMENT_MODEL);
201
+ });
202
+
203
+ it("clamps a retired model behind a tier-shaped override too", () => {
204
+ const retiredTier = {
205
+ ...claude,
206
+ tiers: { simple: "claude-2.1", advanced: "sonnet", research: "opus" },
207
+ };
208
+ expect(
209
+ chooseImplementModel(retiredTier, card({ model_override: "simple" }), 1)
156
210
  .model,
157
211
  ).toBe(MAX_IMPLEMENT_MODEL);
158
212
  });
@@ -160,10 +214,20 @@ describe("chooseImplementModel", () => {
160
214
  it("falls back to base model if a tier has no configured mapping", () => {
161
215
  const r = chooseImplementModel(
162
216
  { ...claude, tiers: { simple: "", advanced: "", research: "" } },
163
- card({ model_tier: "research" }),
217
+ card({}),
164
218
  1,
219
+ { tier: "research", complexity: 9 },
165
220
  );
166
221
  expect(r.model).toBe("opus");
167
222
  expect(r.source).toBe("tier");
168
223
  });
224
+
225
+ it("falls back to base model for a tier-shaped override with no mapping", () => {
226
+ const r = chooseImplementModel(
227
+ { ...claude, tiers: { simple: "", advanced: "", research: "" } },
228
+ card({ model_override: "research" }),
229
+ 1,
230
+ );
231
+ expect(r).toEqual({ model: "opus", escalated: false, source: "override" });
232
+ });
169
233
  });
package/src/model-tier.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Card } from "@harmony/shared";
1
+ import type { Card, ModelTier } from "@harmony/shared";
2
2
  import { escalateTier, isModelTier } from "@harmony/shared";
3
3
 
4
4
  /**
@@ -51,43 +51,73 @@ export function clampWithdrawn(model: string): string {
51
51
  return RETIRED_MODEL.test(model) ? MAX_IMPLEMENT_MODEL : model;
52
52
  }
53
53
 
54
+ /** What the pickup preflight decided, when it ran and produced a verdict. */
55
+ export interface RunSizing {
56
+ tier: ModelTier;
57
+ /** 0-10, as the preflight scored it. Carried for the trace, not for routing. */
58
+ complexity: number;
59
+ /** One sentence explaining the score. */
60
+ reasoning?: string;
61
+ /** Paths the preflight read to reach the score. */
62
+ filesInspected?: string[];
63
+ }
64
+
54
65
  /**
55
66
  * Pick the model for an implement run.
56
67
  *
57
- * Precedence (#354):
58
- * 1. `card.model_override` — a user-pinned concrete model id. Always wins,
59
- * never auto-escalates (the user chose it deliberately).
60
- * 2. `card.model_tier` the classifier's suggested tier, resolved through
61
- * `claude.tiers`. On a retry (attempts >= escalateAfterAttempts) the tier
62
- * bumps up one level before resolving.
63
- * 3. Global policy fallback — the original #348 behaviour: escalate to
64
- * `escalateModel` on high/urgent priority or on a retry, else `model`.
68
+ * Precedence:
69
+ * 1. `card.model_override` — a human pin. Always wins, never auto-escalates
70
+ * (the user chose it deliberately). Accepts either a TIER NAME, which the
71
+ * board writes now, or a concrete model id, which it wrote before. A tier
72
+ * resolves through the operator's own `claude.tiers`, so the board no
73
+ * longer has to duplicate and guess at — the daemon's ladder.
74
+ * 2. `sized.tier` — the pickup preflight. On a retry (attempts >=
75
+ * escalateAfterAttempts) the tier bumps one level before resolving.
76
+ *
77
+ * This replaces the retired `card.model_tier`, which a create-time
78
+ * classifier wrote by guessing engineering effort from a title composed
79
+ * before anyone had looked at the code. The preflight runs at pickup with
80
+ * the repo readable, so the answer is measured rather than guessed — and
81
+ * it is run-scoped, so a stale value can never outlive the run it was for.
82
+ * `card.model_tier` is deliberately NOT read here; the column survives
83
+ * only so old rows stay readable until a follow-up drops it.
84
+ * 3. Global policy fallback — escalate on high/urgent priority or on a retry.
85
+ * This is also where EVERY preflight failure lands (spawn error, timeout,
86
+ * malformed output, or an operator who disabled it), so a broken preflight
87
+ * degrades to exactly the behaviour that shipped before it existed.
65
88
  *
66
- * Whichever path wins, the result is clamped to {@link MAX_IMPLEMENT_MODEL}:
67
- * a retired model id is never spawned regardless of its source.
89
+ * Whichever path wins, the result is clamped to {@link MAX_IMPLEMENT_MODEL}: a
90
+ * retired model id is never spawned regardless of its source.
68
91
  *
69
92
  * Pure so the policy is unit-testable without spawning a worker.
70
93
  */
71
94
  export function chooseImplementModel(
72
95
  claude: ModelTierConfig,
73
- card: Pick<Card, "priority" | "model_tier" | "model_override">,
96
+ card: Pick<Card, "priority" | "model_override">,
74
97
  attempts: number,
98
+ sized?: RunSizing,
75
99
  ): {
76
100
  model: string;
77
101
  escalated: boolean;
78
102
  source: "override" | "tier" | "policy";
79
103
  } {
80
104
  if (card.model_override) {
105
+ // A tier name resolves through the operator's ladder; anything else is
106
+ // taken as a concrete model id. An unmapped tier falls back to the base
107
+ // model rather than spawning the empty string.
108
+ const pinned = isModelTier(card.model_override)
109
+ ? claude.tiers?.[card.model_override] || claude.model
110
+ : card.model_override;
81
111
  return {
82
- model: clampWithdrawn(card.model_override),
112
+ model: clampWithdrawn(pinned),
83
113
  escalated: false,
84
114
  source: "override",
85
115
  };
86
116
  }
87
117
 
88
- if (isModelTier(card.model_tier)) {
118
+ if (sized && isModelTier(sized.tier)) {
89
119
  const retry = attempts >= claude.escalateAfterAttempts;
90
- const tier = retry ? escalateTier(card.model_tier) : card.model_tier;
120
+ const tier = retry ? escalateTier(sized.tier) : sized.tier;
91
121
  const mapped = claude.tiers?.[tier];
92
122
  return {
93
123
  model: clampWithdrawn(
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The motor's LIVE stdout relay for its subagent (card #885).
3
+ *
4
+ * The motor runs a real, token-spending `claude` subagent, and until this
5
+ * module existed nothing about that run left the motor process: `cli.ts` logged
6
+ * each event at `log.debug` (dropped unless `DEBUG` is set) and the driver saw
7
+ * only the four coarse protocol lines, all at the very end. A live run and a
8
+ * hung run were therefore identical to a human — measured on card #906, stdout
9
+ * held 0 bytes for 9 minutes and then produced 4 lines at once.
10
+ *
11
+ * A relayed event is the SAME `AgentRunEventDraft` the daemon's own
12
+ * `ProgressTracker.ingest` already consumes on the in-process path, so the
13
+ * driver needs no second vocabulary: it feeds the draft straight in and the
14
+ * board's turns, tokens, cost, tool actions and progress come out identical to
15
+ * a non-motor run.
16
+ *
17
+ * Two deliberate narrowings, because this crosses a process boundary:
18
+ *
19
+ * 1. An ALLOW-set of kinds, never a deny-set. The daemon owns the lifecycle
20
+ * kinds (`phase_changed`, `progress`, `control`, `user_message`) — relaying
21
+ * them would let the motor fight its own driver for the session row — and a
22
+ * kind added to the union later must be opted in here, not leak by default.
23
+ * 2. `tool_started.input` is BOUNDED. It is the one payload the SDK runner
24
+ * hands over whole: a `Write` of a 200 KB file would otherwise cross the
25
+ * pipe in full, on every tool call. Only top-level primitives survive, each
26
+ * string clipped — which is exactly and only what the tracker reads back
27
+ * out (`describeToolAction` → `extractString`, top-level string keys).
28
+ */
29
+ import type { AgentRunEventDraft } from "@harmony/shared";
30
+
31
+ /** One relayed subagent event on the motor's NDJSON stdout. */
32
+ export interface MotorAgentEventLine {
33
+ type: "agent_event";
34
+ event: AgentRunEventDraft;
35
+ }
36
+
37
+ /**
38
+ * Kinds the motor relays to its driver. `assistant_text` and `tool_ended` are
39
+ * already bounded by the SDK runner (8 KB / 4 KB); the rest are small by shape.
40
+ */
41
+ const RELAYED_KINDS = new Set<AgentRunEventDraft["kind"]>([
42
+ "run_started",
43
+ "assistant_text",
44
+ "tool_started",
45
+ "tool_ended",
46
+ "cost_updated",
47
+ "error",
48
+ "run_finished",
49
+ ]);
50
+
51
+ /** Per-string clip inside a relayed `tool_started.input`. */
52
+ export const MOTOR_TOOL_INPUT_VALUE_MAX = 400;
53
+
54
+ /**
55
+ * Key cap for a relayed `tool_started.input`. A tool schema with more top-level
56
+ * keys than this is not something the tracker reads, and the cap keeps one
57
+ * malformed input from dominating the pipe.
58
+ */
59
+ export const MOTOR_TOOL_INPUT_KEY_MAX = 24;
60
+
61
+ /**
62
+ * Reduce a tool input to the top-level primitives a consumer can actually read
63
+ * back, with every string clipped. Nested objects and arrays are dropped: the
64
+ * tracker's `extractString` only ever resolves a top-level key, so a nested
65
+ * value carries no information downstream and is pure pipe weight.
66
+ */
67
+ function boundToolInput(input: unknown): unknown {
68
+ if (typeof input === "string") {
69
+ return input.slice(0, MOTOR_TOOL_INPUT_VALUE_MAX);
70
+ }
71
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
72
+ // A primitive is already small; anything else (array, null, undefined)
73
+ // carries no top-level key a consumer could read.
74
+ return typeof input === "number" || typeof input === "boolean"
75
+ ? input
76
+ : undefined;
77
+ }
78
+ const bounded: Record<string, unknown> = {};
79
+ let kept = 0;
80
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
81
+ if (kept >= MOTOR_TOOL_INPUT_KEY_MAX) break;
82
+ // The KEY is clipped as well as the value. A tool schema's key names are
83
+ // normally short, but nothing guarantees it — and 24 unbounded keys would
84
+ // make "bounded" untrue by exactly the amount an adversarial input chose.
85
+ const boundedKey = key.slice(0, MOTOR_TOOL_INPUT_VALUE_MAX);
86
+ if (typeof value === "string") {
87
+ bounded[boundedKey] = value.slice(0, MOTOR_TOOL_INPUT_VALUE_MAX);
88
+ } else if (typeof value === "number" || typeof value === "boolean") {
89
+ bounded[boundedKey] = value;
90
+ } else {
91
+ continue;
92
+ }
93
+ kept++;
94
+ }
95
+ return bounded;
96
+ }
97
+
98
+ /**
99
+ * Project one subagent event onto the motor's stdout protocol, or `null` when
100
+ * the kind is not relayed. Never mutates the draft it is given — the motor's own
101
+ * logging reads the original afterwards.
102
+ */
103
+ export function relayAgentEvent(
104
+ draft: AgentRunEventDraft,
105
+ ): MotorAgentEventLine | null {
106
+ if (!RELAYED_KINDS.has(draft.kind)) return null;
107
+ if (draft.kind === "tool_started") {
108
+ return {
109
+ type: "agent_event",
110
+ event: {
111
+ ...draft,
112
+ payload: {
113
+ ...draft.payload,
114
+ input: boundToolInput(draft.payload.input),
115
+ },
116
+ },
117
+ };
118
+ }
119
+ return { type: "agent_event", event: draft };
120
+ }
@@ -21,6 +21,7 @@
21
21
  * deliberately: it is not a secrecy gate, and its output is exactly what a human
22
22
  * needs to see. Do not "make them consistent."
23
23
  */
24
+ import { createHash } from "node:crypto";
24
25
  import type {
25
26
  GateEvidence,
26
27
  GateEvidenceCollector,
@@ -56,6 +57,15 @@ export class OracleCollector implements GateEvidenceCollector {
56
57
  }
57
58
 
58
59
  private async runHeld(oracle: HeldOracle): Promise<GateEvidence> {
60
+ // The identity of the contract this verdict proves (card #927): two
61
+ // authors can pick the same path, but not the same digest — and a digest
62
+ // leaks nothing of the held test, so it survives the secrecy rule. The
63
+ // edge computes the same SHA-256 (lowercase hex) when it records a
64
+ // replacement, so evidence and replacement record can be matched.
65
+ const identity = {
66
+ oracleId: oracle.id ?? null,
67
+ contentHash: createHash("sha256").update(oracle.content).digest("hex"),
68
+ };
59
69
  await this.deps.place(this.deps.repoPath, oracle);
60
70
  try {
61
71
  const { exitCode, output } = await this.deps.run(
@@ -77,6 +87,7 @@ export class OracleCollector implements GateEvidenceCollector {
77
87
  oracle: {
78
88
  exitCode,
79
89
  path: oracle.path,
90
+ ...identity,
80
91
  output:
81
92
  "withheld — oracle_passed is a secrecy gate; see the motor's local log",
82
93
  },
@@ -87,7 +98,10 @@ export class OracleCollector implements GateEvidenceCollector {
87
98
  log.warn(TAG, `Oracle run threw: ${message} — blocked`);
88
99
  return {
89
100
  result: "blocked",
90
- structured: { oracle: { path: oracle.path }, error: message },
101
+ structured: {
102
+ oracle: { path: oracle.path, ...identity },
103
+ error: message,
104
+ },
91
105
  };
92
106
  } finally {
93
107
  // Always. A held test left in the worktree could be committed, which would
package/src/oracle.ts CHANGED
@@ -70,6 +70,13 @@ import { DEFAULT_METRIC_TIMEOUT_MS } from "./exec-types.js";
70
70
  import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
71
71
 
72
72
  export interface HeldOracle {
73
+ /**
74
+ * The `stage_oracles` row id, when the API returns one (card #927 — older
75
+ * deployments answer without it). It rides into the gate's persisted
76
+ * evidence beside the content digest, so a verdict is traceable to the
77
+ * exact contract it graded.
78
+ */
79
+ id?: string | null;
73
80
  path: string;
74
81
  content: string;
75
82
  runnerHint: string | null;