@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/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
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
|
+
});
|