@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.
- package/dist/cli.js +213 -51
- package/dist/index.js +372 -49
- package/package.json +2 -2
- package/src/cli.ts +171 -26
- package/src/confine-to-repo.test.ts +144 -0
- package/src/confine-to-repo.ts +113 -0
- package/src/gate-config-error.ts +19 -69
- package/src/harmony-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/model-tier.test.ts +88 -24
- package/src/model-tier.ts +45 -15
- package/src/motor-stream.ts +120 -0
- package/src/oracle-collector.ts +15 -1
- package/src/oracle.ts +7 -0
- package/src/run-sizing.test.ts +321 -0
- package/src/run-sizing.ts +393 -0
- package/src/runner.ts +16 -0
- package/src/sdk-agent-runner.ts +44 -3
- package/src/stage-cli.ts +94 -2
- package/src/stage-run.ts +32 -4
- package/src/worktree.ts +117 -20
package/src/model-tier.test.ts
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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({
|
|
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
|
|
75
|
-
|
|
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(
|
|
86
|
-
|
|
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({
|
|
123
|
-
|
|
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({
|
|
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({
|
|
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
|
|
58
|
-
* 1. `card.model_override` — a
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
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" | "
|
|
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(
|
|
112
|
+
model: clampWithdrawn(pinned),
|
|
83
113
|
escalated: false,
|
|
84
114
|
source: "override",
|
|
85
115
|
};
|
|
86
116
|
}
|
|
87
117
|
|
|
88
|
-
if (isModelTier(
|
|
118
|
+
if (sized && isModelTier(sized.tier)) {
|
|
89
119
|
const retry = attempts >= claude.escalateAfterAttempts;
|
|
90
|
-
const tier = retry ? escalateTier(
|
|
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
|
+
}
|
package/src/oracle-collector.ts
CHANGED
|
@@ -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: {
|
|
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;
|