@gethmy/harness 1.0.0 → 1.1.1
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 +63 -4
- package/dist/index.js +6 -0
- 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
|
@@ -388,6 +388,11 @@ function toStageGateEvidenceInsert(context, evidence) {
|
|
|
388
388
|
|
|
389
389
|
// ../harmony-shared/dist/logger.js
|
|
390
390
|
var init_logger = () => {};
|
|
391
|
+
// ../harmony-shared/dist/playbookAutoBind.js
|
|
392
|
+
var init_playbookAutoBind = __esm(() => {
|
|
393
|
+
init_gateEvaluate();
|
|
394
|
+
});
|
|
395
|
+
|
|
391
396
|
// ../harmony-shared/dist/playbookCatalog.js
|
|
392
397
|
var init_playbookCatalog = () => {};
|
|
393
398
|
|
|
@@ -465,6 +470,7 @@ var init_dist = __esm(() => {
|
|
|
465
470
|
init_constants();
|
|
466
471
|
init_gateEvaluate();
|
|
467
472
|
init_logger();
|
|
473
|
+
init_playbookAutoBind();
|
|
468
474
|
init_playbookCatalog();
|
|
469
475
|
init_playbookStage();
|
|
470
476
|
init_projectTemplates();
|
|
@@ -2752,15 +2758,50 @@ function assertStageIsAgentRunnable(stage) {
|
|
|
2752
2758
|
return;
|
|
2753
2759
|
throw new Error(`stage "${stage.id}" ("${stage.name}") is owned by "${stage.owner}" — refusing to run a stage a human owns`);
|
|
2754
2760
|
}
|
|
2761
|
+
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.";
|
|
2762
|
+
function oracleWriteAddendum(input) {
|
|
2763
|
+
return [
|
|
2764
|
+
"## Oracle upload — author duty",
|
|
2765
|
+
"",
|
|
2766
|
+
"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:",
|
|
2767
|
+
"",
|
|
2768
|
+
" POST $HARMONY_API_URL/v1/stage-oracle",
|
|
2769
|
+
" Headers: x-api-key: $HARMONY_API_KEY, Content-Type: application/json",
|
|
2770
|
+
"",
|
|
2771
|
+
"Body (JSON):",
|
|
2772
|
+
"```json",
|
|
2773
|
+
JSON.stringify({
|
|
2774
|
+
cardId: input.cardId,
|
|
2775
|
+
sessionId: input.sessionId,
|
|
2776
|
+
targetStageId: input.targetStageId,
|
|
2777
|
+
path: "oracle/<name>.test.ts",
|
|
2778
|
+
content: "<the full test file>",
|
|
2779
|
+
runnerHint: "bun"
|
|
2780
|
+
}, null, 2),
|
|
2781
|
+
"```",
|
|
2782
|
+
"",
|
|
2783
|
+
"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."
|
|
2784
|
+
].join(`
|
|
2785
|
+
`);
|
|
2786
|
+
}
|
|
2755
2787
|
function buildStagePrompt(args) {
|
|
2756
2788
|
const stageName = args.stage?.name ?? args.stageId;
|
|
2757
2789
|
const entryAction = args.stage?.entry_action;
|
|
2758
|
-
|
|
2790
|
+
const lines = [
|
|
2759
2791
|
`## Playbook stage: ${stageName}`,
|
|
2760
2792
|
`You are running the "${stageName}" stage for Harmony card ${args.cardId}.`,
|
|
2761
2793
|
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
|
-
|
|
2794
|
+
"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.",
|
|
2795
|
+
STAGE_SCOPE_LINE
|
|
2796
|
+
];
|
|
2797
|
+
if (args.stage?.role === "author" && args.oracleTargetStageId && args.sessionId) {
|
|
2798
|
+
lines.push("", oracleWriteAddendum({
|
|
2799
|
+
cardId: args.cardId,
|
|
2800
|
+
sessionId: args.sessionId,
|
|
2801
|
+
targetStageId: args.oracleTargetStageId
|
|
2802
|
+
}));
|
|
2803
|
+
}
|
|
2804
|
+
return lines.join(`
|
|
2764
2805
|
`);
|
|
2765
2806
|
}
|
|
2766
2807
|
function resolvePinnedStage(version, stageId) {
|
|
@@ -2783,6 +2824,18 @@ function resolvePinnedStage(version, stageId) {
|
|
|
2783
2824
|
gate: normalizeGateSpec(resolution.stage.gate)
|
|
2784
2825
|
};
|
|
2785
2826
|
}
|
|
2827
|
+
function findOracleTargetStage(version, fromStageId) {
|
|
2828
|
+
const stages = Array.isArray(version.steps) ? version.steps : [];
|
|
2829
|
+
const from = stages.findIndex((s) => s.id === fromStageId);
|
|
2830
|
+
if (from < 0)
|
|
2831
|
+
return null;
|
|
2832
|
+
for (const stage of stages.slice(from + 1)) {
|
|
2833
|
+
const gate = normalizeGateSpec(stage.gate);
|
|
2834
|
+
if (gate?.kind === "oracle_passed")
|
|
2835
|
+
return stage.id;
|
|
2836
|
+
}
|
|
2837
|
+
return null;
|
|
2838
|
+
}
|
|
2786
2839
|
function buildStageRunnerConfig(args) {
|
|
2787
2840
|
const launch = buildRoleLaunch(args);
|
|
2788
2841
|
return {
|
|
@@ -2868,7 +2921,13 @@ ${STAGE_RUN_USAGE}
|
|
|
2868
2921
|
throw new Error(`refusing to run stage "${stageId}" for card ${cardId}: ${pinned.reason}`);
|
|
2869
2922
|
}
|
|
2870
2923
|
assertStageIsAgentRunnable(pinned.stage);
|
|
2871
|
-
const prompt = buildStagePrompt({
|
|
2924
|
+
const prompt = buildStagePrompt({
|
|
2925
|
+
cardId,
|
|
2926
|
+
stageId,
|
|
2927
|
+
stage: pinned.stage,
|
|
2928
|
+
sessionId,
|
|
2929
|
+
oracleTargetStageId: findOracleTargetStage(version, stageId)
|
|
2930
|
+
});
|
|
2872
2931
|
const request = {
|
|
2873
2932
|
cardId,
|
|
2874
2933
|
stageId,
|
package/dist/index.js
CHANGED
|
@@ -471,6 +471,11 @@ function toStageGateEvidenceInsert(context, evidence) {
|
|
|
471
471
|
|
|
472
472
|
// ../harmony-shared/dist/logger.js
|
|
473
473
|
var init_logger = () => {};
|
|
474
|
+
// ../harmony-shared/dist/playbookAutoBind.js
|
|
475
|
+
var init_playbookAutoBind = __esm(() => {
|
|
476
|
+
init_gateEvaluate();
|
|
477
|
+
});
|
|
478
|
+
|
|
474
479
|
// ../harmony-shared/dist/playbookCatalog.js
|
|
475
480
|
var init_playbookCatalog = () => {};
|
|
476
481
|
|
|
@@ -548,6 +553,7 @@ var init_dist = __esm(() => {
|
|
|
548
553
|
init_constants();
|
|
549
554
|
init_gateEvaluate();
|
|
550
555
|
init_logger();
|
|
556
|
+
init_playbookAutoBind();
|
|
551
557
|
init_playbookCatalog();
|
|
552
558
|
init_playbookStage();
|
|
553
559
|
init_projectTemplates();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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.24.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. */
|