harnery 0.8.0 → 0.9.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.
Files changed (90) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/doctor.d.ts.map +1 -1
  5. package/dist/commands/doctor.js +38 -0
  6. package/dist/commands/grep.d.ts +35 -2
  7. package/dist/commands/grep.d.ts.map +1 -1
  8. package/dist/commands/grep.js +427 -139
  9. package/dist/commands/init.d.ts +15 -5
  10. package/dist/commands/init.d.ts.map +1 -1
  11. package/dist/commands/init.js +125 -14
  12. package/dist/commands/workflow.d.ts +4 -0
  13. package/dist/commands/workflow.d.ts.map +1 -0
  14. package/dist/commands/workflow.js +89 -0
  15. package/dist/core/agents/cli.js +10 -3
  16. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  17. package/dist/core/agents/rules/claim-conflict.js +26 -1
  18. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  19. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  20. package/dist/core/agents/rules/stop-hook.js +8 -0
  21. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  22. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  23. package/dist/core/agents/state/heartbeat-projector.js +13 -2
  24. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  25. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  26. package/dist/core/agents/state/heartbeat-writer.js +21 -4
  27. package/dist/core/config.d.ts +9 -0
  28. package/dist/core/config.d.ts.map +1 -1
  29. package/dist/core/config.js +19 -0
  30. package/dist/core/hooks/cli.js +29 -3
  31. package/dist/core/hooks/events/schema.d.ts +4 -0
  32. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  33. package/dist/core/hooks/harness/events.d.ts +7 -0
  34. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  35. package/dist/core/hooks/harness/events.js +1 -0
  36. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  37. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  38. package/dist/core/hooks/resolve/coord-root.js +28 -6
  39. package/dist/core/workflow/billing.d.ts +48 -0
  40. package/dist/core/workflow/billing.d.ts.map +1 -0
  41. package/dist/core/workflow/billing.js +102 -0
  42. package/dist/core/workflow/child-env.d.ts +30 -0
  43. package/dist/core/workflow/child-env.d.ts.map +1 -0
  44. package/dist/core/workflow/child-env.js +43 -0
  45. package/dist/core/workflow/engine.d.ts +21 -0
  46. package/dist/core/workflow/engine.d.ts.map +1 -0
  47. package/dist/core/workflow/engine.js +340 -0
  48. package/dist/core/workflow/harnesses.d.ts +17 -0
  49. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  50. package/dist/core/workflow/harnesses.js +30 -0
  51. package/dist/core/workflow/spawn-claude.d.ts +22 -0
  52. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  53. package/dist/core/workflow/spawn-claude.js +82 -0
  54. package/dist/core/workflow/spawn-codex.d.ts +19 -0
  55. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  56. package/dist/core/workflow/spawn-codex.js +66 -0
  57. package/dist/core/workflow/spawn-cursor.d.ts +26 -0
  58. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  59. package/dist/core/workflow/spawn-cursor.js +72 -0
  60. package/dist/core/workflow/types.d.ts +145 -0
  61. package/dist/core/workflow/types.d.ts.map +1 -0
  62. package/dist/core/workflow/types.js +9 -0
  63. package/dist/core/workflow/validate.d.ts +15 -0
  64. package/dist/core/workflow/validate.d.ts.map +1 -0
  65. package/dist/core/workflow/validate.js +70 -0
  66. package/package.json +1 -1
  67. package/src/commander.ts +2 -0
  68. package/src/commands/doctor.ts +45 -0
  69. package/src/commands/grep.ts +535 -142
  70. package/src/commands/init.ts +138 -17
  71. package/src/commands/workflow.ts +132 -0
  72. package/src/core/agents/cli.ts +11 -4
  73. package/src/core/agents/rules/claim-conflict.ts +26 -1
  74. package/src/core/agents/rules/stop-hook.ts +17 -0
  75. package/src/core/agents/state/heartbeat-projector.ts +13 -1
  76. package/src/core/agents/state/heartbeat-writer.ts +30 -5
  77. package/src/core/config.ts +23 -0
  78. package/src/core/hooks/cli.ts +30 -3
  79. package/src/core/hooks/events/schema.ts +4 -0
  80. package/src/core/hooks/harness/events.ts +8 -0
  81. package/src/core/hooks/resolve/coord-root.ts +28 -6
  82. package/src/core/workflow/billing.ts +146 -0
  83. package/src/core/workflow/child-env.ts +47 -0
  84. package/src/core/workflow/engine.ts +394 -0
  85. package/src/core/workflow/harnesses.ts +38 -0
  86. package/src/core/workflow/spawn-claude.ts +99 -0
  87. package/src/core/workflow/spawn-codex.ts +74 -0
  88. package/src/core/workflow/spawn-cursor.ts +89 -0
  89. package/src/core/workflow/types.ts +153 -0
  90. package/src/core/workflow/validate.ts +75 -0
@@ -23,11 +23,12 @@ export type { HookGroup, SettingsFile };
23
23
  export declare function registerInitCommand(program: Command, emit: EmitContext, binName?: string): void;
24
24
  /**
25
25
  * Merge agent-hook entries into a harness settings object in place, idempotently.
26
- * Preserves every existing hook; skips events already wired to
27
- * `agent-hook <subcommand>`. Honors the harness's entry shape (Claude/Codex nest
28
- * under an inner `hooks` array; Cursor uses a flat `{ command }`) and ensures the
29
- * root `version` key when the harness requires one. Pure (no fs/git) so it's
30
- * unit-testable.
26
+ * Preserves every existing hook; rewrites events already wired to
27
+ * `agent-hook <subcommand>` when their command string is stale (e.g. an older
28
+ * harnery wired a bare relative path). Honors the harness's entry shape
29
+ * (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
30
+ * `{ command }`) and ensures the root `version` key when the harness requires
31
+ * one. Pure (no fs/git) so it's unit-testable.
31
32
  *
32
33
  * The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
33
34
  * it keeps `stop` from matching `stop-failure`.
@@ -36,6 +37,7 @@ export declare function wireHooks(settings: SettingsFile, spec: HarnessSpec, age
36
37
  wired: number;
37
38
  already: number;
38
39
  removed: number;
40
+ upgraded: number;
39
41
  };
40
42
  /**
41
43
  * Inverse of {@link wireHooks}: strip every harnery-owned hook entry from a
@@ -61,4 +63,12 @@ export declare function unwireHooks(settings: SettingsFile): {
61
63
  * - key absent → splice it in as the first key (comment-safe).
62
64
  */
63
65
  export declare function stampBinName(configPath: string, binName: string, dryRun: boolean): string | null;
66
+ /**
67
+ * Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
68
+ * (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
69
+ * billing safeguards). Same comment-preserving discipline as `stampBinName`.
70
+ * A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
71
+ * deliberate project choice and is left alone.
72
+ */
73
+ export declare function stampWorkflowDefaults(configPath: string, dryRun: boolean): string | null;
64
74
  //# sourceMappingURL=init.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAClG,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,YAAY,EAClB,MAAM,iCAAiC,CAAC;AAKzC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAYxC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAsH/F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,SAAS,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAiCrD;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAmB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmDhG"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAClG,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,YAAY,EAClB,MAAM,iCAAiC,CAAC;AAKzC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAYxC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAiI/F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,SAAS,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAqCvE;AAyDD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAmB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmDhG;AASD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAkCxF"}
@@ -92,6 +92,17 @@ export function registerInitCommand(program, emit, binName) {
92
92
  if (stamp)
93
93
  actions.push(stamp);
94
94
  }
95
+ // ── 1c. workflow billing default ───────────────────────────────────────
96
+ // Every init'd project gets `workflow.subscriptionOnly: true`: workflow
97
+ // children ride the logged-in (subscription) harness auth, and the pin
98
+ // makes per-token API billing structurally impossible unless the project
99
+ // deliberately flips it. A committed `workflow` key of any shape is a
100
+ // deliberate choice and is never touched.
101
+ {
102
+ const stamp = stampWorkflowDefaults(resolve(coordDir, "config.jsonc"), dryRun);
103
+ if (stamp)
104
+ actions.push(stamp);
105
+ }
95
106
  // ── 2. harness hooks ───────────────────────────────────────────────────
96
107
  const settingsPath = resolve(projectRoot, spec.settingsFile);
97
108
  const agentHook = relative(projectRoot, resolve(HARNERY_ROOT, "bin", "agent-hook"));
@@ -110,19 +121,19 @@ export function registerInitCommand(program, emit, binName) {
110
121
  else {
111
122
  settings = {};
112
123
  }
113
- const { wired, already, removed } = wireHooks(settings, spec, agentHook, harness);
114
- if (wired === 0 && removed === 0) {
124
+ const { wired, already, removed, upgraded } = wireHooks(settings, spec, agentHook, harness);
125
+ if (wired === 0 && removed === 0 && upgraded === 0) {
115
126
  actions.push(`· all ${spec.events.length} ${harness} hooks already wired in ${rel(projectRoot, settingsPath)}`);
116
127
  }
117
128
  else if (dryRun) {
118
- actions.push(`+ would wire ${wired} hook(s) and remove ${removed} legacy hook(s) in ` +
119
- `${rel(projectRoot, settingsPath)} (${already} already present)`);
129
+ actions.push(`+ would wire ${wired} hook(s), upgrade ${upgraded} stale command(s), and remove ` +
130
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`);
120
131
  }
121
132
  else {
122
133
  mkdirSync(dirname(settingsPath), { recursive: true });
123
134
  writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
124
- actions.push(`+ wired ${wired} hook(s) and removed ${removed} legacy hook(s) in ` +
125
- `${rel(projectRoot, settingsPath)} (${already} already present)`);
135
+ actions.push(`+ wired ${wired} hook(s), upgraded ${upgraded} stale command(s), and removed ` +
136
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`);
126
137
  }
127
138
  // ── 3. agent-facing instructions block + skills ────────────────────────
128
139
  const applied = applyInstructions(projectRoot, { binName: bin, harness, dryRun });
@@ -132,11 +143,12 @@ export function registerInitCommand(program, emit, binName) {
132
143
  }
133
144
  /**
134
145
  * Merge agent-hook entries into a harness settings object in place, idempotently.
135
- * Preserves every existing hook; skips events already wired to
136
- * `agent-hook <subcommand>`. Honors the harness's entry shape (Claude/Codex nest
137
- * under an inner `hooks` array; Cursor uses a flat `{ command }`) and ensures the
138
- * root `version` key when the harness requires one. Pure (no fs/git) so it's
139
- * unit-testable.
146
+ * Preserves every existing hook; rewrites events already wired to
147
+ * `agent-hook <subcommand>` when their command string is stale (e.g. an older
148
+ * harnery wired a bare relative path). Honors the harness's entry shape
149
+ * (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
150
+ * `{ command }`) and ensures the root `version` key when the harness requires
151
+ * one. Pure (no fs/git) so it's unit-testable.
140
152
  *
141
153
  * The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
142
154
  * it keeps `stop` from matching `stop-failure`.
@@ -150,6 +162,7 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
150
162
  let wired = 0;
151
163
  let already = 0;
152
164
  let removed = 0;
165
+ let upgraded = 0;
153
166
  for (const { settingsKey, subcommand } of spec.legacyEvents ?? []) {
154
167
  const groups = settings.hooks[settingsKey] ?? [];
155
168
  const kept = groups.filter((group) => !groupCommands(group).some((command) => commandWiresSubcommand(command, subcommand)));
@@ -160,9 +173,14 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
160
173
  settings.hooks[settingsKey] = kept;
161
174
  }
162
175
  for (const { settingsKey, subcommand } of spec.events) {
163
- const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
176
+ const command = hookCommand(spec, agentHookPath, subcommand, harness);
164
177
  const groups = settings.hooks[settingsKey] ?? [];
165
- const present = groups.some((g) => groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)));
178
+ let present = false;
179
+ for (const group of groups) {
180
+ upgraded += rewriteStaleCommands(group, subcommand, command, () => {
181
+ present = true;
182
+ });
183
+ }
166
184
  if (present) {
167
185
  already++;
168
186
  continue;
@@ -171,7 +189,52 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
171
189
  settings.hooks[settingsKey] = groups;
172
190
  wired++;
173
191
  }
174
- return { wired, already, removed };
192
+ return { wired, already, removed, upgraded };
193
+ }
194
+ /**
195
+ * The canonical hook command for one event. When the harness exports a
196
+ * project-dir env var to hook processes (Claude Code's CLAUDE_PROJECT_DIR),
197
+ * anchor the agent-hook path on it: hook processes inherit the session
198
+ * shell's cwd, which follows `cd` away from the project root — a bare
199
+ * relative path silently fails to spawn from there (no events, no image
200
+ * capture, no guards). `:-.` keeps the command working on harness versions
201
+ * that don't set the var. Only the env expansion is quoted so the
202
+ * `agent-hook <subcommand> ` wiring match stays byte-identical.
203
+ */
204
+ function hookCommand(spec, agentHookPath, subcommand, harness) {
205
+ const anchor = spec.projectDirEnv ? `"\${${spec.projectDirEnv}:-.}"/` : "";
206
+ return `bash ${anchor}${agentHookPath} ${subcommand} --harness ${harness}`;
207
+ }
208
+ /**
209
+ * Rewrite any harnery-owned command for `subcommand` inside one hook group to
210
+ * the canonical form, in place. Returns the number of commands rewritten and
211
+ * calls `onPresent` when the subcommand is wired in this group at all.
212
+ */
213
+ function rewriteStaleCommands(group, subcommand, canonical, onPresent) {
214
+ let rewritten = 0;
215
+ if ("command" in group && typeof group.command === "string") {
216
+ if (commandWiresSubcommand(group.command, subcommand)) {
217
+ onPresent();
218
+ if (group.command !== canonical) {
219
+ group.command = canonical;
220
+ rewritten++;
221
+ }
222
+ }
223
+ }
224
+ if ("hooks" in group && Array.isArray(group.hooks)) {
225
+ for (const hook of group.hooks) {
226
+ if (typeof hook.command !== "string")
227
+ continue;
228
+ if (!commandWiresSubcommand(hook.command, subcommand))
229
+ continue;
230
+ onPresent();
231
+ if (hook.command !== canonical) {
232
+ hook.command = canonical;
233
+ rewritten++;
234
+ }
235
+ }
236
+ }
237
+ return rewritten;
175
238
  }
176
239
  /**
177
240
  * Inverse of {@link wireHooks}: strip every harnery-owned hook entry from a
@@ -270,6 +333,54 @@ export function stampBinName(configPath, binName, dryRun) {
270
333
  writeFileSync(configPath, next);
271
334
  return `+ added binName "${binName}" to ${rel(configPath)}`;
272
335
  }
336
+ /** The commented workflow-billing default block init stamps into config.jsonc. */
337
+ const WORKFLOW_DEFAULT_BLOCK = ` // Workflow children ride the logged-in (subscription) harness auth; this pin\n` +
338
+ ` // scrubs API-key vars from child envs so runs can never bill per-token rates.\n` +
339
+ ` // Key-only hosts (CI): set false, or HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=0.\n` +
340
+ ` "workflow": { "subscriptionOnly": true }`;
341
+ /**
342
+ * Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
343
+ * (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
344
+ * billing safeguards). Same comment-preserving discipline as `stampBinName`.
345
+ * A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
346
+ * deliberate project choice and is left alone.
347
+ */
348
+ export function stampWorkflowDefaults(configPath, dryRun) {
349
+ const rel = (p) => relative(dirname(dirname(configPath)), p) || p;
350
+ if (!existsSync(configPath)) {
351
+ if (dryRun)
352
+ return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
353
+ writeFileSync(configPath, `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`);
354
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
355
+ }
356
+ let raw;
357
+ try {
358
+ raw = readFileSync(configPath, "utf8");
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ let parsed;
364
+ try {
365
+ parsed = JSON.parse(stripJsonComments(raw)) ?? {};
366
+ }
367
+ catch {
368
+ return `· ${rel(configPath)} isn't valid JSONC; skipped workflow.subscriptionOnly pin`;
369
+ }
370
+ if ("workflow" in parsed)
371
+ return null; // deliberate config; never touch
372
+ const at = firstBraceIndex(raw);
373
+ if (at < 0)
374
+ return `· ${rel(configPath)} has no object literal; skipped workflow pin`;
375
+ const keys = Object.keys(parsed);
376
+ const next = keys.length === 0
377
+ ? `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`
378
+ : `${raw.slice(0, at + 1)}\n${WORKFLOW_DEFAULT_BLOCK},${raw.slice(at + 1)}`;
379
+ if (dryRun)
380
+ return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
381
+ writeFileSync(configPath, next);
382
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
383
+ }
273
384
  /** Index of the first structural `{`, skipping leading whitespace + comments. */
274
385
  function firstBraceIndex(raw) {
275
386
  let i = 0;
@@ -0,0 +1,4 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.js";
3
+ export declare function registerWorkflowCommand(program: Command, emit: EmitContext): void;
4
+ //# sourceMappingURL=workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow.d.ts","sourceRoot":"","sources":["../../src/commands/workflow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA2BnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAuGjF"}
@@ -0,0 +1,89 @@
1
+ import { workflowSubscriptionOnly } from "../core/config.js";
2
+ import { findCoordRoot } from "../core/hooks/resolve/coord-root.js";
3
+ const HARNESSES = ["claude-code", "codex", "cursor"];
4
+ export function registerWorkflowCommand(program, emit) {
5
+ const workflow = program
6
+ .command("workflow")
7
+ .description("Run bounded, schema-gated multi-subagent workflow scripts.");
8
+ workflow
9
+ .command("run <script>")
10
+ .description("Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
11
+ "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.")
12
+ .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
13
+ .option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
14
+ .option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
15
+ .option("--harness <name>", `Default harness for agent() calls: ${HARNESSES.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`)
16
+ .option("--resume-from <run-id>", "Reuse completed agent results from a prior run's journal; only changed/failed calls re-run")
17
+ .option("--subscription-only", "Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
18
+ "harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)")
19
+ .option("--allow-api-billing", "Permit an exported API key to override a stored subscription login (per-token billing); " +
20
+ "without this the engine refuses that silent-override state")
21
+ .option("--json", "Emit the full RunReport as JSON")
22
+ .action(async (script, opts) => {
23
+ const coordRoot = findCoordRoot();
24
+ if (!coordRoot) {
25
+ emit.error({
26
+ code: "no_coord_root",
27
+ message: "no .harnery/ coordination root found; run `init` first",
28
+ });
29
+ process.exit(1);
30
+ }
31
+ if (opts.harness && !HARNESSES.includes(opts.harness)) {
32
+ emit.error({
33
+ code: "bad_harness",
34
+ message: `unknown harness "${opts.harness}" (expected: ${HARNESSES.join(" | ")})`,
35
+ });
36
+ process.exit(1);
37
+ }
38
+ // Flag beats config; the two flags contradict each other by design.
39
+ const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
40
+ if (subscriptionOnly && opts.allowApiBilling) {
41
+ emit.error({
42
+ code: "billing_flags_conflict",
43
+ message: "--allow-api-billing contradicts subscription-only mode " +
44
+ "(flag or config.jsonc workflow.subscriptionOnly); pick one",
45
+ });
46
+ process.exit(1);
47
+ }
48
+ try {
49
+ const [{ runWorkflow }, { claudeCodeSpawner }, { codexSpawner }, { cursorSpawner }] = await Promise.all([
50
+ import("../core/workflow/engine.js"),
51
+ import("../core/workflow/spawn-claude.js"),
52
+ import("../core/workflow/spawn-codex.js"),
53
+ import("../core/workflow/spawn-cursor.js"),
54
+ ]);
55
+ const report = await runWorkflow(script, {
56
+ coordRoot,
57
+ spawners: {
58
+ "claude-code": claudeCodeSpawner,
59
+ codex: codexSpawner,
60
+ cursor: cursorSpawner,
61
+ },
62
+ defaultHarness: opts.harness,
63
+ resumeFrom: opts.resumeFrom,
64
+ subscriptionOnly,
65
+ allowApiBilling: opts.allowApiBilling,
66
+ maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
67
+ concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
68
+ cwd: opts.cwd,
69
+ });
70
+ if (opts.json) {
71
+ emit.config({ format: "json" });
72
+ emit.data(report);
73
+ return;
74
+ }
75
+ const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
76
+ const billingPart = report.billing.length
77
+ ? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
78
+ : "";
79
+ emit.text(`run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
80
+ `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
81
+ `journal: ${report.journalPath}\n` +
82
+ `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`);
83
+ }
84
+ catch (err) {
85
+ emit.error({ code: "workflow_failed", message: err.message });
86
+ process.exit(1);
87
+ }
88
+ });
89
+ }
@@ -427,11 +427,16 @@ async function handlePostCommit(root) {
427
427
  }
428
428
  const { groupUnclaim } = await import("./state/heartbeat-writer.js");
429
429
  // Session-group-wide unclaim. `owner` is the parent's session_id which is
430
- // also the group key (parent + subagents share session_id).
430
+ // also the group key (parent + subagents share session_id). Each actual
431
+ // removal also emits a durable claim.release event — the projector rebuilds
432
+ // files_touched from the permanent Edit/Write events, so a file-only prune
433
+ // would resurrect on the next replay.
431
434
  if (req.owner && Array.isArray(req.prune)) {
432
435
  for (const path of req.prune) {
433
436
  try {
434
- groupUnclaim(root, req.owner, path);
437
+ for (const hit of groupUnclaim(root, req.owner, path)) {
438
+ await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
439
+ }
435
440
  }
436
441
  catch {
437
442
  /* best-effort */
@@ -453,7 +458,9 @@ async function handlePostCheckout(root, _rest) {
453
458
  if (req.owner && Array.isArray(req.removed)) {
454
459
  for (const path of req.removed) {
455
460
  try {
456
- groupUnclaim(root, req.owner, path);
461
+ for (const hit of groupUnclaim(root, req.owner, path)) {
462
+ await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
463
+ }
457
464
  }
458
465
  catch {
459
466
  /* best-effort */
@@ -1 +1 @@
1
- {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
1
+ {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AASH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
@@ -16,6 +16,7 @@
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
+ import { emit } from "../events/emit.js";
19
20
  const FRESHNESS_SECS = 600;
20
21
  /**
21
22
  * Evaluate a file-claim. Returns deny when another fresh peer claims the
@@ -267,13 +268,37 @@ function pruneClaimFromPeer(coordRoot, instanceId, relPath) {
267
268
  try {
268
269
  const body = JSON.parse(readFileSync(path, "utf8"));
269
270
  const files = body.files_touched ?? [];
270
- const next = files.filter((p) => p !== relPath);
271
+ // Normalize both sides: files_touched can hold absolute-under-coordRoot
272
+ // entries (legacy projections) as well as canonical relative ones — an
273
+ // exact-string filter silently no-ops on the mixed-form case and the
274
+ // stale claim never heals.
275
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
276
+ const target = norm(relPath);
277
+ const next = files.filter((p) => norm(p) !== target);
271
278
  if (next.length === files.length)
272
279
  return;
273
280
  body.files_touched = next;
274
281
  const tmp = `${path}.tmp.${process.pid}`;
275
282
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
276
283
  renameSync(tmp, path);
284
+ // Durable subtraction: the projector rebuilds files_touched from the
285
+ // permanent Edit/Write events, so a file-only prune resurrects on the
286
+ // next replay. Soft-fail — the heartbeat write already landed.
287
+ try {
288
+ const platform = body.platform;
289
+ emit(coordRoot, {
290
+ event_type: "claim.release",
291
+ instance_id: instanceId,
292
+ session_id: body.session_id ?? instanceId,
293
+ harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
294
+ source: "agent-coord",
295
+ data: { path: target, reason: "heal" },
296
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
297
+ });
298
+ }
299
+ catch {
300
+ /* soft-fail */
301
+ }
277
302
  }
278
303
  catch {
279
304
  /* silent */
@@ -34,6 +34,14 @@ export interface StopHookRequest {
34
34
  };
35
35
  /** Bypass switch: operator escape hatch identical to HARNERY_AGENT_COORD_BYPASS_STOP. */
36
36
  bypass?: boolean;
37
+ /** Headless child spawned by `harn workflow` (HARNERY_WORKFLOW_CHILD=1).
38
+ * The end-of-turn ritual exists to surface status to a HUMAN reader; a
39
+ * workflow child reports to the engine's journal instead, so the ritual is
40
+ * meaningless there — worse, blocking burns the child's turn budget on
41
+ * re-prompts (observed as error_max_turns in the Phase 1 spike). Exempting
42
+ * here, rather than disabling the child's hooks wholesale, keeps heartbeat +
43
+ * event capture on: the child stays visible to peers and the coord layer. */
44
+ workflow_child?: boolean;
37
45
  }
38
46
  /**
39
47
  * Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
@@ -1 +1 @@
1
- {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAcF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CA2FvF"}
1
+ {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAcF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;iFAM6E;IAC7E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CAoGvF"}
@@ -33,6 +33,14 @@ export function evaluateStopHook(coordRoot, req) {
33
33
  reason: "HARNERY_AGENT_COORD_BYPASS_STOP=1",
34
34
  };
35
35
  }
36
+ if (req.workflow_child) {
37
+ return {
38
+ allow: true,
39
+ exit_code: 0,
40
+ rule: "stop-hook.workflow_child",
41
+ reason: "HARNERY_WORKFLOW_CHILD=1: headless workflow child; ritual not applicable",
42
+ };
43
+ }
36
44
  let events;
37
45
  try {
38
46
  events = readRecentEvents(coordRoot, RECENT_EVENT_WINDOW_LINES);
@@ -20,6 +20,8 @@ export interface V2Heartbeat {
20
20
  platform?: string;
21
21
  subagent_call_id?: string;
22
22
  parent_session_id?: string;
23
+ /** Set iff this owner is a `workflow run` child (joins to the run journal). */
24
+ workflow_run_id?: string;
23
25
  started_at?: string;
24
26
  last_heartbeat: string;
25
27
  last_tool?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AAsWD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
1
+ {"version":3,"file":"heartbeat-projector.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,OAAO,EAAE;QACP,cAAc,EAAE,CAAC,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAE,CAoD9D;AAgXD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3E"}
@@ -138,6 +138,9 @@ function apply(hb, ev, coordRoot) {
138
138
  const parentSession = pickStr(d, "parent_session_id");
139
139
  if (parentSession)
140
140
  hb.parent_session_id = parentSession;
141
+ const workflowRunId = pickStr(d, "workflow_run_id");
142
+ if (workflowRunId)
143
+ hb.workflow_run_id = workflowRunId;
141
144
  if (!hb.files_touched)
142
145
  hb.files_touched = [];
143
146
  }
@@ -202,10 +205,17 @@ function apply(hb, ev, coordRoot) {
202
205
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
203
206
  const target = extractFilePath(d);
204
207
  if (target) {
208
+ // Canonicalize to repo-relative before storing: the claim guard
209
+ // writes canonical paths directly, so an absolute entry here would
210
+ // double-count the same file (inflated "N files" display) and
211
+ // defeat exact-match pruning on commit.
212
+ const canonical = target.startsWith(`${coordRoot}/`)
213
+ ? target.slice(coordRoot.length + 1)
214
+ : target;
205
215
  if (!hb.files_touched)
206
216
  hb.files_touched = [];
207
- if (!hb.files_touched.includes(target))
208
- hb.files_touched.push(target);
217
+ if (!hb.files_touched.includes(canonical))
218
+ hb.files_touched.push(canonical);
209
219
  }
210
220
  }
211
221
  break;
@@ -391,6 +401,7 @@ function writeHeartbeat(coordRoot, instanceId, hb) {
391
401
  setIfDefined(merged, "kind", hb.kind);
392
402
  setIfDefined(merged, "agent_id", hb.agent_id);
393
403
  setIfDefined(merged, "subagent_call_id", hb.subagent_call_id);
404
+ setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
394
405
  setIfDefined(merged, "model", hb.model);
395
406
  setIfDefined(merged, "platform", hb.platform);
396
407
  setIfDefined(merged, "started_at", hb.started_at);
@@ -42,17 +42,31 @@ export declare function setTask(coordRoot: string, instanceId: string, task: str
42
42
  export declare function stampStatusCheck(coordRoot: string, instanceId: string): Heartbeat | null;
43
43
  export declare function setTurnSummary(coordRoot: string, instanceId: string, summary: string): Heartbeat | null;
44
44
  export declare function releaseClaim(coordRoot: string, instanceId: string, path: string): Heartbeat | null;
45
+ /** A heartbeat that actually dropped a path during a group unclaim. */
46
+ export interface GroupUnclaimHit {
47
+ instance_id: string;
48
+ session_id?: string;
49
+ platform?: string;
50
+ }
45
51
  /**
46
52
  * Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
47
53
  * (parent's session_id == group_id;
48
54
  * subagents inherit it) and removes the path from each one's files_touched.
49
- * Idempotent: heartbeats that don't hold the path are untouched.
55
+ * Idempotent: heartbeats that don't hold the path are untouched. Returns the
56
+ * heartbeats that actually dropped the path so the caller can emit the
57
+ * durable `claim.release` events — a file-only prune is silently reverted by
58
+ * the next projector replay.
59
+ *
60
+ * files_touched can hold either absolute-under-coordRoot or canonical
61
+ * repo-relative entries (legacy projections stored the raw tool_input path),
62
+ * so both sides are normalized before comparing — an exact-string match
63
+ * silently no-ops on the mixed-form case and the claim never releases.
50
64
  *
51
65
  * This is the Option B fix for post-commit's pid-map attribution hole: a
52
66
  * subagent-held claim that doesn't live on the parent's heartbeat still gets
53
67
  * pruned because the walk covers the whole group.
54
68
  */
55
- export declare function groupUnclaim(coordRoot: string, groupId: string, path: string): void;
69
+ export declare function groupUnclaim(coordRoot: string, groupId: string, path: string): GroupUnclaimHit[];
56
70
  export declare function killHeartbeat(coordRoot: string, instanceId: string): boolean;
57
71
  export declare function healPidmap(coordRoot: string, instanceId: string, pid: number): void;
58
72
  export declare function healHeartbeat(coordRoot: string, instanceId: string, sessionId?: string, model?: string, harness?: string): Heartbeat | null;
@@ -1 +1 @@
1
- {"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAYlB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA4BnF;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAS5E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAuBnF;AAED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,SAAS,GAAG,IAAI,CAqDlB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,GAAG,IAAI,CAOlB"}
1
+ {"version":3,"file":"heartbeat-writer.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/heartbeat-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA6DH,MAAM,WAAW,SAAS;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAiBD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAQrF;AAeD,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAS7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKxF;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,SAAS,GAAG,IAAI,CAMlB;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,SAAS,GAAG,IAAI,CAYlB;AAED,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,EAAE,CAsChG;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAS5E;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAuBnF;AAED,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,SAAS,GAAG,IAAI,CAqDlB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,SAAS,GAAG,IAAI,CAOlB"}
@@ -129,18 +129,29 @@ export function releaseClaim(coordRoot, instanceId, path) {
129
129
  * Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
130
130
  * (parent's session_id == group_id;
131
131
  * subagents inherit it) and removes the path from each one's files_touched.
132
- * Idempotent: heartbeats that don't hold the path are untouched.
132
+ * Idempotent: heartbeats that don't hold the path are untouched. Returns the
133
+ * heartbeats that actually dropped the path so the caller can emit the
134
+ * durable `claim.release` events — a file-only prune is silently reverted by
135
+ * the next projector replay.
136
+ *
137
+ * files_touched can hold either absolute-under-coordRoot or canonical
138
+ * repo-relative entries (legacy projections stored the raw tool_input path),
139
+ * so both sides are normalized before comparing — an exact-string match
140
+ * silently no-ops on the mixed-form case and the claim never releases.
133
141
  *
134
142
  * This is the Option B fix for post-commit's pid-map attribution hole: a
135
143
  * subagent-held claim that doesn't live on the parent's heartbeat still gets
136
144
  * pruned because the walk covers the whole group.
137
145
  */
138
146
  export function groupUnclaim(coordRoot, groupId, path) {
147
+ const hits = [];
139
148
  if (!groupId || !path)
140
- return;
149
+ return hits;
141
150
  const activeDir = join(coordRoot, ".harnery", "active");
142
151
  if (!existsSync(activeDir))
143
- return;
152
+ return hits;
153
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
154
+ const target = norm(path);
144
155
  for (const f of readdirSync(activeDir)) {
145
156
  if (!f.endsWith(".json"))
146
157
  continue;
@@ -156,7 +167,7 @@ export function groupUnclaim(coordRoot, groupId, path) {
156
167
  if (peerSession !== groupId)
157
168
  continue;
158
169
  const files = body.files_touched ?? [];
159
- const next = files.filter((p) => p !== path);
170
+ const next = files.filter((p) => norm(p) !== target);
160
171
  if (next.length === files.length)
161
172
  continue;
162
173
  body.files_touched = next;
@@ -164,11 +175,17 @@ export function groupUnclaim(coordRoot, groupId, path) {
164
175
  const tmp = `${hbPath}.tmp.${process.pid}`;
165
176
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
166
177
  renameSync(tmp, hbPath);
178
+ hits.push({
179
+ instance_id: body.instance_id ?? f.replace(/\.json$/, ""),
180
+ session_id: body.session_id,
181
+ platform: body.platform,
182
+ });
167
183
  }
168
184
  catch {
169
185
  /* silent */
170
186
  }
171
187
  }
188
+ return hits;
172
189
  }
173
190
  export function killHeartbeat(coordRoot, instanceId) {
174
191
  const path = heartbeatPath(coordRoot, instanceId);