@gethmy/harness 1.0.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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The motor's only door to Harmony: read the one card it was invoked for, read
3
+ * that card's pinned stage definition, fetch a held oracle, record gate
4
+ * evidence. Nothing else — the motor does not read the board, does not advance
5
+ * a stage, and does not decide what runs next.
6
+ *
7
+ * The card read is deliberately NARROW: `fetchStageCard` returns exactly
8
+ * `current_stage`, `playbook_id` and `playbook_version` and drops every other
9
+ * field the API sends, so no board content (title, description, comments) can
10
+ * flow onward from here — not into a prompt, not into a log. Those three fields
11
+ * exist because `resolveStageGate` (gate-collectors.ts) takes a card, not a
12
+ * request: it needs the PIN to read the frozen stage definition the gate is
13
+ * collected against. Passing `playbook_id` / `playbook_version` as CLI flags
14
+ * instead would move the read into the driver and make the pin forgeable by the
15
+ * caller.
16
+ *
17
+ * `request` is the generic seam `resolveStageGate` consumes
18
+ * (`(method, path) => Promise<{ version: … }>`). It THROWS on a non-ok status.
19
+ * `fetchOracle` deliberately does not: a denied or missing oracle returns null
20
+ * so the collector reports `blocked`. A gate with no signal must never read as
21
+ * a pass, and after ruling 27 a 403 also means "this caller does not own the
22
+ * run", which is exactly the signal worth logging loudly.
23
+ *
24
+ * Auth is the `X-API-Key` header, which is what harmony-api reads for an
25
+ * agent/API key (`supabase/functions/harmony-api/index.ts`; the same header
26
+ * also carries an OAuth access token when it starts with `hmy_at_`). An
27
+ * `authorization: Bearer` header would fall through to the Supabase-JWT path
28
+ * and 401. `packages/mcp-server/src/api-client.ts` sends the same header, and
29
+ * this client mirrors its URL shape too: `HARMONY_API_URL` is the API BASE
30
+ * (e.g. `https://app.gethmy.com/api`) and `/v1` is appended per request.
31
+ */
32
+ import type { Card, StageGateEvidenceInsert } from "@harmony/shared";
33
+ import { log } from "./log.js";
34
+ import type { HeldOracle } from "./oracle.js";
35
+
36
+ const TAG = "harmony-client";
37
+
38
+ export interface HarmonyClientConfig {
39
+ apiUrl: string;
40
+ apiKey: string;
41
+ }
42
+
43
+ /**
44
+ * The three card fields the motor reads, and the only ones it keeps. Typed off
45
+ * `Card` so a rename in the shared model breaks this compile rather than
46
+ * silently producing an unresolvable pin.
47
+ */
48
+ export type StageCardPin = Pick<
49
+ Card,
50
+ "current_stage" | "playbook_id" | "playbook_version"
51
+ >;
52
+
53
+ /** The oracle payload `POST /stage-oracle/fetch` returns (camelCase, edge-side). */
54
+ interface OracleResponse {
55
+ path: string;
56
+ content: string;
57
+ runnerHint?: string | null;
58
+ }
59
+
60
+ export function readClientConfig(
61
+ env: Record<string, string | undefined>,
62
+ ): HarmonyClientConfig {
63
+ const apiUrl = env.HARMONY_API_URL?.trim();
64
+ const apiKey = env.HARMONY_API_KEY?.trim();
65
+ if (!apiUrl) {
66
+ throw new Error(
67
+ "HARMONY_API_URL is not set — the motor needs the Harmony API base URL (e.g. https://app.gethmy.com/api)",
68
+ );
69
+ }
70
+ if (!apiKey) {
71
+ throw new Error(
72
+ "HARMONY_API_KEY is not set — the motor needs a Harmony API key or OAuth access token",
73
+ );
74
+ }
75
+ // Trim trailing slashes so the built path has exactly one separator. A blank
76
+ // value is refused above rather than sent: an empty credential would 401 at
77
+ // the edge with no hint about which end is misconfigured.
78
+ return { apiUrl: apiUrl.replace(/\/+$/, ""), apiKey };
79
+ }
80
+
81
+ export class HarmonyClient {
82
+ constructor(private readonly config: HarmonyClientConfig) {}
83
+
84
+ /** One raw call. Returns the Response so a caller can judge the status itself. */
85
+ private async send(
86
+ method: string,
87
+ path: string,
88
+ body?: unknown,
89
+ ): Promise<Response> {
90
+ return await fetch(`${this.config.apiUrl}/v1${path}`, {
91
+ method,
92
+ headers: {
93
+ "X-API-Key": this.config.apiKey,
94
+ "content-type": "application/json",
95
+ accept: "application/json",
96
+ },
97
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
98
+ });
99
+ }
100
+
101
+ /**
102
+ * The generic request seam. `resolveStageGate` reads the pinned
103
+ * `playbook_versions` snapshot through this; `fetchStageCard` uses it too.
104
+ * Throws on a non-ok status — the caller that must not throw (`fetchOracle`)
105
+ * uses {@link send} directly instead.
106
+ */
107
+ async request<T>(method: string, path: string, body?: unknown): Promise<T> {
108
+ const response = await this.send(method, path, body);
109
+ if (!response.ok) {
110
+ throw new Error(
111
+ `${method} ${path} failed with ${response.status}${await detail(response)}`,
112
+ );
113
+ }
114
+ return (await response.json()) as T;
115
+ }
116
+
117
+ /**
118
+ * Read the card's stage pin. Only the three pinned fields survive this
119
+ * function — see the module doc comment for why the read exists at all and
120
+ * why it stops there.
121
+ */
122
+ async fetchStageCard(cardId: string): Promise<StageCardPin> {
123
+ const { card } = await this.request<{ card: StageCardPin }>(
124
+ "GET",
125
+ `/cards/${encodeURIComponent(cardId)}`,
126
+ );
127
+ return {
128
+ current_stage: card.current_stage ?? null,
129
+ playbook_id: card.playbook_id ?? null,
130
+ playbook_version: card.playbook_version ?? null,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Fetch the held oracle for `stageId`. The session id travels in the body
136
+ * beside `purpose: "gate_evaluation"` — the read route binds the caller to the
137
+ * card's ACTIVE agent session (ruling 27), so an ended or foreign session is a
138
+ * 403.
139
+ *
140
+ * ANY non-ok status returns null, not just 403/404: the collector turns null
141
+ * into `blocked`, which is the honest outcome for "no oracle was read" whatever
142
+ * the reason. The status itself is logged here, because the collector's
143
+ * `blocked` reason cannot distinguish a refusal from an absence.
144
+ */
145
+ async fetchOracle(
146
+ cardId: string,
147
+ stageId: string,
148
+ sessionId: string,
149
+ ): Promise<HeldOracle | null> {
150
+ const response = await this.send("POST", "/stage-oracle/fetch", {
151
+ cardId,
152
+ stageId,
153
+ sessionId,
154
+ purpose: "gate_evaluation",
155
+ });
156
+ if (!response.ok) {
157
+ log.warn(
158
+ TAG,
159
+ `Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`,
160
+ );
161
+ return null;
162
+ }
163
+ const body = (await response.json()) as OracleResponse;
164
+ return {
165
+ path: body.path,
166
+ content: body.content,
167
+ runnerHint: body.runnerHint ?? null,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Persist one evidence row. The card id in the insert IS the id in the route's
173
+ * path (`POST /cards/:cardId/stage-gate-evidence`) — the same shape
174
+ * `packages/mcp-server/src/api-client.ts` uses. Throws on a refused write:
175
+ * the stored row is what the human-advance edge re-evaluates, so a lost row
176
+ * must never read as a recorded one.
177
+ */
178
+ async recordStageGateEvidence(
179
+ insert: StageGateEvidenceInsert,
180
+ ): Promise<void> {
181
+ await this.request(
182
+ "POST",
183
+ `/cards/${encodeURIComponent(insert.card_id)}/stage-gate-evidence`,
184
+ insert,
185
+ );
186
+ }
187
+ }
188
+
189
+ /** Best-effort error detail for a failed request. Never throws. */
190
+ async function detail(response: Response): Promise<string> {
191
+ try {
192
+ const text = await response.text();
193
+ return text ? `: ${text.slice(0, 500)}` : "";
194
+ } catch {
195
+ return "";
196
+ }
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Public surface of the Harmony execution motor.
3
+ *
4
+ * The motor runs exactly ONE playbook stage per invocation and exits. It owns the
5
+ * worktree, dispatches the subagent for the stage's role, places and removes the
6
+ * held oracle, and collects gate evidence. It deliberately does NOT decide which
7
+ * stage runs next (Harmony does), does NOT form a gate verdict (`gateEvaluate` in
8
+ * @harmony/shared does), and does NOT push or merge.
9
+ *
10
+ * Dependency direction is agent → harness → shared. Importing from @gethmy/agent
11
+ * here would create a cycle and is never correct.
12
+ */
13
+ export const MOTOR_NAME = "harmony-harness";
14
+
15
+ export * from "./artifact-judge.js";
16
+ export * from "./command-metric.js";
17
+ export * from "./error-classifier.js";
18
+ export * from "./exec-types.js";
19
+ export * from "./gate-collectors.js";
20
+ export * from "./gate-config-error.js";
21
+ export * from "./git-diff-stat.js";
22
+ export * from "./git-pr.js";
23
+ export * from "./harmony-client.js";
24
+ export * from "./log.js";
25
+ export * from "./model-tier.js";
26
+ export * from "./oracle.js";
27
+ export * from "./oracle-collector.js";
28
+ export * from "./pm.js";
29
+ export * from "./process-group.js";
30
+ export * from "./project-type.js";
31
+ export * from "./revert-guard.js";
32
+ export * from "./review-types.js";
33
+ export * from "./runner.js";
34
+ export * from "./sdk-agent-runner.js";
35
+ export * from "./stage-run.js";
36
+ export * from "./verification.js";
37
+ export * from "./worktree.js";
package/src/log.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Structured logging for the agent daemon.
3
+ *
4
+ * When stderr is a TTY, output is ANSI-coloured single-line text — the
5
+ * readable default for humans running the daemon interactively. When
6
+ * stderr is piped/redirected (files, logshippers, systemd), output
7
+ * falls back to one JSON object per line — trivially `jq`-able,
8
+ * indexable, and machine-readable.
9
+ *
10
+ * Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
11
+ * `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
12
+ * TTY detection either way.
13
+ *
14
+ * Stdout is left clean so consumers can pipe structured data if they
15
+ * want to (future use).
16
+ */
17
+
18
+ type Level = "debug" | "info" | "warn" | "error";
19
+
20
+ export interface LogContext {
21
+ event?: string;
22
+ runId?: string;
23
+ cardId?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface LogRecord extends LogContext {
28
+ ts: string;
29
+ level: Level;
30
+ tag: string;
31
+ msg: string;
32
+ }
33
+
34
+ const COLORS = {
35
+ reset: "\x1b[0m",
36
+ dim: "\x1b[2m",
37
+ red: "\x1b[31m",
38
+ green: "\x1b[32m",
39
+ yellow: "\x1b[33m",
40
+ blue: "\x1b[34m",
41
+ cyan: "\x1b[36m",
42
+ } as const;
43
+
44
+ const LEVEL_COLOR: Record<Level, string> = {
45
+ debug: COLORS.dim,
46
+ info: COLORS.green,
47
+ warn: COLORS.yellow,
48
+ error: COLORS.red,
49
+ };
50
+
51
+ function pretty(): boolean {
52
+ if (
53
+ process.env.HARMONY_AGENT_JSON === "1" ||
54
+ process.argv.includes("--json")
55
+ ) {
56
+ return false;
57
+ }
58
+ if (process.env.HARMONY_AGENT_PRETTY === "1") return true;
59
+ if (process.argv.includes("--pretty")) return true;
60
+ return Boolean(process.stderr.isTTY);
61
+ }
62
+
63
+ function shortTime(iso: string): string {
64
+ return iso.slice(11, 23);
65
+ }
66
+
67
+ function emit(rec: LogRecord): void {
68
+ if (rec.level === "debug" && !process.env.DEBUG) return;
69
+
70
+ if (pretty()) {
71
+ const color = LEVEL_COLOR[rec.level];
72
+ const label = rec.level.toUpperCase().padEnd(5, " ");
73
+ const ctx: string[] = [];
74
+ if (rec.event) ctx.push(`event=${rec.event}`);
75
+ if (rec.runId) ctx.push(`run=${rec.runId}`);
76
+ if (rec.cardId) ctx.push(`card=${rec.cardId}`);
77
+ const tail = ctx.length
78
+ ? ` ${COLORS.dim}(${ctx.join(" ")})${COLORS.reset}`
79
+ : "";
80
+ process.stderr.write(
81
+ `${COLORS.dim}${shortTime(rec.ts)}${COLORS.reset} ${color}${label}${COLORS.reset} ${COLORS.cyan}[${rec.tag}]${COLORS.reset} ${rec.msg}${tail}\n`,
82
+ );
83
+ return;
84
+ }
85
+
86
+ process.stderr.write(`${JSON.stringify(rec)}\n`);
87
+ }
88
+
89
+ function record(
90
+ level: Level,
91
+ tag: string,
92
+ msg: string,
93
+ ctx?: LogContext,
94
+ ): void {
95
+ const rec: LogRecord = {
96
+ ts: new Date().toISOString(),
97
+ level,
98
+ tag,
99
+ msg,
100
+ ...(ctx ?? {}),
101
+ };
102
+ emit(rec);
103
+ }
104
+
105
+ export const log = {
106
+ info(tag: string, msg: string, ctx?: LogContext) {
107
+ record("info", tag, msg, ctx);
108
+ },
109
+ warn(tag: string, msg: string, ctx?: LogContext) {
110
+ record("warn", tag, msg, ctx);
111
+ },
112
+ error(tag: string, msg: string, ctx?: LogContext) {
113
+ record("error", tag, msg, ctx);
114
+ },
115
+ debug(tag: string, msg: string, ctx?: LogContext) {
116
+ record("debug", tag, msg, ctx);
117
+ },
118
+ /**
119
+ * Emit a named event. Semantically the same as `info` but signals to
120
+ * downstream tooling that this is a structured event worth indexing.
121
+ */
122
+ event(tag: string, event: string, ctx?: LogContext) {
123
+ record("info", tag, event, { ...ctx, event });
124
+ },
125
+ };
126
+
127
+ export function isPretty(): boolean {
128
+ return pretty();
129
+ }
@@ -0,0 +1,169 @@
1
+ import type { Card } from "@harmony/shared";
2
+ import { escalateTier, tierFromScore } from "@harmony/shared";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ chooseImplementModel,
6
+ MAX_IMPLEMENT_MODEL,
7
+ type ModelTierConfig,
8
+ } from "./model-tier.js";
9
+
10
+ const claude: ModelTierConfig = {
11
+ model: "opus",
12
+ escalateModel: "claude-opus-4-8",
13
+ escalateAfterAttempts: 2,
14
+ tiers: { simple: "haiku", advanced: "sonnet", research: "opus" },
15
+ };
16
+
17
+ function card(p: Partial<Card>): Card {
18
+ return {
19
+ priority: "medium",
20
+ model_tier: null,
21
+ model_override: null,
22
+ ...p,
23
+ } as Card;
24
+ }
25
+
26
+ describe("tierFromScore", () => {
27
+ it("maps score ranges to tiers and clamps", () => {
28
+ expect(tierFromScore(0)).toBe("simple");
29
+ expect(tierFromScore(2)).toBe("simple");
30
+ expect(tierFromScore(3)).toBe("advanced");
31
+ expect(tierFromScore(6)).toBe("advanced");
32
+ expect(tierFromScore(7)).toBe("research");
33
+ expect(tierFromScore(99)).toBe("research");
34
+ expect(tierFromScore(-5)).toBe("simple");
35
+ });
36
+ });
37
+
38
+ describe("escalateTier", () => {
39
+ it("bumps one level, capping at research", () => {
40
+ expect(escalateTier("simple")).toBe("advanced");
41
+ expect(escalateTier("advanced")).toBe("research");
42
+ expect(escalateTier("research")).toBe("research");
43
+ });
44
+ });
45
+
46
+ describe("chooseImplementModel", () => {
47
+ it("honors a pinned override above everything (no escalation)", () => {
48
+ const r = chooseImplementModel(
49
+ claude,
50
+ card({
51
+ model_override: "claude-opus-4-8",
52
+ model_tier: "simple",
53
+ priority: "urgent",
54
+ }),
55
+ 5,
56
+ );
57
+ expect(r).toEqual({
58
+ model: "claude-opus-4-8",
59
+ escalated: false,
60
+ source: "override",
61
+ });
62
+ });
63
+
64
+ it("resolves a tier to its configured model on the first attempt", () => {
65
+ const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 1);
66
+ expect(r).toEqual({ model: "haiku", escalated: false, source: "tier" });
67
+ });
68
+
69
+ it("escalates the tier up one level on a retry", () => {
70
+ const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 2);
71
+ expect(r).toEqual({ model: "sonnet", escalated: true, source: "tier" });
72
+ });
73
+
74
+ it("falls back to global policy when no tier/override is set", () => {
75
+ expect(chooseImplementModel(claude, card({}), 1)).toEqual({
76
+ model: "opus",
77
+ escalated: false,
78
+ source: "policy",
79
+ });
80
+ expect(chooseImplementModel(claude, card({}), 2)).toEqual({
81
+ model: "claude-opus-4-8",
82
+ escalated: true,
83
+ source: "policy",
84
+ });
85
+ expect(chooseImplementModel(claude, card({ priority: "high" }), 1)).toEqual(
86
+ { model: "claude-opus-4-8", escalated: true, source: "policy" },
87
+ );
88
+ });
89
+
90
+ it("spawns Fable 5 unclamped now that it is available again (#747)", () => {
91
+ // Pinned override.
92
+ expect(
93
+ chooseImplementModel(
94
+ claude,
95
+ card({ model_override: "claude-fable-5" }),
96
+ 1,
97
+ ),
98
+ ).toEqual({
99
+ model: "claude-fable-5",
100
+ escalated: false,
101
+ source: "override",
102
+ });
103
+
104
+ // Global policy escalation (the shipped default).
105
+ const fableEscalate = { ...claude, escalateModel: "claude-fable-5" };
106
+ expect(chooseImplementModel(fableEscalate, card({}), 2)).toEqual({
107
+ model: "claude-fable-5",
108
+ escalated: true,
109
+ source: "policy",
110
+ });
111
+
112
+ // Tier mapping.
113
+ const fableTier = {
114
+ ...claude,
115
+ tiers: {
116
+ simple: "haiku",
117
+ advanced: "sonnet",
118
+ research: "claude-fable-5",
119
+ },
120
+ };
121
+ expect(
122
+ chooseImplementModel(fableTier, card({ model_tier: "research" }), 1)
123
+ .model,
124
+ ).toBe("claude-fable-5");
125
+ });
126
+
127
+ it("clamps a retired model id up to the ceiling from any source", () => {
128
+ // Pinned override naming a retired model.
129
+ expect(
130
+ chooseImplementModel(
131
+ claude,
132
+ card({ model_override: "claude-3-5-sonnet-20241022" }),
133
+ 1,
134
+ ),
135
+ ).toEqual({
136
+ model: MAX_IMPLEMENT_MODEL,
137
+ escalated: false,
138
+ source: "override",
139
+ });
140
+
141
+ // Stale config still pointing escalateModel at a retired model.
142
+ const staleConfig = { ...claude, escalateModel: "claude-3-opus-20240229" };
143
+ expect(chooseImplementModel(staleConfig, card({}), 2)).toEqual({
144
+ model: MAX_IMPLEMENT_MODEL,
145
+ escalated: true,
146
+ source: "policy",
147
+ });
148
+
149
+ // Tier mapping pointing at a retired model.
150
+ const retiredTier = {
151
+ ...claude,
152
+ tiers: { simple: "claude-2.1", advanced: "sonnet", research: "opus" },
153
+ };
154
+ expect(
155
+ chooseImplementModel(retiredTier, card({ model_tier: "simple" }), 1)
156
+ .model,
157
+ ).toBe(MAX_IMPLEMENT_MODEL);
158
+ });
159
+
160
+ it("falls back to base model if a tier has no configured mapping", () => {
161
+ const r = chooseImplementModel(
162
+ { ...claude, tiers: { simple: "", advanced: "", research: "" } },
163
+ card({ model_tier: "research" }),
164
+ 1,
165
+ );
166
+ expect(r.model).toBe("opus");
167
+ expect(r.source).toBe("tier");
168
+ });
169
+ });
@@ -0,0 +1,108 @@
1
+ import type { Card } from "@harmony/shared";
2
+ import { escalateTier, isModelTier } from "@harmony/shared";
3
+
4
+ /**
5
+ * The narrow slice of `AgentConfig["claude"]` that {@link chooseImplementModel}
6
+ * actually reads. The daemon's `AgentConfig` carries fields this module never
7
+ * touches (`reviewModel`, `maxTurns`, `leanSettingSources`, …); importing the
8
+ * whole daemon type would pull `harness → agent`, which the dependency
9
+ * direction (agent → harness → shared) forbids. `VerificationConfig.claude` in
10
+ * `exec-types.ts` is a *different* narrow slice of the same daemon field — two
11
+ * narrow types over the same source, each declaring only what its consumer
12
+ * reads.
13
+ */
14
+ export interface ModelTierConfig {
15
+ model: string;
16
+ escalateModel: string;
17
+ escalateAfterAttempts: number;
18
+ tiers: { simple: string; advanced: string; research: string };
19
+ }
20
+
21
+ /**
22
+ * Hard ceiling for any implement run: Fable 5, the most capable model the
23
+ * daemon may spawn — nothing escalates past it. Also the substitute a retired
24
+ * model id is clamped to (see {@link clampWithdrawn}).
25
+ *
26
+ * Fable 5 was previously withdrawn from availability, which is why this used to
27
+ * point at Opus 4.8 and the clamp below matched `/fable/`. It is generally
28
+ * available again (#747), so it is once more the top of the ladder.
29
+ */
30
+ export const MAX_IMPLEMENT_MODEL = "claude-fable-5";
31
+
32
+ /**
33
+ * Retired model ids that must never reach the Claude CLI. Every `claude-2.x`
34
+ * and `claude-3.x` id has now passed its retirement date (the last one,
35
+ * `claude-3-haiku-20240307`, retired 2026-04-19), so the whole 2/3 generation
36
+ * 404s on the API. The 4.x family is still served and is deliberately NOT
37
+ * matched — an operator pinning Opus 4.8 gets Opus 4.8.
38
+ */
39
+ const RETIRED_MODEL = /^claude-[23][.-]/i;
40
+
41
+ /**
42
+ * Clamp a requested model up to {@link MAX_IMPLEMENT_MODEL} if it names a
43
+ * retired model. Applied to every resolution path (pinned override, tier
44
+ * mapping, global policy) so a stale `config.json` or a manually-pinned card
45
+ * can't spawn a model that no longer exists.
46
+ *
47
+ * Exported so the #517 artifact judge clamps its lean tier through the same gate
48
+ * (a stale judge model id can't reach the SDK either).
49
+ */
50
+ export function clampWithdrawn(model: string): string {
51
+ return RETIRED_MODEL.test(model) ? MAX_IMPLEMENT_MODEL : model;
52
+ }
53
+
54
+ /**
55
+ * Pick the model for an implement run.
56
+ *
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`.
65
+ *
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.
68
+ *
69
+ * Pure so the policy is unit-testable without spawning a worker.
70
+ */
71
+ export function chooseImplementModel(
72
+ claude: ModelTierConfig,
73
+ card: Pick<Card, "priority" | "model_tier" | "model_override">,
74
+ attempts: number,
75
+ ): {
76
+ model: string;
77
+ escalated: boolean;
78
+ source: "override" | "tier" | "policy";
79
+ } {
80
+ if (card.model_override) {
81
+ return {
82
+ model: clampWithdrawn(card.model_override),
83
+ escalated: false,
84
+ source: "override",
85
+ };
86
+ }
87
+
88
+ if (isModelTier(card.model_tier)) {
89
+ const retry = attempts >= claude.escalateAfterAttempts;
90
+ const tier = retry ? escalateTier(card.model_tier) : card.model_tier;
91
+ const mapped = claude.tiers?.[tier];
92
+ return {
93
+ model: clampWithdrawn(
94
+ mapped && mapped.length > 0 ? mapped : claude.model,
95
+ ),
96
+ escalated: retry,
97
+ source: "tier",
98
+ };
99
+ }
100
+
101
+ const highPriority = card.priority === "high" || card.priority === "urgent";
102
+ const escalated = highPriority || attempts >= claude.escalateAfterAttempts;
103
+ return {
104
+ model: clampWithdrawn(escalated ? claude.escalateModel : claude.model),
105
+ escalated,
106
+ source: "policy",
107
+ };
108
+ }