@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,302 @@
1
+ /**
2
+ * The stage-run CLI's decidable parts: argument validation, the fail-loud stage
3
+ * check, the pinned-stage resolution, the subagent's prompt, and the runner
4
+ * config that carries the role's two enforcement fields. No network and no
5
+ * subprocess here — `cli.ts` stays a thin shell around the IO, and every rule
6
+ * below is testable without a live API.
7
+ *
8
+ * `buildStageRunnerConfig` exists as a seam for exactly one reason: the tool
9
+ * deny and the credential strip would otherwise be assembled inline in
10
+ * `cli.ts`, the one file no test executes, where deleting either line leaves
11
+ * the whole suite green while a documented security property quietly stops
12
+ * holding.
13
+ */
14
+ import {
15
+ type GateSpec,
16
+ isAgentRunnableOwner,
17
+ type PlaybookStageDef,
18
+ type PlaybookStageRole,
19
+ type PlaybookVersionDef,
20
+ resolveStageDef,
21
+ } from "@harmony/shared";
22
+ import { normalizeGateSpec } from "./gate-collectors.js";
23
+ import type { StageCardPin } from "./harmony-client.js";
24
+ import { buildRoleLaunch, envKeysDroppedByLaunch } from "./runner.js";
25
+ import type { SdkRunnerConfig } from "./sdk-agent-runner.js";
26
+
27
+ export const STAGE_RUN_USAGE =
28
+ "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
29
+
30
+ /** The five identifiers one stage run needs. Every one is required.
31
+ * `metricsPath` is the one optional input: a JSON file holding the DRIVER's
32
+ * metric allowlist for `custom` gates (same shape as the daemon operator's
33
+ * `agent.playbooks.metrics`). The motor itself stays config-free — without the
34
+ * flag a `custom` gate reports "metric not declared" and blocks, exactly as
35
+ * before. */
36
+ export interface StageRunArgs {
37
+ cardId: string;
38
+ stageId: string;
39
+ workspaceId: string;
40
+ repoPath: string;
41
+ sessionId: string;
42
+ metricsPath: string | null;
43
+ }
44
+
45
+ export type StageRunArgsResult =
46
+ | { ok: true; args: StageRunArgs }
47
+ | { ok: false; message: string };
48
+
49
+ /**
50
+ * Read one `--flag value` pair. A flag whose value is absent, blank, or another
51
+ * flag is REFUSED rather than defaulted: `--session --repo /tmp/wt` would
52
+ * otherwise send "--repo" (or an empty string) as the agent-session id, and the
53
+ * oracle read route would refuse it with a 403 that names nothing useful.
54
+ */
55
+ function readFlag(argv: string[], name: string): string | null {
56
+ const index = argv.indexOf(`--${name}`);
57
+ if (index === -1) return null;
58
+ const value = argv[index + 1];
59
+ if (value === undefined || value.startsWith("--")) return null;
60
+ // Return the TRIMMED value: a padded `--card " card-1 "` would otherwise be
61
+ // handed to `encodeURIComponent` and reach the API as "%20card-1%20", which
62
+ // matches no card and reports as a plain 404.
63
+ const trimmed = value.trim();
64
+ return trimmed.length === 0 ? null : trimmed;
65
+ }
66
+
67
+ /**
68
+ * Validate an `argv` (already stripped of `node` and the script path). Returns
69
+ * the five identifiers, or a message naming the first flag that is missing —
70
+ * the caller exits 2 with the usage line.
71
+ */
72
+ export function parseStageRunArgs(argv: string[]): StageRunArgsResult {
73
+ if (argv[0] !== "stage" || argv[1] !== "run") {
74
+ return {
75
+ ok: false,
76
+ message: `unknown command "${argv.slice(0, 2).join(" ")}" — the motor runs exactly one thing: stage run`,
77
+ };
78
+ }
79
+
80
+ const fields: Array<[Exclude<keyof StageRunArgs, "metricsPath">, string]> = [
81
+ ["cardId", "card"],
82
+ ["stageId", "stage"],
83
+ ["workspaceId", "workspace"],
84
+ ["repoPath", "repo"],
85
+ ["sessionId", "session"],
86
+ ];
87
+ const args = { metricsPath: null } as StageRunArgs;
88
+ for (const [field, flag] of fields) {
89
+ const value = readFlag(argv, flag);
90
+ if (value === null) {
91
+ return { ok: false, message: `missing --${flag} <value>` };
92
+ }
93
+ args[field] = value;
94
+ }
95
+
96
+ // Optional: --metrics <path>. Distinguish "absent" (fine — custom gates
97
+ // block with a legible reason) from "present but valueless" (refused — the
98
+ // driver clearly meant to pass an allowlist and silently running without one
99
+ // would turn every custom gate into a block the driver did not expect).
100
+ if (argv.includes("--metrics")) {
101
+ const metricsPath = readFlag(argv, "metrics");
102
+ if (metricsPath === null) {
103
+ return { ok: false, message: "missing value for --metrics <json-path>" };
104
+ }
105
+ args.metricsPath = metricsPath;
106
+ }
107
+ return { ok: true, args };
108
+ }
109
+
110
+ /**
111
+ * Parse the driver's metric-allowlist file (`--metrics`). THROWS on an
112
+ * unreadable file, invalid JSON, or a non-object top level — a driver that
113
+ * passed the flag wants the allowlist applied, and degrading to `{}` would
114
+ * silently turn every `custom` gate into a block. Per-metric validation stays
115
+ * where it lives: the command collector fails closed on a malformed
116
+ * declaration and names the key to fix.
117
+ */
118
+ export function parseMetricsAllowlist(
119
+ raw: string,
120
+ sourcePath: string,
121
+ ): Record<string, unknown> {
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(raw);
125
+ } catch (err) {
126
+ throw new Error(
127
+ `--metrics ${sourcePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
128
+ );
129
+ }
130
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
131
+ throw new Error(
132
+ `--metrics ${sourcePath} must hold a JSON object keyed by metric name`,
133
+ );
134
+ }
135
+ return parsed as Record<string, unknown>;
136
+ }
137
+
138
+ /**
139
+ * Refuse to run when the CLI's `--stage` disagrees with the card's
140
+ * `current_stage`. THROWS — the motor collects nothing in that case.
141
+ *
142
+ * Collecting evidence for a stage the card is not on writes a row that
143
+ * `gateEvaluate` later applies to a DIFFERENT stage. Silently preferring either
144
+ * side would hide a driver/board desync, which is the same acting-vs-named
145
+ * stage confusion the API had to untangle for the oracle routes.
146
+ */
147
+ export function assertCardOnStage(stageId: string, card: StageCardPin): void {
148
+ if (card.current_stage === stageId) return;
149
+ throw new Error(
150
+ `--stage "${stageId}" does not match the card's current_stage ${
151
+ card.current_stage === null ? "(none)" : `"${card.current_stage}"`
152
+ } — refusing to collect evidence for a stage the card is not on`,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Refuse to run a stage a HUMAN owns. THROWS — before any subagent is launched
158
+ * and before anything is written.
159
+ *
160
+ * `owner` is the stage model's answer to "who runs this stage?", and `human`
161
+ * means a person does. Until this check existed nothing under
162
+ * `packages/harmony-harness/src/` read `owner` at all, so a driver that handed
163
+ * the motor a human stage would get a subagent doing that person's work and a
164
+ * gate collected against it — the card would look advanced by a human who never
165
+ * saw it. `fix-a-bug` stage 3 (`Review`) is `owner: "human"`, so this is
166
+ * reachable the moment a driver exists, not a hypothetical.
167
+ *
168
+ * The spec's rule for both drivers is that reaching a human stage is a NORMAL
169
+ * outcome — write `stage_waiting` and stop — so the refusal belongs to the
170
+ * driver's decision, and the motor's job is only to make running one impossible
171
+ * by accident. Same shape as {@link assertCardOnStage}: fail loud, name what
172
+ * disagrees, collect nothing. `either` and `agent` both run
173
+ * (`isAgentRunnableOwner`), which is the same predicate the daemon's stage
174
+ * executor already uses — one rule, not a second opinion.
175
+ */
176
+ export function assertStageIsAgentRunnable(stage: PlaybookStageDef): void {
177
+ if (isAgentRunnableOwner(stage.owner)) return;
178
+ throw new Error(
179
+ `stage "${stage.id}" ("${stage.name}") is owned by "${stage.owner}" — refusing to run a stage a human owns`,
180
+ );
181
+ }
182
+
183
+ /**
184
+ * The subagent's prompt. Built from the stage's `entry_action` when the pinned
185
+ * stage names one, otherwise from the card — in which case the agent is told to
186
+ * read the card itself through the Harmony MCP tools, which every role keeps
187
+ * (see runner.ts). The motor does not read board content to build this.
188
+ *
189
+ * The signature is the guard on what can leak: there is no parameter for the
190
+ * agent-session id or for the oracle's path, and neither value is reachable
191
+ * from here. The prompt is free text and the last remaining way to hand the
192
+ * implementer either one.
193
+ */
194
+ export function buildStagePrompt(args: {
195
+ cardId: string;
196
+ stageId: string;
197
+ stage: PlaybookStageDef | null;
198
+ }): string {
199
+ const stageName = args.stage?.name ?? args.stageId;
200
+ const entryAction = args.stage?.entry_action;
201
+ return [
202
+ `## Playbook stage: ${stageName}`,
203
+ `You are running the "${stageName}" stage for Harmony card ${args.cardId}.`,
204
+ entryAction
205
+ ? `Stage skill / entry action: \`${entryAction}\`. Follow that skill's method for this stage.`
206
+ : "Read the card with the Harmony MCP tools (`harmony_get_card`) and do this stage's work for it.",
207
+ "Do only this stage's work, then stop. Do not move the card, do not advance the stage, and do not end your agent session — the driver that invoked this stage owns all three.",
208
+ ].join("\n");
209
+ }
210
+
211
+ /**
212
+ * The pinned stage this run is bound to, or a refusal. Two OUTCOMES, kept
213
+ * apart on purpose:
214
+ *
215
+ * - `ok: true` with `gate: null` — the stage exists and simply declares no
216
+ * gate. The run proceeds: the stage's `role` and `entry_action` are still
217
+ * in force, and nothing is collected or recorded afterwards.
218
+ * - `ok: false` — the PIN itself is unusable (a legacy macro snapshot, or a
219
+ * stage id that is not in the pinned version). The caller refuses to run.
220
+ *
221
+ * Collapsing those two into one `null`, which is what deriving the stage from
222
+ * `resolveStageGate` did, made an ungated stage lose both its declared role
223
+ * (silently falling back to the fail-closed `implementer` treatment) and its
224
+ * `entry_action` prompt — a degradation invisible at runtime.
225
+ */
226
+ export type PinnedStageResolution =
227
+ | { ok: true; stage: PlaybookStageDef; gate: GateSpec | null }
228
+ | { ok: false; reason: string };
229
+
230
+ /**
231
+ * Resolve `stageId` against the PINNED `playbook_versions` snapshot and
232
+ * normalize that stage's gate. Pure and total — `resolveStageDef` and
233
+ * `normalizeGateSpec` are both pure, and neither throws.
234
+ *
235
+ * A gate whose `kind` is unknown normalizes to `null` (fail-closed in
236
+ * `normalizeGateSpec`): the stage still runs, and nothing is collected. That
237
+ * is deliberately NOT a refusal — the stage def is intact, only its gate is
238
+ * unreadable.
239
+ */
240
+ export function resolvePinnedStage(
241
+ version: PlaybookVersionDef,
242
+ stageId: string,
243
+ ): PinnedStageResolution {
244
+ const resolution = resolveStageDef(version, stageId);
245
+ if (resolution.kind === "not_stage_model") {
246
+ return {
247
+ ok: false,
248
+ reason: `the pinned playbook version is a legacy macro (steps_version ${version.steps_version}) and declares no stages`,
249
+ };
250
+ }
251
+ if (resolution.kind === "stage_not_found") {
252
+ return {
253
+ ok: false,
254
+ reason: `stage "${stageId}" is not in the pinned playbook version`,
255
+ };
256
+ }
257
+ return {
258
+ ok: true,
259
+ stage: resolution.stage,
260
+ gate: normalizeGateSpec(resolution.stage.gate),
261
+ };
262
+ }
263
+
264
+ /** A stage's subagent launch, in the two shapes `SdkAgentRunner` consumes. */
265
+ export interface StageRunnerLaunch {
266
+ /** The NORMALIZED role (`buildRoleLaunch`), never the raw input. */
267
+ role: PlaybookStageRole | null;
268
+ prompt: string;
269
+ cwd: string;
270
+ /**
271
+ * The runner config carrying Task 13's two measures: the scoped `Read` deny
272
+ * over the credential directory, and the environment keys the child must not
273
+ * receive. `stripEnvKeys` is DERIVED from the launch's own omissions, so
274
+ * `HARMONY_CREDENTIAL_KEYS` stays the only credential list in the codebase.
275
+ */
276
+ config: SdkRunnerConfig;
277
+ }
278
+
279
+ /**
280
+ * Build the subagent launch for one stage: the role's env strip and tool deny,
281
+ * plus the prompt and worktree the run input needs.
282
+ *
283
+ * Both enforcement fields are asserted in `stage-cli.test.ts`, so removing
284
+ * either one fails a test rather than silently disarming the role separation.
285
+ */
286
+ export function buildStageRunnerConfig(args: {
287
+ role: PlaybookStageRole | null;
288
+ prompt: string;
289
+ repoPath: string;
290
+ parentEnv: Record<string, string | undefined>;
291
+ }): StageRunnerLaunch {
292
+ const launch = buildRoleLaunch(args);
293
+ return {
294
+ role: launch.role,
295
+ prompt: launch.prompt,
296
+ cwd: launch.repoPath,
297
+ config: {
298
+ disallowedTools: launch.disallowedTools,
299
+ stripEnvKeys: envKeysDroppedByLaunch(args.parentEnv, launch),
300
+ },
301
+ };
302
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * One stage, one invocation. This is the whole orchestration the motor performs:
3
+ * announce the stage, run the subagent for its role, collect the gate's evidence,
4
+ * report. It deliberately does NOT decide what runs next and does NOT form a
5
+ * verdict — Harmony advances the stage, and `gateEvaluate` in @harmony/shared
6
+ * turns evidence into pass or fail.
7
+ *
8
+ * The deps are injected so the orchestration is testable without a repo, a
9
+ * subagent, or a network. The real wiring lives in cli.ts.
10
+ */
11
+ import type {
12
+ GateEvidence,
13
+ GateSpec,
14
+ PlaybookStageRole,
15
+ } from "@harmony/shared";
16
+
17
+ export interface StageRunRequest {
18
+ cardId: string;
19
+ stageId: string;
20
+ workspaceId: string;
21
+ repoPath: string;
22
+ /**
23
+ * The card's ACTIVE agent session id (Task 11 review, ruling 27). The oracle
24
+ * read route binds the caller to it, so the driver that claimed the stage
25
+ * threads its own session id in through the CLI's `--session` flag. It is
26
+ * passed to the oracle deps and to the runner's event labels ONLY — it never
27
+ * enters the subagent's prompt or context.
28
+ */
29
+ sessionId: string;
30
+ /**
31
+ * The stage's role (Task 8). The real `runRole` builds its subagent launch
32
+ * from this via `buildRoleLaunch` (runner.ts). `null` fails closed — treated
33
+ * like `implementer`, not like an unrestricted default.
34
+ */
35
+ role: PlaybookStageRole | null;
36
+ }
37
+
38
+ export type MotorEvent =
39
+ | { type: "stage_entered"; stageId: string }
40
+ | {
41
+ type: "gate_evaluated";
42
+ stageId: string;
43
+ gateKind: string;
44
+ result: string;
45
+ };
46
+
47
+ export interface StageRunDeps {
48
+ /** Resolve the pinned stage's gate, or null when the stage declares none. */
49
+ resolveGate(request: StageRunRequest): Promise<GateSpec | null>;
50
+ /** Run the subagent for this stage's role. Returns when it has exited. */
51
+ runRole(request: StageRunRequest): Promise<void>;
52
+ /** Collect the gate's evidence. Called only after runRole has resolved. */
53
+ collect(request: StageRunRequest, gate: GateSpec): Promise<GateEvidence>;
54
+ }
55
+
56
+ export interface StageRunResult {
57
+ stageId: string;
58
+ gateKind: string | null;
59
+ evidence: GateEvidence | null;
60
+ events: MotorEvent[];
61
+ }
62
+
63
+ export async function runStage(
64
+ request: StageRunRequest,
65
+ deps: StageRunDeps,
66
+ ): Promise<StageRunResult> {
67
+ const events: MotorEvent[] = [
68
+ { type: "stage_entered", stageId: request.stageId },
69
+ ];
70
+
71
+ const gate = await deps.resolveGate(request);
72
+
73
+ // The role's subagent always runs to completion BEFORE any gate is collected.
74
+ // Task 12's oracle collector relies on this ordering: the held test and a model
75
+ // must never share a filesystem window.
76
+ await deps.runRole(request);
77
+
78
+ if (!gate) {
79
+ return { stageId: request.stageId, gateKind: null, evidence: null, events };
80
+ }
81
+
82
+ const evidence = await deps.collect(request, gate);
83
+ events.push({
84
+ type: "gate_evaluated",
85
+ stageId: request.stageId,
86
+ gateKind: gate.kind,
87
+ result: evidence.result,
88
+ });
89
+
90
+ return { stageId: request.stageId, gateKind: gate.kind, evidence, events };
91
+ }