@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 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. Today the drivers are `@gethmy/agent` (unattended) and the
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
- return [
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
- ].join(`
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({ cardId, stageId, stage: pinned.stage });
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.0.0",
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.22.0",
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({ cardId, stageId, stage: pinned.stage });
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
- * 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.
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
- return [
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
- ].join("\n");
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. */