@gethmy/harness 1.0.0 → 1.1.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 +6 -1
- package/dist/cli.js +57 -4
- package/package.json +6 -4
- package/src/cli.ts +8 -1
- package/src/stage-cli.ts +108 -6
package/README.md
CHANGED
|
@@ -11,10 +11,15 @@ It deliberately does **not** decide anything. Harmony routes and advances;
|
|
|
11
11
|
`gateEvaluate` in `@harmony/shared` turns evidence into a verdict. The motor
|
|
12
12
|
never routes, never judges, never pushes.
|
|
13
13
|
|
|
14
|
-
A *driver* invokes it.
|
|
14
|
+
A *driver* invokes it. The drivers are `@gethmy/agent` (unattended) and the
|
|
15
15
|
`hmy` skill (interactive). Installing this package on its own is useful only if
|
|
16
16
|
you are building a driver.
|
|
17
17
|
|
|
18
|
+
The full chain — bind, drive, gate, advance — is exercised end to end by
|
|
19
|
+
`e2e/planted-bug.e2e.ts` (`bun run e2e`). It spends real agent tokens against a
|
|
20
|
+
real Harmony API and a real Claude Code login, so it runs on demand only,
|
|
21
|
+
never in CI.
|
|
22
|
+
|
|
18
23
|
## Prerequisites
|
|
19
24
|
|
|
20
25
|
- [Node.js](https://nodejs.org) >= 20 or [Bun](https://bun.sh) >= 1.0
|
package/dist/cli.js
CHANGED
|
@@ -2752,15 +2752,50 @@ function assertStageIsAgentRunnable(stage) {
|
|
|
2752
2752
|
return;
|
|
2753
2753
|
throw new Error(`stage "${stage.id}" ("${stage.name}") is owned by "${stage.owner}" — refusing to run a stage a human owns`);
|
|
2754
2754
|
}
|
|
2755
|
+
var STAGE_SCOPE_LINE = "Do ONLY the work of this stage. Other stages of this card belong to other runs — do not start their work, and do not undo it.";
|
|
2756
|
+
function oracleWriteAddendum(input) {
|
|
2757
|
+
return [
|
|
2758
|
+
"## Oracle upload — author duty",
|
|
2759
|
+
"",
|
|
2760
|
+
"After writing the hidden acceptance test, upload it as the oracle for the gated downstream stage. POST it yourself; there is no MCP tool for this:",
|
|
2761
|
+
"",
|
|
2762
|
+
" POST $HARMONY_API_URL/v1/stage-oracle",
|
|
2763
|
+
" Headers: x-api-key: $HARMONY_API_KEY, Content-Type: application/json",
|
|
2764
|
+
"",
|
|
2765
|
+
"Body (JSON):",
|
|
2766
|
+
"```json",
|
|
2767
|
+
JSON.stringify({
|
|
2768
|
+
cardId: input.cardId,
|
|
2769
|
+
sessionId: input.sessionId,
|
|
2770
|
+
targetStageId: input.targetStageId,
|
|
2771
|
+
path: "oracle/<name>.test.ts",
|
|
2772
|
+
content: "<the full test file>",
|
|
2773
|
+
runnerHint: "bun"
|
|
2774
|
+
}, null, 2),
|
|
2775
|
+
"```",
|
|
2776
|
+
"",
|
|
2777
|
+
"Constraints: `path` is repo-relative ([A-Za-z0-9._-] per segment, no `..`, no dot-prefixed segment, no absolute path); `content` <= 100 KB; `runnerHint` is `bun` or `vitest`. Do NOT commit the oracle file — the motor places and removes it at the gated stage."
|
|
2778
|
+
].join(`
|
|
2779
|
+
`);
|
|
2780
|
+
}
|
|
2755
2781
|
function buildStagePrompt(args) {
|
|
2756
2782
|
const stageName = args.stage?.name ?? args.stageId;
|
|
2757
2783
|
const entryAction = args.stage?.entry_action;
|
|
2758
|
-
|
|
2784
|
+
const lines = [
|
|
2759
2785
|
`## Playbook stage: ${stageName}`,
|
|
2760
2786
|
`You are running the "${stageName}" stage for Harmony card ${args.cardId}.`,
|
|
2761
2787
|
entryAction ? `Stage skill / entry action: \`${entryAction}\`. Follow that skill's method for this stage.` : "Read the card with the Harmony MCP tools (`harmony_get_card`) and do this stage's work for it.",
|
|
2762
|
-
"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."
|
|
2763
|
-
|
|
2788
|
+
"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.",
|
|
2789
|
+
STAGE_SCOPE_LINE
|
|
2790
|
+
];
|
|
2791
|
+
if (args.stage?.role === "author" && args.oracleTargetStageId && args.sessionId) {
|
|
2792
|
+
lines.push("", oracleWriteAddendum({
|
|
2793
|
+
cardId: args.cardId,
|
|
2794
|
+
sessionId: args.sessionId,
|
|
2795
|
+
targetStageId: args.oracleTargetStageId
|
|
2796
|
+
}));
|
|
2797
|
+
}
|
|
2798
|
+
return lines.join(`
|
|
2764
2799
|
`);
|
|
2765
2800
|
}
|
|
2766
2801
|
function resolvePinnedStage(version, stageId) {
|
|
@@ -2783,6 +2818,18 @@ function resolvePinnedStage(version, stageId) {
|
|
|
2783
2818
|
gate: normalizeGateSpec(resolution.stage.gate)
|
|
2784
2819
|
};
|
|
2785
2820
|
}
|
|
2821
|
+
function findOracleTargetStage(version, fromStageId) {
|
|
2822
|
+
const stages = Array.isArray(version.steps) ? version.steps : [];
|
|
2823
|
+
const from = stages.findIndex((s) => s.id === fromStageId);
|
|
2824
|
+
if (from < 0)
|
|
2825
|
+
return null;
|
|
2826
|
+
for (const stage of stages.slice(from + 1)) {
|
|
2827
|
+
const gate = normalizeGateSpec(stage.gate);
|
|
2828
|
+
if (gate?.kind === "oracle_passed")
|
|
2829
|
+
return stage.id;
|
|
2830
|
+
}
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2786
2833
|
function buildStageRunnerConfig(args) {
|
|
2787
2834
|
const launch = buildRoleLaunch(args);
|
|
2788
2835
|
return {
|
|
@@ -2868,7 +2915,13 @@ ${STAGE_RUN_USAGE}
|
|
|
2868
2915
|
throw new Error(`refusing to run stage "${stageId}" for card ${cardId}: ${pinned.reason}`);
|
|
2869
2916
|
}
|
|
2870
2917
|
assertStageIsAgentRunnable(pinned.stage);
|
|
2871
|
-
const prompt = buildStagePrompt({
|
|
2918
|
+
const prompt = buildStagePrompt({
|
|
2919
|
+
cardId,
|
|
2920
|
+
stageId,
|
|
2921
|
+
stage: pinned.stage,
|
|
2922
|
+
sessionId,
|
|
2923
|
+
oracleTargetStageId: findOracleTargetStage(version, stageId)
|
|
2924
|
+
});
|
|
2872
2925
|
const request = {
|
|
2873
2926
|
cardId,
|
|
2874
2927
|
stageId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./src/index.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
13
14
|
},
|
|
14
15
|
"bin": {
|
|
15
16
|
"harmony-harness": "dist/cli.js"
|
|
@@ -49,11 +50,12 @@
|
|
|
49
50
|
"build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @supabase/supabase-js --external @gethmy/mcp --external \"@gethmy/mcp/*\" --external @anthropic-ai/claude-agent-sdk",
|
|
50
51
|
"typecheck": "tsc --noEmit",
|
|
51
52
|
"prepublishOnly": "npm run build",
|
|
52
|
-
"test": "vitest run"
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"e2e": "bun e2e/planted-bug.e2e.ts"
|
|
53
55
|
},
|
|
54
56
|
"dependencies": {
|
|
55
57
|
"@anthropic-ai/claude-agent-sdk": "^0.3.178",
|
|
56
|
-
"@gethmy/mcp": "2.
|
|
58
|
+
"@gethmy/mcp": "2.23.0",
|
|
57
59
|
"@supabase/supabase-js": "2.95.3"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
assertStageIsAgentRunnable,
|
|
39
39
|
buildStagePrompt,
|
|
40
40
|
buildStageRunnerConfig,
|
|
41
|
+
findOracleTargetStage,
|
|
41
42
|
parseMetricsAllowlist,
|
|
42
43
|
parseStageRunArgs,
|
|
43
44
|
resolvePinnedStage,
|
|
@@ -173,7 +174,13 @@ async function main(): Promise<void> {
|
|
|
173
174
|
// accident.
|
|
174
175
|
assertStageIsAgentRunnable(pinned.stage);
|
|
175
176
|
|
|
176
|
-
const prompt = buildStagePrompt({
|
|
177
|
+
const prompt = buildStagePrompt({
|
|
178
|
+
cardId,
|
|
179
|
+
stageId,
|
|
180
|
+
stage: pinned.stage,
|
|
181
|
+
sessionId,
|
|
182
|
+
oracleTargetStageId: findOracleTargetStage(version, stageId),
|
|
183
|
+
});
|
|
177
184
|
|
|
178
185
|
const request: StageRunRequest = {
|
|
179
186
|
cardId,
|
package/src/stage-cli.ts
CHANGED
|
@@ -180,32 +180,107 @@ export function assertStageIsAgentRunnable(stage: PlaybookStageDef): void {
|
|
|
180
180
|
);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Every stage's prompt carries this line: automatic driving runs one stage per
|
|
185
|
+
* invocation, and a stage's subagent otherwise has no way to know that the
|
|
186
|
+
* rest of the card's playbook is someone else's run, not unfinished work of
|
|
187
|
+
* its own to pick up or undo.
|
|
188
|
+
*/
|
|
189
|
+
const STAGE_SCOPE_LINE =
|
|
190
|
+
"Do ONLY the work of this stage. Other stages of this card belong to other runs — do not start their work, and do not undo it.";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The author's oracle-upload procedure, appended to the prompt only for a
|
|
194
|
+
* stage whose role is `author` with a resolved oracle target. There is no MCP
|
|
195
|
+
* tool for this write (the oracle route requires the acting stage's role to
|
|
196
|
+
* normalize to `author` server-side — see `playbookStage.ts`), so the author
|
|
197
|
+
* must POST it directly; this is the one place that procedure is spelled out.
|
|
198
|
+
*/
|
|
199
|
+
function oracleWriteAddendum(input: {
|
|
200
|
+
cardId: string;
|
|
201
|
+
sessionId: string;
|
|
202
|
+
targetStageId: string;
|
|
203
|
+
}): string {
|
|
204
|
+
return [
|
|
205
|
+
"## Oracle upload — author duty",
|
|
206
|
+
"",
|
|
207
|
+
"After writing the hidden acceptance test, upload it as the oracle for the gated downstream stage. POST it yourself; there is no MCP tool for this:",
|
|
208
|
+
"",
|
|
209
|
+
" POST $HARMONY_API_URL/v1/stage-oracle",
|
|
210
|
+
" Headers: x-api-key: $HARMONY_API_KEY, Content-Type: application/json",
|
|
211
|
+
"",
|
|
212
|
+
"Body (JSON):",
|
|
213
|
+
"```json",
|
|
214
|
+
JSON.stringify(
|
|
215
|
+
{
|
|
216
|
+
cardId: input.cardId,
|
|
217
|
+
sessionId: input.sessionId,
|
|
218
|
+
targetStageId: input.targetStageId,
|
|
219
|
+
path: "oracle/<name>.test.ts",
|
|
220
|
+
content: "<the full test file>",
|
|
221
|
+
runnerHint: "bun",
|
|
222
|
+
},
|
|
223
|
+
null,
|
|
224
|
+
2,
|
|
225
|
+
),
|
|
226
|
+
"```",
|
|
227
|
+
"",
|
|
228
|
+
"Constraints: `path` is repo-relative ([A-Za-z0-9._-] per segment, no `..`, no dot-prefixed segment, no absolute path); `content` <= 100 KB; `runnerHint` is `bun` or `vitest`. Do NOT commit the oracle file — the motor places and removes it at the gated stage.",
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
183
232
|
/**
|
|
184
233
|
* The subagent's prompt. Built from the stage's `entry_action` when the pinned
|
|
185
234
|
* stage names one, otherwise from the card — in which case the agent is told to
|
|
186
235
|
* read the card itself through the Harmony MCP tools, which every role keeps
|
|
187
236
|
* (see runner.ts). The motor does not read board content to build this.
|
|
188
237
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
238
|
+
* Every prompt carries {@link STAGE_SCOPE_LINE}. An `author` stage additionally
|
|
239
|
+
* gets the oracle-write procedure ({@link oracleWriteAddendum}), but only when
|
|
240
|
+
* BOTH `oracleTargetStageId` and `sessionId` are given — `sessionId` is the one
|
|
241
|
+
* value the author needs verbatim in the POST body, and omitting the addendum
|
|
242
|
+
* entirely when it is absent is deliberate: a body with a hole in it is worse
|
|
243
|
+
* than no addendum.
|
|
244
|
+
*
|
|
245
|
+
* The signature is still the guard on what can leak to an implementer: neither
|
|
246
|
+
* `sessionId` nor `oracleTargetStageId` reaches an implementer stage's prompt,
|
|
247
|
+
* because the addendum is gated on `role === "author"`.
|
|
193
248
|
*/
|
|
194
249
|
export function buildStagePrompt(args: {
|
|
195
250
|
cardId: string;
|
|
196
251
|
stageId: string;
|
|
197
252
|
stage: PlaybookStageDef | null;
|
|
253
|
+
/** Session id — the author needs it verbatim in the oracle POST body. */
|
|
254
|
+
sessionId?: string;
|
|
255
|
+
/** Id of the downstream stage gated by oracle_passed, from findOracleTargetStage. */
|
|
256
|
+
oracleTargetStageId?: string | null;
|
|
198
257
|
}): string {
|
|
199
258
|
const stageName = args.stage?.name ?? args.stageId;
|
|
200
259
|
const entryAction = args.stage?.entry_action;
|
|
201
|
-
|
|
260
|
+
const lines = [
|
|
202
261
|
`## Playbook stage: ${stageName}`,
|
|
203
262
|
`You are running the "${stageName}" stage for Harmony card ${args.cardId}.`,
|
|
204
263
|
entryAction
|
|
205
264
|
? `Stage skill / entry action: \`${entryAction}\`. Follow that skill's method for this stage.`
|
|
206
265
|
: "Read the card with the Harmony MCP tools (`harmony_get_card`) and do this stage's work for it.",
|
|
207
266
|
"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
|
-
|
|
267
|
+
STAGE_SCOPE_LINE,
|
|
268
|
+
];
|
|
269
|
+
if (
|
|
270
|
+
args.stage?.role === "author" &&
|
|
271
|
+
args.oracleTargetStageId &&
|
|
272
|
+
args.sessionId
|
|
273
|
+
) {
|
|
274
|
+
lines.push(
|
|
275
|
+
"",
|
|
276
|
+
oracleWriteAddendum({
|
|
277
|
+
cardId: args.cardId,
|
|
278
|
+
sessionId: args.sessionId,
|
|
279
|
+
targetStageId: args.oracleTargetStageId,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return lines.join("\n");
|
|
209
284
|
}
|
|
210
285
|
|
|
211
286
|
/**
|
|
@@ -261,6 +336,33 @@ export function resolvePinnedStage(
|
|
|
261
336
|
};
|
|
262
337
|
}
|
|
263
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The first stage AFTER `fromStageId` whose gate is `oracle_passed` — the
|
|
341
|
+
* stage the author's held test will grade. Null when none exists; the author
|
|
342
|
+
* addendum is then omitted and the prompt carries no oracle duty.
|
|
343
|
+
*
|
|
344
|
+
* Reads `version.steps` directly rather than through `readStageDefs` /
|
|
345
|
+
* `resolveStageDef`: those refuse a legacy (`steps_version !== 2`) snapshot
|
|
346
|
+
* outright, but this lookup only needs "is there a later stage with this
|
|
347
|
+
* gate" and degrades safely to `null` on any shape it does not recognize —
|
|
348
|
+
* `fromStageId` simply will not be found, and the loop below finds nothing.
|
|
349
|
+
*/
|
|
350
|
+
export function findOracleTargetStage(
|
|
351
|
+
version: PlaybookVersionDef,
|
|
352
|
+
fromStageId: string,
|
|
353
|
+
): string | null {
|
|
354
|
+
const stages = Array.isArray(version.steps)
|
|
355
|
+
? (version.steps as PlaybookStageDef[])
|
|
356
|
+
: [];
|
|
357
|
+
const from = stages.findIndex((s) => s.id === fromStageId);
|
|
358
|
+
if (from < 0) return null;
|
|
359
|
+
for (const stage of stages.slice(from + 1)) {
|
|
360
|
+
const gate = normalizeGateSpec(stage.gate);
|
|
361
|
+
if (gate?.kind === "oracle_passed") return stage.id;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
264
366
|
/** A stage's subagent launch, in the two shapes `SdkAgentRunner` consumes. */
|
|
265
367
|
export interface StageRunnerLaunch {
|
|
266
368
|
/** The NORMALIZED role (`buildRoleLaunch`), never the raw input. */
|