@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/src/cli.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from "./gate-collectors.js";
32
32
  import { HarmonyClient, readClientConfig } from "./harmony-client.js";
33
33
  import { log } from "./log.js";
34
+ import { relayAgentEvent } from "./motor-stream.js";
34
35
  import { place, remove, runHeldOracle } from "./oracle.js";
35
36
  import { SdkAgentRunner } from "./sdk-agent-runner.js";
36
37
  import {
@@ -38,11 +39,13 @@ import {
38
39
  assertStageIsAgentRunnable,
39
40
  buildStagePrompt,
40
41
  buildStageRunnerConfig,
42
+ describeShutdown,
41
43
  findOracleTargetStage,
42
44
  parseMetricsAllowlist,
43
45
  parseStageRunArgs,
44
46
  resolvePinnedStage,
45
47
  STAGE_RUN_USAGE,
48
+ stageTimeoutMs,
46
49
  } from "./stage-cli.js";
47
50
  import { runStage, type StageRunRequest } from "./stage-run.js";
48
51
 
@@ -56,6 +59,65 @@ const TAG = "cli";
56
59
  */
57
60
  const GATE_VERIFICATION_TIMEOUT_MS = 600_000;
58
61
 
62
+ /** Hard exit if the motor's own teardown does not finish after a driver signal. */
63
+ const SHUTDOWN_HARD_EXIT_MS = 15_000;
64
+
65
+ /**
66
+ * The subagent runner for the stage currently in flight, published so the
67
+ * signal handlers can tear it down (card #885).
68
+ *
69
+ * A driver signals THIS process. The `claude` subagent is a grandchild in its
70
+ * OWN process group (`spawnInGroup` detaches it), so a group kill aimed at the
71
+ * motor never reaches it — only the motor can, through `SdkAgentRunner.stop`,
72
+ * which aborts the query and then escalates on the subagent's own pgid. Without
73
+ * a handler here, Node's default SIGTERM disposition killed the motor outright
74
+ * and left a token-spending `claude` writing into a worktree the driver was
75
+ * already tearing down.
76
+ */
77
+ let activeRunner: SdkAgentRunner | null = null;
78
+ let shuttingDown = false;
79
+
80
+ /**
81
+ * Stop the stage subagent, then exit non-zero with the motor's error protocol
82
+ * so the driver reads a cause rather than a bare signal death. Re-entrant: a
83
+ * second signal while the first teardown runs is ignored, and the unref'd hard
84
+ * exit is the floor under a `stop()` that never returns.
85
+ */
86
+ async function shutdown(signal: NodeJS.Signals): Promise<void> {
87
+ if (shuttingDown) return;
88
+ shuttingDown = true;
89
+ const runner = activeRunner;
90
+ // `describeShutdown` refuses to claim a teardown that did not happen: no
91
+ // subagent is in flight during argument parsing, the card and playbook
92
+ // fetches, or the whole gate-collection window, and a driver renders this
93
+ // message verbatim.
94
+ const plan = describeShutdown(signal, runner !== null);
95
+ // Written BEFORE the teardown. Node writes stdout/stderr to a pipe
96
+ // synchronously on Windows and Linux but ASYNCHRONOUSLY on macOS, and
97
+ // `process.exit` does not flush a pending async write — so on the platform
98
+ // this daemon usually runs on, a line written immediately before exiting can
99
+ // be lost outright. Emitting it here gives the teardown's own duration as the
100
+ // flush margin.
101
+ //
102
+ // Note this line reaches a HUMAN or the interactive `hmy` driver, not the
103
+ // daemon: when the daemon signals, it does so through its own abort, which
104
+ // settles `runMotorStage` synchronously with its own `errorMessage` and never
105
+ // reads this one.
106
+ process.stderr.write(
107
+ `${JSON.stringify({ type: "error", message: plan.message })}\n`,
108
+ );
109
+ const hardExit = setTimeout(() => process.exit(1), SHUTDOWN_HARD_EXIT_MS);
110
+ hardExit.unref?.();
111
+ if (plan.stopSubagent) {
112
+ try {
113
+ await runner?.stop("shutdown");
114
+ } catch {
115
+ // Already gone — the exit below is the point.
116
+ }
117
+ }
118
+ process.exit(1);
119
+ }
120
+
59
121
  /**
60
122
  * Run the stage's subagent to completion. The launch decides the role, the
61
123
  * environment and the tool denies (runner.ts); `envKeysDroppedByLaunch` turns
@@ -63,13 +125,18 @@ const GATE_VERIFICATION_TIMEOUT_MS = 600_000;
63
125
  * key would not remove it, because the child's environment is rebuilt from the
64
126
  * motor's own `process.env`.
65
127
  *
66
- * The subagent's own events are diagnostics, not the motor's protocol: stdout
67
- * carries the newline-delimited motor events a driver parses, so these go to
68
- * the log instead.
128
+ * The subagent's events are RELAYED to the driver on stdout (`agent_event`,
129
+ * card #885) and logged on stderr. They are diagnostics to a human reader and
130
+ * live telemetry to a driver: the relayed draft is the very shape the daemon's
131
+ * `ProgressTracker` ingests, which is what puts the run's turns, tokens, cost
132
+ * and tool actions on the board. The stderr line is `log.event`, not
133
+ * `log.debug` — a default-level run used to print exactly one line for a
134
+ * nine-minute stage, because `debug` is dropped unless `DEBUG` is set.
69
135
  */
70
136
  async function runRole(
71
137
  request: StageRunRequest,
72
138
  prompt: string,
139
+ emit: (line: unknown) => void,
73
140
  ): Promise<void> {
74
141
  // `buildStageRunnerConfig` owns the role's env strip and tool deny — see
75
142
  // stage-cli.ts for why they live behind a tested seam rather than inline here.
@@ -80,29 +147,94 @@ async function runRole(
80
147
  parentEnv: process.env,
81
148
  });
82
149
  const runner = new SdkAgentRunner(launch.config);
150
+ activeRunner = runner;
83
151
 
84
152
  log.info(
85
153
  TAG,
86
154
  `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`,
87
155
  );
88
- for await (const event of runner.start({
89
- // The Harmony agent-session id the runner LABELS its events with. It never
90
- // enters the subagent's context: the prompt is built without it, and
91
- // `RoleLaunch` has no field for it.
92
- sessionId: request.sessionId,
93
- cardId: request.cardId,
94
- workspaceId: request.workspaceId,
95
- prompt: launch.prompt,
96
- cwd: launch.cwd,
97
- })) {
98
- if (event.kind === "error") {
99
- log.warn(TAG, `subagent error: ${event.payload.message}`);
100
- } else {
101
- log.debug(TAG, `subagent ${event.kind}`);
156
+
157
+ // The motor's own clock. `stop("timeout")` runs the same cooperative
158
+ // abort + pgid escalation a driver's signal would, so the subagent dies with
159
+ // the motor instead of outliving it — and the `for await` below ends, which
160
+ // is what lets the run report the timeout instead of hanging on it.
161
+ const timeoutMs = stageTimeoutMs(process.env);
162
+ let timedOut = false;
163
+ const clock =
164
+ timeoutMs > 0
165
+ ? setTimeout(() => {
166
+ timedOut = true;
167
+ log.warn(
168
+ TAG,
169
+ `stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`,
170
+ );
171
+ void runner.stop("timeout");
172
+ }, timeoutMs)
173
+ : null;
174
+ clock?.unref?.();
175
+
176
+ try {
177
+ for await (const event of runner.start({
178
+ // The Harmony agent-session id the runner LABELS its events with. It never
179
+ // enters the subagent's context: the prompt is built without it, and
180
+ // `RoleLaunch` has no field for it.
181
+ sessionId: request.sessionId,
182
+ cardId: request.cardId,
183
+ workspaceId: request.workspaceId,
184
+ prompt: launch.prompt,
185
+ cwd: launch.cwd,
186
+ })) {
187
+ const relayed = relayAgentEvent(event);
188
+ if (relayed) emit(relayed);
189
+ if (event.kind === "error") {
190
+ log.warn(TAG, `subagent error: ${event.payload.message}`);
191
+ } else {
192
+ log.event(TAG, `subagent ${event.kind}`);
193
+ }
102
194
  }
195
+ } finally {
196
+ if (clock) clearTimeout(clock);
197
+ activeRunner = null;
198
+ }
199
+
200
+ if (timedOut) {
201
+ throw new Error(
202
+ `stage ${request.stageId} exceeded its ${timeoutMs}ms wall-clock bound and its subagent was stopped`,
203
+ );
103
204
  }
104
205
  }
105
206
 
207
+ /**
208
+ * Write one protocol line to stdout, now. Every line the motor prints goes
209
+ * through here so "when a driver sees it" has exactly one answer: at the moment
210
+ * it happened (card #885).
211
+ *
212
+ * Total by design. A driver that went away leaves a closed pipe, and the write
213
+ * then fails EPIPE — which must not abort a stage run that is otherwise fine
214
+ * and whose evidence row is already persisted. Nobody is left to read the line,
215
+ * so there is nothing to salvage by failing here.
216
+ *
217
+ * The `try` alone does not deliver that. A pipe write is synchronous on Windows
218
+ * and Linux, where EPIPE arrives as a throw and is caught here — but it is
219
+ * ASYNCHRONOUS on macOS, where it arrives as an `'error'` event instead and
220
+ * would go unhandled. Hence the stream handlers installed below. That path
221
+ * matters far more since #885: stdout now carries thousands of lines during a
222
+ * run rather than four at the end of it.
223
+ */
224
+ function emitLine(line: unknown): void {
225
+ try {
226
+ process.stdout.write(`${JSON.stringify(line)}\n`);
227
+ } catch {
228
+ // Deliberately swallowed — see above.
229
+ }
230
+ }
231
+
232
+ // The async half of the same contract: an EPIPE on a macOS pipe surfaces here,
233
+ // not as a throw, and an unhandled stream 'error' would take the motor down
234
+ // mid-stage over an audience that already left.
235
+ process.stdout.on("error", () => {});
236
+ process.stderr.on("error", () => {});
237
+
106
238
  async function main(): Promise<void> {
107
239
  const parsed = parseStageRunArgs(process.argv.slice(2));
108
240
  if (!parsed.ok) {
@@ -192,10 +324,13 @@ async function main(): Promise<void> {
192
324
  };
193
325
 
194
326
  const result = await runStage(request, {
327
+ // Flush every protocol event when it happens (card #885). `runStage` still
328
+ // accumulates them for the final `result` line — this is the live copy.
329
+ emit: emitLine,
195
330
  // Already resolved above, because the stage's ROLE and prompt come off the
196
331
  // same pinned def and have to be known before the subagent launches.
197
332
  resolveGate: async () => pinned.gate,
198
- runRole: (req) => runRole(req, prompt),
333
+ runRole: (req) => runRole(req, prompt, emitLine),
199
334
  collect: async (req, gate) => {
200
335
  const registry = buildGateCollectorRegistry({
201
336
  build: {
@@ -235,10 +370,9 @@ async function main(): Promise<void> {
235
370
  },
236
371
  });
237
372
 
238
- for (const event of result.events) {
239
- process.stdout.write(`${JSON.stringify(event)}\n`);
240
- }
241
-
373
+ // `result.events` is NOT reprinted here: `emit` above already wrote each one
374
+ // at the moment it happened, and a second pass would duplicate every line for
375
+ // a driver that parses the stream.
242
376
  if (result.evidence && pinned.gate) {
243
377
  const context: GateEvidenceContext = {
244
378
  cardId,
@@ -255,12 +389,23 @@ async function main(): Promise<void> {
255
389
  await client.recordStageGateEvidence(
256
390
  toStageGateEvidenceInsert(context, result.evidence),
257
391
  );
258
- process.stdout.write(
259
- `${JSON.stringify({ type: "gate_verdict", passed: evaluation.passed, findings: evaluation.findings })}\n`,
260
- );
392
+ emitLine({
393
+ type: "gate_verdict",
394
+ passed: evaluation.passed,
395
+ findings: evaluation.findings,
396
+ });
261
397
  }
262
398
 
263
- process.stdout.write(`${JSON.stringify({ type: "result", ...result })}\n`);
399
+ emitLine({ type: "result", ...result });
400
+ }
401
+
402
+ // Installed BEFORE main runs: a driver that signals during argument parsing or
403
+ // the first API call must still get the motor's error protocol rather than a
404
+ // silent signal death (card #885).
405
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
406
+ process.on(signal, () => {
407
+ void shutdown(signal);
408
+ });
264
409
  }
265
410
 
266
411
  main().catch((err: unknown) => {
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ confineToRepo,
4
+ decideConfinedTool,
5
+ isInsideTree,
6
+ } from "./confine-to-repo.js";
7
+
8
+ const ROOT = "/Users/dev/repo";
9
+
10
+ function decide(tool: string, input: Record<string, unknown>) {
11
+ return decideConfinedTool(ROOT, tool, input);
12
+ }
13
+
14
+ describe("isInsideTree", () => {
15
+ it("accepts the root itself and anything under it", () => {
16
+ expect(isInsideTree(ROOT, ROOT)).toBe(true);
17
+ expect(isInsideTree(ROOT, `${ROOT}/src/index.ts`)).toBe(true);
18
+ expect(isInsideTree(ROOT, `${ROOT}/a/b/c/d.ts`)).toBe(true);
19
+ });
20
+
21
+ it("rejects a sibling whose name merely starts with the root", () => {
22
+ // The separator check is the whole point: without it `/Users/dev/repo-secrets`
23
+ // reads as inside `/Users/dev/repo`.
24
+ expect(isInsideTree(ROOT, "/Users/dev/repo-secrets/.env")).toBe(false);
25
+ expect(isInsideTree(ROOT, "/Users/dev/repository/x")).toBe(false);
26
+ });
27
+
28
+ it("rejects a parent and an unrelated tree", () => {
29
+ expect(isInsideTree(ROOT, "/Users/dev")).toBe(false);
30
+ expect(isInsideTree(ROOT, "/etc/passwd")).toBe(false);
31
+ });
32
+
33
+ it("normalises traversal before comparing", () => {
34
+ expect(isInsideTree(ROOT, `${ROOT}/../repo/src/a.ts`)).toBe(true);
35
+ expect(isInsideTree(ROOT, `${ROOT}/../../etc/passwd`)).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("decideConfinedTool", () => {
40
+ it("allows a read inside the tree", () => {
41
+ expect(decide("Read", { file_path: `${ROOT}/src/index.ts` })).toEqual({
42
+ behavior: "allow",
43
+ });
44
+ });
45
+
46
+ it("resolves a relative path against the repo, not process.cwd()", () => {
47
+ // The daemon's own cwd may be somewhere else entirely.
48
+ expect(decide("Read", { file_path: "src/index.ts" })).toEqual({
49
+ behavior: "allow",
50
+ });
51
+ expect(decide("Read", { file_path: "../../.ssh/id_rsa" }).behavior).toBe(
52
+ "deny",
53
+ );
54
+ });
55
+
56
+ // --- The paths this exists to close. ---
57
+
58
+ it("denies the operator's credential directory", () => {
59
+ const r = decide("Read", {
60
+ file_path: "/Users/dev/.harmony-mcp/config.json",
61
+ });
62
+ expect(r.behavior).toBe("deny");
63
+ });
64
+
65
+ it("denies an ssh key", () => {
66
+ expect(
67
+ decide("Read", { file_path: "/Users/dev/.ssh/id_rsa" }).behavior,
68
+ ).toBe("deny");
69
+ });
70
+
71
+ it("denies traversal out of the tree", () => {
72
+ expect(
73
+ decide("Read", { file_path: `${ROOT}/../../.aws/credentials` }).behavior,
74
+ ).toBe("deny");
75
+ });
76
+
77
+ it("denies a sibling directory that shares the root's prefix", () => {
78
+ expect(
79
+ decide("Read", { file_path: "/Users/dev/repo-secrets/.env" }).behavior,
80
+ ).toBe("deny");
81
+ });
82
+
83
+ it("names the refused path so the denial is legible", () => {
84
+ const r = decide("Read", { file_path: "/etc/passwd" });
85
+ expect(r.behavior).toBe("deny");
86
+ if (r.behavior === "deny") expect(r.message).toContain("/etc/passwd");
87
+ });
88
+
89
+ // --- Every read-only tool, and every argument each can carry. ---
90
+
91
+ it("checks Grep and Glob paths too", () => {
92
+ expect(decide("Grep", { path: `${ROOT}/src` })).toEqual({
93
+ behavior: "allow",
94
+ });
95
+ expect(decide("Grep", { path: "/etc" }).behavior).toBe("deny");
96
+ expect(decide("Glob", { path: "/Users/dev" }).behavior).toBe("deny");
97
+ });
98
+
99
+ it("checks every path argument a Read can carry", () => {
100
+ expect(decide("Read", { path: "/etc/hosts" }).behavior).toBe("deny");
101
+ expect(decide("Read", { notebook_path: "/etc/x.ipynb" }).behavior).toBe(
102
+ "deny",
103
+ );
104
+ });
105
+
106
+ it("allows a pathless call, which defaults to the session cwd", () => {
107
+ // Grep with no `path` searches cwd — which IS the confined tree.
108
+ expect(decide("Grep", { pattern: "foo" })).toEqual({ behavior: "allow" });
109
+ expect(decide("Glob", { pattern: "**/*.ts" })).toEqual({
110
+ behavior: "allow",
111
+ });
112
+ });
113
+
114
+ it("ignores a non-string or empty path rather than crashing", () => {
115
+ expect(decide("Read", { file_path: 42 })).toEqual({ behavior: "allow" });
116
+ expect(decide("Read", { file_path: "" })).toEqual({ behavior: "allow" });
117
+ expect(decide("Read", { file_path: null })).toEqual({ behavior: "allow" });
118
+ });
119
+
120
+ it("denies any tool it does not recognise", () => {
121
+ // Fails closed: an unrecognised tool means the allow-list drifted or the
122
+ // model reached for something this spawn should not have.
123
+ expect(decide("Bash", { command: "cat /etc/passwd" }).behavior).toBe(
124
+ "deny",
125
+ );
126
+ expect(decide("Write", { file_path: `${ROOT}/x.ts` }).behavior).toBe(
127
+ "deny",
128
+ );
129
+ expect(decide("Edit", { file_path: `${ROOT}/x.ts` }).behavior).toBe("deny");
130
+ expect(decide("WebFetch", { url: "https://x" }).behavior).toBe("deny");
131
+ });
132
+ });
133
+
134
+ describe("confineToRepo", () => {
135
+ it("returns the async shape the SDK expects", async () => {
136
+ const handler = confineToRepo(ROOT);
137
+ await expect(
138
+ handler("Read", { file_path: `${ROOT}/a.ts` }),
139
+ ).resolves.toEqual({ behavior: "allow" });
140
+ await expect(
141
+ handler("Read", { file_path: "/etc/passwd" }),
142
+ ).resolves.toMatchObject({ behavior: "deny" });
143
+ });
144
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Confine a read-only spawn to one directory tree.
3
+ *
4
+ * ## Why a handler and not a permission string
5
+ *
6
+ * The obvious move is a scoped rule in `allowedTools` — `Read(/**)` and friends.
7
+ * The Agent SDK documents `allowedTools` as a list of tool NAMES ("To restrict
8
+ * which tools are available, use the `tools` option instead"), so a rule-shaped
9
+ * entry there most likely matches no tool at all. That failure is silent and
10
+ * expensive: the tool is denied, the preflight returns no verdict, `sizeRun`
11
+ * answers `null`, and the daemon degrades to the policy fallback forever with
12
+ * nothing in the logs to say the sizing step stopped working.
13
+ *
14
+ * `canUseTool` is documented to run before each tool execution and receives the
15
+ * tool's INPUT, so the path can be checked directly. It is an ordinary function,
16
+ * which means the rule is unit-testable rather than a string whose semantics we
17
+ * would be guessing at.
18
+ *
19
+ * ## What it is for
20
+ *
21
+ * The pickup sizing preflight reads the operator's PRIMARY checkout, not a
22
+ * disposable worktree — the card's worktree does not exist when the model has to
23
+ * be chosen. Its prompt is built from card text anyone in the workspace can
24
+ * write. Denying the credential directory by name closes the worst path, but it
25
+ * is a blocklist: a repo-root `.env`, an `~/.ssh` key, or anything else the
26
+ * daemon's user can read stays reachable. This inverts that into an allowlist —
27
+ * inside the tree, or denied.
28
+ *
29
+ * Prompt-level containment (JSON-encoded card data) makes injection harder; this
30
+ * is what bounds it when that fails.
31
+ */
32
+ import { isAbsolute, resolve, sep } from "node:path";
33
+
34
+ /** The result shape the Agent SDK's `canUseTool` must return. */
35
+ export type ConfineDecision =
36
+ | { behavior: "allow" }
37
+ | { behavior: "deny"; message: string };
38
+
39
+ /**
40
+ * Where each read-only tool carries the path it wants to touch.
41
+ *
42
+ * A tool absent from this map is denied outright rather than allowed: this
43
+ * guards a spawn whose allow-list is meant to be exactly these three, so an
44
+ * unrecognised tool means either the allow-list drifted or the model reached
45
+ * for something it should not have. Failing closed is the honest answer to
46
+ * "I do not know what this tool would do".
47
+ */
48
+ const PATH_ARG_BY_TOOL: Record<string, readonly string[]> = {
49
+ Read: ["file_path", "path", "notebook_path"],
50
+ Grep: ["path"],
51
+ Glob: ["path"],
52
+ };
53
+
54
+ /** Is `target` inside `root` (or root itself)? */
55
+ export function isInsideTree(root: string, target: string): boolean {
56
+ const normalizedRoot = resolve(root);
57
+ const normalizedTarget = resolve(target);
58
+ if (normalizedTarget === normalizedRoot) return true;
59
+ // The separator matters: without it `/repo-secrets` reads as inside `/repo`.
60
+ return normalizedTarget.startsWith(normalizedRoot + sep);
61
+ }
62
+
63
+ /**
64
+ * Decide one tool call. Pure, so the policy is testable without a spawn.
65
+ *
66
+ * A tool with no path argument is allowed: `Grep` and `Glob` default to the
67
+ * session's `cwd`, which IS the tree being confined to. A relative path is
68
+ * resolved against that same root rather than `process.cwd()`, which may be
69
+ * somewhere else entirely in the daemon.
70
+ */
71
+ export function decideConfinedTool(
72
+ repoRoot: string,
73
+ toolName: string,
74
+ input: Record<string, unknown>,
75
+ ): ConfineDecision {
76
+ const pathArgs = PATH_ARG_BY_TOOL[toolName];
77
+ if (!pathArgs) {
78
+ return {
79
+ behavior: "deny",
80
+ message: `${toolName} is not available to this run.`,
81
+ };
82
+ }
83
+
84
+ for (const key of pathArgs) {
85
+ const value = input[key];
86
+ if (typeof value !== "string" || value.length === 0) continue;
87
+ const candidate = isAbsolute(value) ? value : resolve(repoRoot, value);
88
+ if (!isInsideTree(repoRoot, candidate)) {
89
+ return {
90
+ behavior: "deny",
91
+ message: `${toolName} may only read inside the repository. Refused: ${value}`,
92
+ };
93
+ }
94
+ }
95
+
96
+ return { behavior: "allow" };
97
+ }
98
+
99
+ /**
100
+ * Build the `canUseTool` handler for a spawn confined to `repoRoot`.
101
+ *
102
+ * Kept separate from {@link decideConfinedTool} so the policy stays synchronous
103
+ * and testable while the SDK gets the async shape it expects.
104
+ */
105
+ export function confineToRepo(
106
+ repoRoot: string,
107
+ ): (
108
+ toolName: string,
109
+ input: Record<string, unknown>,
110
+ ) => Promise<ConfineDecision> {
111
+ return async (toolName, input) =>
112
+ decideConfinedTool(repoRoot, toolName, input);
113
+ }
@@ -1,73 +1,23 @@
1
1
  /**
2
- * The "this gate cannot be measured as configured" marker (card #823).
2
+ * Re-export of the "gate cannot be measured as configured" marker (#823), which
3
+ * now lives in `@harmony/shared` (`gateConfigError.ts` — read its module doc for
4
+ * the whole rule; card #914 moved it).
3
5
  *
4
- * A gate that reports `blocked` says only that no measurement exists. Two very
5
- * different situations produce it, and the advancement engine must not treat them
6
- * alike:
6
+ * It moved because the marker is a persistence contract on
7
+ * `stage_gate_evidence.structured` with readers on three runtimes: the harness
8
+ * collector writes it, the daemon's `stage-advance.ts` holds on it, and the Deno
9
+ * edge's `decideAdvance` now reads it so the board can tell a misconfigured gate
10
+ * apart from an unmet one. The edge cannot import the harness (dependency
11
+ * direction is agent → harness → shared), and a second spelling of the key in the
12
+ * edge is the per-path drift #495 forbids.
7
13
  *
8
- * - **The measurement failed.** The command exited non-zero, timed out, or
9
- * printed something unparsable. A re-run can genuinely change the outcome —
10
- * the tool may be flaky, the branch may be fixed by the next attempt — so this
11
- * keeps the existing retry path (`handleGateUnmet`).
12
- * - **The gate is misconfigured.** The metric name is undeclared, the
13
- * declaration carries no `command`, or its `parse` mode is unknown. Nothing
14
- * ran and nothing ever will: the inputs are static config, so attempt N+1
15
- * computes exactly the same answer as attempt 1. Re-running costs a full
16
- * Claude implementation run each time and cannot help.
17
- *
18
- * Before this marker existed, both routed into the generic "gate unmet → re-run
19
- * this stage" path, so a typo'd metric name burned the card's whole `maxAttempts`
20
- * budget (and a converge loop's whole iteration budget) before holding. The
21
- * collector now tags the config-defect blocks, and `stage-advance.ts` holds on
22
- * them immediately with the attempt rolled back.
23
- *
24
- * The marker is a plain own key on the evidence's `structured` doc, so it survives
25
- * the round-trip every other field takes: `gateEvaluate` echoes `structured` into
26
- * its `GateEvaluation` verbatim, and `stage_gate_evidence.structured` persists it
27
- * for the board/edge to read later. It is deliberately NOT part of the gate
28
- * predicate surface — `blockedDetail` composes the human-facing finding from
29
- * `structured.reason` alone, so adding this key changes no verdict text.
30
- *
31
- * Fail-safe by omission: evidence without the key reads as retryable, which is the
32
- * pre-#823 behavior. A collector opts a block IN to the hold-immediately path; it
33
- * can never opt one out by accident.
34
- */
35
-
36
- /** Own key on `GateEvidence.structured` marking a block as a configuration defect. */
37
- export const GATE_CONFIG_ERROR_KEY = "configError";
38
-
39
- /** The structured fragment a collector spreads into config-defect `blocked` evidence. */
40
- export const GATE_CONFIG_ERROR_MARK: Readonly<Record<string, unknown>> =
41
- Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
42
-
43
- /** The minimal evaluation shape this predicate reads — structurally a `GateEvaluation`. */
44
- interface EvaluationLike {
45
- passed: boolean;
46
- structured: Record<string, unknown>;
47
- }
48
-
49
- /**
50
- * The reason a gate cannot be measured as configured, or `null` when this is a
51
- * normal (retryable) verdict. PURE + TOTAL — never throws, so a malformed
52
- * structured doc degrades to "retryable" rather than crashing the engine.
53
- *
54
- * A *passing* evaluation is never a config error: `gateEvaluate` cannot pass
55
- * blocked evidence, so a marked-and-passed evaluation would mean the marker was
56
- * forged into a passing doc. Reading `passed` first makes that unreachable instead
57
- * of merely unlikely.
14
+ * This file stays as the harness's own surface: `@gethmy/harness` exports these
15
+ * three names (`packages/harmony-agent/src/stage-advance.ts` and the motor-driver
16
+ * tests import them from the published package), so re-exporting keeps every
17
+ * existing import path working with no consumer change.
58
18
  */
59
- export function gateConfigErrorReason(
60
- evaluation: EvaluationLike | null | undefined,
61
- ): string | null {
62
- if (!evaluation || evaluation.passed) return null;
63
- const structured = evaluation.structured;
64
- if (!structured || typeof structured !== "object") return null;
65
- if (!Object.hasOwn(structured, GATE_CONFIG_ERROR_KEY)) return null;
66
- if ((structured as Record<string, unknown>)[GATE_CONFIG_ERROR_KEY] !== true) {
67
- return null;
68
- }
69
- const reason = (structured as Record<string, unknown>).reason;
70
- return typeof reason === "string" && reason.trim().length > 0
71
- ? reason.trim()
72
- : "the gate cannot be measured as configured";
73
- }
19
+ export {
20
+ GATE_CONFIG_ERROR_KEY,
21
+ GATE_CONFIG_ERROR_MARK,
22
+ gateConfigErrorReason,
23
+ } from "@harmony/shared";
@@ -52,6 +52,8 @@ export type StageCardPin = Pick<
52
52
 
53
53
  /** The oracle payload `POST /stage-oracle/fetch` returns (camelCase, edge-side). */
54
54
  interface OracleResponse {
55
+ /** The row id — absent from pre-#927 deployments, so optional here. */
56
+ id?: string | null;
55
57
  path: string;
56
58
  content: string;
57
59
  runnerHint?: string | null;
@@ -162,6 +164,7 @@ export class HarmonyClient {
162
164
  }
163
165
  const body = (await response.json()) as OracleResponse;
164
166
  return {
167
+ id: body.id ?? null,
165
168
  path: body.path,
166
169
  content: body.content,
167
170
  runnerHint: body.runnerHint ?? null,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export const MOTOR_NAME = "harmony-harness";
14
14
 
15
15
  export * from "./artifact-judge.js";
16
16
  export * from "./command-metric.js";
17
+ export * from "./confine-to-repo.js";
17
18
  export * from "./error-classifier.js";
18
19
  export * from "./exec-types.js";
19
20
  export * from "./gate-collectors.js";
@@ -23,6 +24,7 @@ export * from "./git-pr.js";
23
24
  export * from "./harmony-client.js";
24
25
  export * from "./log.js";
25
26
  export * from "./model-tier.js";
27
+ export * from "./motor-stream.js";
26
28
  export * from "./oracle.js";
27
29
  export * from "./oracle-collector.js";
28
30
  export * from "./pm.js";
@@ -30,6 +32,7 @@ export * from "./process-group.js";
30
32
  export * from "./project-type.js";
31
33
  export * from "./revert-guard.js";
32
34
  export * from "./review-types.js";
35
+ export * from "./run-sizing.js";
33
36
  export * from "./runner.js";
34
37
  export * from "./sdk-agent-runner.js";
35
38
  export * from "./stage-run.js";