@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.
package/src/cli.ts ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harmony-harness stage run --card <id> --stage <id> --workspace <id>
4
+ * --repo <path> --session <id>
5
+ *
6
+ * Prints one JSON object per motor event to stdout, newline delimited, then a
7
+ * final JSON result object. A driver reads these lines; a human reads a
8
+ * driver's rendering of them. The motor writes no prose — everything
9
+ * diagnostic goes to the log on stderr.
10
+ *
11
+ * This file is the wiring, and only the wiring: the decidable rules (argument
12
+ * validation, the fail-loud stage check, the prompt) live in `stage-cli.ts`
13
+ * where they are testable without a live API.
14
+ *
15
+ * What the motor reads from Harmony here is deliberately small: the ONE card it
16
+ * was invoked for, and only that card's `current_stage` / `playbook_id` /
17
+ * `playbook_version` (see harmony-client.ts). That is what lets it resolve the
18
+ * PINNED stage definition whose gate it must collect evidence against. It reads
19
+ * no other card, forms no verdict, and advances nothing.
20
+ */
21
+ import {
22
+ type GateEvidenceContext,
23
+ gateEvaluate,
24
+ type PlaybookVersionDef,
25
+ toStageGateEvidenceInsert,
26
+ } from "@harmony/shared";
27
+ import type { PlaybookMetricDef } from "./exec-types.js";
28
+ import {
29
+ buildGateCollectorRegistry,
30
+ collectGateEvidence,
31
+ } from "./gate-collectors.js";
32
+ import { HarmonyClient, readClientConfig } from "./harmony-client.js";
33
+ import { log } from "./log.js";
34
+ import { place, remove, runHeldOracle } from "./oracle.js";
35
+ import { SdkAgentRunner } from "./sdk-agent-runner.js";
36
+ import {
37
+ assertCardOnStage,
38
+ assertStageIsAgentRunnable,
39
+ buildStagePrompt,
40
+ buildStageRunnerConfig,
41
+ parseMetricsAllowlist,
42
+ parseStageRunArgs,
43
+ resolvePinnedStage,
44
+ STAGE_RUN_USAGE,
45
+ } from "./stage-cli.js";
46
+ import { runStage, type StageRunRequest } from "./stage-run.js";
47
+
48
+ const TAG = "cli";
49
+
50
+ /**
51
+ * Wall-clock cap for the `build_green` gate's build and lint steps. The motor
52
+ * holds no operator config of its own yet (the daemon's `AgentConfig` supplies
53
+ * one on its side), so this is the motor's own default until a driver passes
54
+ * one in — deliberately generous for a cold worktree, and never unbounded.
55
+ */
56
+ const GATE_VERIFICATION_TIMEOUT_MS = 600_000;
57
+
58
+ /**
59
+ * Run the stage's subagent to completion. The launch decides the role, the
60
+ * environment and the tool denies (runner.ts); `envKeysDroppedByLaunch` turns
61
+ * the launch's omissions into the DELETE the spawned child needs — omitting a
62
+ * key would not remove it, because the child's environment is rebuilt from the
63
+ * motor's own `process.env`.
64
+ *
65
+ * The subagent's own events are diagnostics, not the motor's protocol: stdout
66
+ * carries the newline-delimited motor events a driver parses, so these go to
67
+ * the log instead.
68
+ */
69
+ async function runRole(
70
+ request: StageRunRequest,
71
+ prompt: string,
72
+ ): Promise<void> {
73
+ // `buildStageRunnerConfig` owns the role's env strip and tool deny — see
74
+ // stage-cli.ts for why they live behind a tested seam rather than inline here.
75
+ const launch = buildStageRunnerConfig({
76
+ role: request.role,
77
+ prompt,
78
+ repoPath: request.repoPath,
79
+ parentEnv: process.env,
80
+ });
81
+ const runner = new SdkAgentRunner(launch.config);
82
+
83
+ log.info(
84
+ TAG,
85
+ `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`,
86
+ );
87
+ for await (const event of runner.start({
88
+ // The Harmony agent-session id the runner LABELS its events with. It never
89
+ // enters the subagent's context: the prompt is built without it, and
90
+ // `RoleLaunch` has no field for it.
91
+ sessionId: request.sessionId,
92
+ cardId: request.cardId,
93
+ workspaceId: request.workspaceId,
94
+ prompt: launch.prompt,
95
+ cwd: launch.cwd,
96
+ })) {
97
+ if (event.kind === "error") {
98
+ log.warn(TAG, `subagent error: ${event.payload.message}`);
99
+ } else {
100
+ log.debug(TAG, `subagent ${event.kind}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ async function main(): Promise<void> {
106
+ const parsed = parseStageRunArgs(process.argv.slice(2));
107
+ if (!parsed.ok) {
108
+ process.stderr.write(`${parsed.message}\n${STAGE_RUN_USAGE}\n`);
109
+ process.exit(2);
110
+ }
111
+ const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } =
112
+ parsed.args;
113
+
114
+ // The driver's metric allowlist for `custom` gates (--metrics, optional).
115
+ // Read + validated BEFORE anything runs: a driver that passed the flag wants
116
+ // it applied, so an unreadable file refuses the run instead of degrading to
117
+ // the empty allowlist. Absent flag ⇒ {} — a `custom` gate then blocks with
118
+ // the legible "metric not declared" reason, exactly as before.
119
+ let driverMetrics: Record<string, unknown> = {};
120
+ if (metricsPath !== null) {
121
+ const { readFileSync } = await import("node:fs");
122
+ driverMetrics = parseMetricsAllowlist(
123
+ readFileSync(metricsPath, "utf8"),
124
+ metricsPath,
125
+ );
126
+ }
127
+
128
+ const client = new HarmonyClient(readClientConfig(process.env));
129
+
130
+ // Fail loud, collect nothing, when the driver and the board disagree about
131
+ // which stage this card is on. See `assertCardOnStage`.
132
+ const card = await client.fetchStageCard(cardId);
133
+ assertCardOnStage(stageId, card);
134
+
135
+ // The PINNED stage definition, read from the frozen `playbook_versions`
136
+ // snapshot — never live `playbooks.steps`, so editing a playbook cannot
137
+ // retroactively change an in-flight card.
138
+ //
139
+ // The stage def is resolved ONCE and the gate is derived from it, rather than
140
+ // reading the gate with `resolveStageGate` and taking the stage off that.
141
+ // `resolveStageGate` answers null for "this stage declares no gate" and for
142
+ // "the pin is unusable" alike, which made an ungated stage silently lose both
143
+ // its declared `role` and its `entry_action` prompt. Here the two stay
144
+ // distinct: a stage with no gate still runs with everything else it declares,
145
+ // and only an unusable PIN refuses.
146
+ //
147
+ // A refusal is deliberate, and stricter than the old path: running a stage
148
+ // whose definition cannot be read would run the subagent with a fail-closed
149
+ // role, collect nothing, and record nothing — an ungated run that looks
150
+ // successful. Refusing before anything starts is the honest outcome.
151
+ if (!card.playbook_id || card.playbook_version == null) {
152
+ throw new Error(
153
+ `card ${cardId} carries no playbook pin (playbook_id / playbook_version) — refusing to run a stage whose definition cannot be read`,
154
+ );
155
+ }
156
+ const { version } = await client.request<{ version: PlaybookVersionDef }>(
157
+ "GET",
158
+ `/playbooks/${encodeURIComponent(card.playbook_id)}/versions/${card.playbook_version}`,
159
+ );
160
+ // `stageId` and the card's `current_stage` are already known to agree —
161
+ // `assertCardOnStage` above refused the run otherwise.
162
+ const pinned = resolvePinnedStage(version, stageId);
163
+ if (!pinned.ok) {
164
+ throw new Error(
165
+ `refusing to run stage "${stageId}" for card ${cardId}: ${pinned.reason}`,
166
+ );
167
+ }
168
+
169
+ // Refuse a human-owned stage HERE — after the pinned def is readable (that is
170
+ // where `owner` lives) and before the prompt, the subagent, or any write. See
171
+ // `assertStageIsAgentRunnable`: the driver, not the motor, decides to wait on
172
+ // a person, and the motor's job is to make running their stage impossible by
173
+ // accident.
174
+ assertStageIsAgentRunnable(pinned.stage);
175
+
176
+ const prompt = buildStagePrompt({ cardId, stageId, stage: pinned.stage });
177
+
178
+ const request: StageRunRequest = {
179
+ cardId,
180
+ stageId,
181
+ workspaceId,
182
+ repoPath,
183
+ sessionId,
184
+ role: pinned.stage.role ?? null,
185
+ };
186
+
187
+ const result = await runStage(request, {
188
+ // Already resolved above, because the stage's ROLE and prompt come off the
189
+ // same pinned def and have to be known before the subagent launches.
190
+ resolveGate: async () => pinned.gate,
191
+ runRole: (req) => runRole(req, prompt),
192
+ collect: async (req, gate) => {
193
+ const registry = buildGateCollectorRegistry({
194
+ build: {
195
+ worktreePath: req.repoPath,
196
+ buildTimeout: GATE_VERIFICATION_TIMEOUT_MS,
197
+ lintTimeout: GATE_VERIFICATION_TIMEOUT_MS,
198
+ },
199
+ // The metric allowlist is the DRIVER's, passed via --metrics — the
200
+ // motor still holds no operator config of its own. Without the flag
201
+ // this is {} and a `custom` gate reports "metric X is not declared",
202
+ // naming the config key to edit — a far better hold reason than the
203
+ // dispatcher's generic "no collector for kind custom". Both are
204
+ // `blocked`; only one tells the operator what to do about it.
205
+ // The cast mirrors how the daemon's own JSON config reaches the same
206
+ // collector: per-declaration validation is the collector's job and it
207
+ // fails closed with the key name on anything malformed.
208
+ command: {
209
+ worktreePath: req.repoPath,
210
+ metrics: driverMetrics as Record<string, PlaybookMetricDef>,
211
+ },
212
+ oracle: {
213
+ repoPath: req.repoPath,
214
+ sessionId: req.sessionId,
215
+ fetchOracle: (oracleCardId, oracleStageId, oracleSessionId) =>
216
+ client.fetchOracle(oracleCardId, oracleStageId, oracleSessionId),
217
+ place,
218
+ remove,
219
+ run: runHeldOracle,
220
+ },
221
+ });
222
+ return await collectGateEvidence(registry, {
223
+ cardId: req.cardId,
224
+ stageId: req.stageId,
225
+ gate,
226
+ workspaceId: req.workspaceId,
227
+ });
228
+ },
229
+ });
230
+
231
+ for (const event of result.events) {
232
+ process.stdout.write(`${JSON.stringify(event)}\n`);
233
+ }
234
+
235
+ if (result.evidence && pinned.gate) {
236
+ const context: GateEvidenceContext = {
237
+ cardId,
238
+ stageId,
239
+ gate: pinned.gate,
240
+ workspaceId,
241
+ };
242
+ // The stored row IS the evidence, and the human-advance edge re-evaluates
243
+ // it with this same pure function — that is the no-drift guarantee.
244
+ // `gateEvaluate` runs here ONLY to print a verdict for a human reader; the
245
+ // motor must not become a second authority on pass or fail. The write comes
246
+ // first, so a printed `gate_verdict` always implies a persisted row.
247
+ const evaluation = gateEvaluate(pinned.gate, result.evidence);
248
+ await client.recordStageGateEvidence(
249
+ toStageGateEvidenceInsert(context, result.evidence),
250
+ );
251
+ process.stdout.write(
252
+ `${JSON.stringify({ type: "gate_verdict", passed: evaluation.passed, findings: evaluation.findings })}\n`,
253
+ );
254
+ }
255
+
256
+ process.stdout.write(`${JSON.stringify({ type: "result", ...result })}\n`);
257
+ }
258
+
259
+ main().catch((err: unknown) => {
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ // `fetch` reports a transport failure as a bare "fetch failed" and puts the
262
+ // real reason (ECONNREFUSED, DNS, TLS) on `cause`. Fold it in — a driver that
263
+ // only sees "fetch failed" cannot tell a wrong URL from a down API.
264
+ const cause =
265
+ err instanceof Error && err.cause instanceof Error
266
+ ? ` (${err.cause.message})`
267
+ : "";
268
+ process.stderr.write(
269
+ `${JSON.stringify({ type: "error", message: `${message}${cause}` })}\n`,
270
+ );
271
+ process.exit(1);
272
+ });