@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 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
@@ -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
- return [
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
- ].join(`
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({ cardId, stageId, stage: pinned.stage });
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.0.0",
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.22.0",
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({ 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. */