harnery 0.7.1 → 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 (98) hide show
  1. package/README.md +2 -1
  2. package/dist/commander.d.ts +9 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/callers.d.ts.map +1 -1
  6. package/dist/commands/callers.js +69 -17
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +84 -2
  9. package/dist/commands/grep.d.ts +77 -0
  10. package/dist/commands/grep.d.ts.map +1 -1
  11. package/dist/commands/grep.js +598 -109
  12. package/dist/commands/init.d.ts +15 -5
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +125 -14
  15. package/dist/commands/workflow.d.ts +4 -0
  16. package/dist/commands/workflow.d.ts.map +1 -0
  17. package/dist/commands/workflow.js +89 -0
  18. package/dist/core/agents/cli.js +10 -3
  19. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  20. package/dist/core/agents/rules/claim-conflict.js +26 -1
  21. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  22. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  23. package/dist/core/agents/rules/stop-hook.js +8 -0
  24. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  25. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  26. package/dist/core/agents/state/heartbeat-projector.js +13 -2
  27. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  28. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  29. package/dist/core/agents/state/heartbeat-writer.js +21 -4
  30. package/dist/core/config.d.ts +18 -0
  31. package/dist/core/config.d.ts.map +1 -1
  32. package/dist/core/config.js +38 -0
  33. package/dist/core/hooks/cli.js +29 -3
  34. package/dist/core/hooks/events/schema.d.ts +4 -0
  35. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  36. package/dist/core/hooks/harness/events.d.ts +7 -0
  37. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  38. package/dist/core/hooks/harness/events.js +1 -0
  39. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  40. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  41. package/dist/core/hooks/resolve/coord-root.js +28 -6
  42. package/dist/core/workflow/billing.d.ts +48 -0
  43. package/dist/core/workflow/billing.d.ts.map +1 -0
  44. package/dist/core/workflow/billing.js +102 -0
  45. package/dist/core/workflow/child-env.d.ts +30 -0
  46. package/dist/core/workflow/child-env.d.ts.map +1 -0
  47. package/dist/core/workflow/child-env.js +43 -0
  48. package/dist/core/workflow/engine.d.ts +21 -0
  49. package/dist/core/workflow/engine.d.ts.map +1 -0
  50. package/dist/core/workflow/engine.js +340 -0
  51. package/dist/core/workflow/harnesses.d.ts +17 -0
  52. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  53. package/dist/core/workflow/harnesses.js +30 -0
  54. package/dist/core/workflow/spawn-claude.d.ts +22 -0
  55. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  56. package/dist/core/workflow/spawn-claude.js +82 -0
  57. package/dist/core/workflow/spawn-codex.d.ts +19 -0
  58. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  59. package/dist/core/workflow/spawn-codex.js +66 -0
  60. package/dist/core/workflow/spawn-cursor.d.ts +26 -0
  61. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  62. package/dist/core/workflow/spawn-cursor.js +72 -0
  63. package/dist/core/workflow/types.d.ts +145 -0
  64. package/dist/core/workflow/types.d.ts.map +1 -0
  65. package/dist/core/workflow/types.js +9 -0
  66. package/dist/core/workflow/validate.d.ts +15 -0
  67. package/dist/core/workflow/validate.d.ts.map +1 -0
  68. package/dist/core/workflow/validate.js +70 -0
  69. package/dist/lib/tools/ripgrep.d.ts +61 -0
  70. package/dist/lib/tools/ripgrep.d.ts.map +1 -0
  71. package/dist/lib/tools/ripgrep.js +217 -0
  72. package/package.json +1 -1
  73. package/src/commander.ts +11 -0
  74. package/src/commands/callers.ts +90 -26
  75. package/src/commands/doctor.ts +95 -2
  76. package/src/commands/grep.ts +737 -113
  77. package/src/commands/init.ts +138 -17
  78. package/src/commands/workflow.ts +132 -0
  79. package/src/core/agents/cli.ts +11 -4
  80. package/src/core/agents/rules/claim-conflict.ts +26 -1
  81. package/src/core/agents/rules/stop-hook.ts +17 -0
  82. package/src/core/agents/state/heartbeat-projector.ts +13 -1
  83. package/src/core/agents/state/heartbeat-writer.ts +30 -5
  84. package/src/core/config.ts +47 -0
  85. package/src/core/hooks/cli.ts +30 -3
  86. package/src/core/hooks/events/schema.ts +4 -0
  87. package/src/core/hooks/harness/events.ts +8 -0
  88. package/src/core/hooks/resolve/coord-root.ts +28 -6
  89. package/src/core/workflow/billing.ts +146 -0
  90. package/src/core/workflow/child-env.ts +47 -0
  91. package/src/core/workflow/engine.ts +394 -0
  92. package/src/core/workflow/harnesses.ts +38 -0
  93. package/src/core/workflow/spawn-claude.ts +99 -0
  94. package/src/core/workflow/spawn-codex.ts +74 -0
  95. package/src/core/workflow/spawn-cursor.ts +89 -0
  96. package/src/core/workflow/types.ts +153 -0
  97. package/src/core/workflow/validate.ts +75 -0
  98. package/src/lib/tools/ripgrep.ts +244 -0
@@ -120,6 +120,17 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
120
120
  if (stamp) actions.push(stamp);
121
121
  }
122
122
 
123
+ // ── 1c. workflow billing default ───────────────────────────────────────
124
+ // Every init'd project gets `workflow.subscriptionOnly: true`: workflow
125
+ // children ride the logged-in (subscription) harness auth, and the pin
126
+ // makes per-token API billing structurally impossible unless the project
127
+ // deliberately flips it. A committed `workflow` key of any shape is a
128
+ // deliberate choice and is never touched.
129
+ {
130
+ const stamp = stampWorkflowDefaults(resolve(coordDir, "config.jsonc"), dryRun);
131
+ if (stamp) actions.push(stamp);
132
+ }
133
+
123
134
  // ── 2. harness hooks ───────────────────────────────────────────────────
124
135
  const settingsPath = resolve(projectRoot, spec.settingsFile);
125
136
  const agentHook = relative(projectRoot, resolve(HARNERY_ROOT, "bin", "agent-hook"));
@@ -139,23 +150,23 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
139
150
  } else {
140
151
  settings = {};
141
152
  }
142
- const { wired, already, removed } = wireHooks(settings, spec, agentHook, harness);
153
+ const { wired, already, removed, upgraded } = wireHooks(settings, spec, agentHook, harness);
143
154
 
144
- if (wired === 0 && removed === 0) {
155
+ if (wired === 0 && removed === 0 && upgraded === 0) {
145
156
  actions.push(
146
157
  `· all ${spec.events.length} ${harness} hooks already wired in ${rel(projectRoot, settingsPath)}`,
147
158
  );
148
159
  } else if (dryRun) {
149
160
  actions.push(
150
- `+ would wire ${wired} hook(s) and remove ${removed} legacy hook(s) in ` +
151
- `${rel(projectRoot, settingsPath)} (${already} already present)`,
161
+ `+ would wire ${wired} hook(s), upgrade ${upgraded} stale command(s), and remove ` +
162
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
152
163
  );
153
164
  } else {
154
165
  mkdirSync(dirname(settingsPath), { recursive: true });
155
166
  writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
156
167
  actions.push(
157
- `+ wired ${wired} hook(s) and removed ${removed} legacy hook(s) in ` +
158
- `${rel(projectRoot, settingsPath)} (${already} already present)`,
168
+ `+ wired ${wired} hook(s), upgraded ${upgraded} stale command(s), and removed ` +
169
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
159
170
  );
160
171
  }
161
172
 
@@ -169,11 +180,12 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
169
180
 
170
181
  /**
171
182
  * Merge agent-hook entries into a harness settings object in place, idempotently.
172
- * Preserves every existing hook; skips events already wired to
173
- * `agent-hook <subcommand>`. Honors the harness's entry shape (Claude/Codex nest
174
- * under an inner `hooks` array; Cursor uses a flat `{ command }`) and ensures the
175
- * root `version` key when the harness requires one. Pure (no fs/git) so it's
176
- * unit-testable.
183
+ * Preserves every existing hook; rewrites events already wired to
184
+ * `agent-hook <subcommand>` when their command string is stale (e.g. an older
185
+ * harnery wired a bare relative path). Honors the harness's entry shape
186
+ * (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
187
+ * `{ command }`) and ensures the root `version` key when the harness requires
188
+ * one. Pure (no fs/git) so it's unit-testable.
177
189
  *
178
190
  * The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
179
191
  * it keeps `stop` from matching `stop-failure`.
@@ -183,7 +195,7 @@ export function wireHooks(
183
195
  spec: HarnessSpec,
184
196
  agentHookPath: string,
185
197
  harness: HarnessId,
186
- ): { wired: number; already: number; removed: number } {
198
+ ): { wired: number; already: number; removed: number; upgraded: number } {
187
199
  if (spec.rootVersion !== undefined && settings.version === undefined) {
188
200
  settings.version = spec.rootVersion;
189
201
  }
@@ -191,6 +203,7 @@ export function wireHooks(
191
203
  let wired = 0;
192
204
  let already = 0;
193
205
  let removed = 0;
206
+ let upgraded = 0;
194
207
  for (const { settingsKey, subcommand } of spec.legacyEvents ?? []) {
195
208
  const groups = settings.hooks[settingsKey] ?? [];
196
209
  const kept = groups.filter(
@@ -202,11 +215,14 @@ export function wireHooks(
202
215
  else settings.hooks[settingsKey] = kept;
203
216
  }
204
217
  for (const { settingsKey, subcommand } of spec.events) {
205
- const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
218
+ const command = hookCommand(spec, agentHookPath, subcommand, harness);
206
219
  const groups = settings.hooks[settingsKey] ?? [];
207
- const present = groups.some((g) =>
208
- groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)),
209
- );
220
+ let present = false;
221
+ for (const group of groups) {
222
+ upgraded += rewriteStaleCommands(group, subcommand, command, () => {
223
+ present = true;
224
+ });
225
+ }
210
226
  if (present) {
211
227
  already++;
212
228
  continue;
@@ -215,7 +231,62 @@ export function wireHooks(
215
231
  settings.hooks[settingsKey] = groups;
216
232
  wired++;
217
233
  }
218
- return { wired, already, removed };
234
+ return { wired, already, removed, upgraded };
235
+ }
236
+
237
+ /**
238
+ * The canonical hook command for one event. When the harness exports a
239
+ * project-dir env var to hook processes (Claude Code's CLAUDE_PROJECT_DIR),
240
+ * anchor the agent-hook path on it: hook processes inherit the session
241
+ * shell's cwd, which follows `cd` away from the project root — a bare
242
+ * relative path silently fails to spawn from there (no events, no image
243
+ * capture, no guards). `:-.` keeps the command working on harness versions
244
+ * that don't set the var. Only the env expansion is quoted so the
245
+ * `agent-hook <subcommand> ` wiring match stays byte-identical.
246
+ */
247
+ function hookCommand(
248
+ spec: HarnessSpec,
249
+ agentHookPath: string,
250
+ subcommand: string,
251
+ harness: HarnessId,
252
+ ): string {
253
+ const anchor = spec.projectDirEnv ? `"\${${spec.projectDirEnv}:-.}"/` : "";
254
+ return `bash ${anchor}${agentHookPath} ${subcommand} --harness ${harness}`;
255
+ }
256
+
257
+ /**
258
+ * Rewrite any harnery-owned command for `subcommand` inside one hook group to
259
+ * the canonical form, in place. Returns the number of commands rewritten and
260
+ * calls `onPresent` when the subcommand is wired in this group at all.
261
+ */
262
+ function rewriteStaleCommands(
263
+ group: HookGroup,
264
+ subcommand: string,
265
+ canonical: string,
266
+ onPresent: () => void,
267
+ ): number {
268
+ let rewritten = 0;
269
+ if ("command" in group && typeof group.command === "string") {
270
+ if (commandWiresSubcommand(group.command, subcommand)) {
271
+ onPresent();
272
+ if (group.command !== canonical) {
273
+ group.command = canonical;
274
+ rewritten++;
275
+ }
276
+ }
277
+ }
278
+ if ("hooks" in group && Array.isArray(group.hooks)) {
279
+ for (const hook of group.hooks) {
280
+ if (typeof hook.command !== "string") continue;
281
+ if (!commandWiresSubcommand(hook.command, subcommand)) continue;
282
+ onPresent();
283
+ if (hook.command !== canonical) {
284
+ hook.command = canonical;
285
+ rewritten++;
286
+ }
287
+ }
288
+ }
289
+ return rewritten;
219
290
  }
220
291
 
221
292
  /**
@@ -311,6 +382,56 @@ export function stampBinName(configPath: string, binName: string, dryRun: boolea
311
382
  return `+ added binName "${binName}" to ${rel(configPath)}`;
312
383
  }
313
384
 
385
+ /** The commented workflow-billing default block init stamps into config.jsonc. */
386
+ const WORKFLOW_DEFAULT_BLOCK =
387
+ ` // Workflow children ride the logged-in (subscription) harness auth; this pin\n` +
388
+ ` // scrubs API-key vars from child envs so runs can never bill per-token rates.\n` +
389
+ ` // Key-only hosts (CI): set false, or HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=0.\n` +
390
+ ` "workflow": { "subscriptionOnly": true }`;
391
+
392
+ /**
393
+ * Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
394
+ * (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
395
+ * billing safeguards). Same comment-preserving discipline as `stampBinName`.
396
+ * A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
397
+ * deliberate project choice and is left alone.
398
+ */
399
+ export function stampWorkflowDefaults(configPath: string, dryRun: boolean): string | null {
400
+ const rel = (p: string) => relative(dirname(dirname(configPath)), p) || p;
401
+
402
+ if (!existsSync(configPath)) {
403
+ if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
404
+ writeFileSync(configPath, `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`);
405
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
406
+ }
407
+
408
+ let raw: string;
409
+ try {
410
+ raw = readFileSync(configPath, "utf8");
411
+ } catch {
412
+ return null;
413
+ }
414
+ let parsed: { workflow?: unknown } & Record<string, unknown>;
415
+ try {
416
+ parsed = (JSON.parse(stripJsonComments(raw)) as typeof parsed) ?? {};
417
+ } catch {
418
+ return `· ${rel(configPath)} isn't valid JSONC; skipped workflow.subscriptionOnly pin`;
419
+ }
420
+
421
+ if ("workflow" in parsed) return null; // deliberate config; never touch
422
+
423
+ const at = firstBraceIndex(raw);
424
+ if (at < 0) return `· ${rel(configPath)} has no object literal; skipped workflow pin`;
425
+ const keys = Object.keys(parsed);
426
+ const next =
427
+ keys.length === 0
428
+ ? `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`
429
+ : `${raw.slice(0, at + 1)}\n${WORKFLOW_DEFAULT_BLOCK},${raw.slice(at + 1)}`;
430
+ if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
431
+ writeFileSync(configPath, next);
432
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
433
+ }
434
+
314
435
  /** Index of the first structural `{`, skipping leading whitespace + comments. */
315
436
  function firstBraceIndex(raw: string): number {
316
437
  let i = 0;
@@ -0,0 +1,132 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import { workflowSubscriptionOnly } from "../core/config.ts";
4
+ import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
5
+
6
+ /**
7
+ * `workflow run <script>`: execute a workflow script — bounded, schema-gated,
8
+ * conditionally-routed stages fanning out to headless harness-CLI subagents.
9
+ * The script (deterministic code), not a model, decides routing between
10
+ * stages, and the run terminates when the script returns.
11
+ *
12
+ * Phase 2 surface: `run` only, claude-code spawn adapter only. Design +
13
+ * phasing: decision 0015.
14
+ */
15
+
16
+ interface WorkflowRunOpts {
17
+ maxAgents?: string;
18
+ concurrency?: string;
19
+ cwd?: string;
20
+ harness?: string;
21
+ resumeFrom?: string;
22
+ subscriptionOnly?: boolean;
23
+ allowApiBilling?: boolean;
24
+ json?: boolean;
25
+ }
26
+
27
+ const HARNESSES = ["claude-code", "codex", "cursor"] as const;
28
+
29
+ export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
30
+ const workflow = program
31
+ .command("workflow")
32
+ .description("Run bounded, schema-gated multi-subagent workflow scripts.");
33
+
34
+ workflow
35
+ .command("run <script>")
36
+ .description(
37
+ "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
38
+ "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
39
+ )
40
+ .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
41
+ .option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
42
+ .option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
43
+ .option(
44
+ "--harness <name>",
45
+ `Default harness for agent() calls: ${HARNESSES.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
46
+ )
47
+ .option(
48
+ "--resume-from <run-id>",
49
+ "Reuse completed agent results from a prior run's journal; only changed/failed calls re-run",
50
+ )
51
+ .option(
52
+ "--subscription-only",
53
+ "Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
54
+ "harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)",
55
+ )
56
+ .option(
57
+ "--allow-api-billing",
58
+ "Permit an exported API key to override a stored subscription login (per-token billing); " +
59
+ "without this the engine refuses that silent-override state",
60
+ )
61
+ .option("--json", "Emit the full RunReport as JSON")
62
+ .action(async (script: string, opts: WorkflowRunOpts) => {
63
+ const coordRoot = findCoordRoot();
64
+ if (!coordRoot) {
65
+ emit.error({
66
+ code: "no_coord_root",
67
+ message: "no .harnery/ coordination root found; run `init` first",
68
+ });
69
+ process.exit(1);
70
+ }
71
+ if (opts.harness && !HARNESSES.includes(opts.harness as (typeof HARNESSES)[number])) {
72
+ emit.error({
73
+ code: "bad_harness",
74
+ message: `unknown harness "${opts.harness}" (expected: ${HARNESSES.join(" | ")})`,
75
+ });
76
+ process.exit(1);
77
+ }
78
+ // Flag beats config; the two flags contradict each other by design.
79
+ const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
80
+ if (subscriptionOnly && opts.allowApiBilling) {
81
+ emit.error({
82
+ code: "billing_flags_conflict",
83
+ message:
84
+ "--allow-api-billing contradicts subscription-only mode " +
85
+ "(flag or config.jsonc workflow.subscriptionOnly); pick one",
86
+ });
87
+ process.exit(1);
88
+ }
89
+ try {
90
+ const [{ runWorkflow }, { claudeCodeSpawner }, { codexSpawner }, { cursorSpawner }] =
91
+ await Promise.all([
92
+ import("../core/workflow/engine.ts"),
93
+ import("../core/workflow/spawn-claude.ts"),
94
+ import("../core/workflow/spawn-codex.ts"),
95
+ import("../core/workflow/spawn-cursor.ts"),
96
+ ]);
97
+ const report = await runWorkflow(script, {
98
+ coordRoot,
99
+ spawners: {
100
+ "claude-code": claudeCodeSpawner,
101
+ codex: codexSpawner,
102
+ cursor: cursorSpawner,
103
+ },
104
+ defaultHarness: opts.harness as "claude-code" | "codex" | "cursor" | undefined,
105
+ resumeFrom: opts.resumeFrom,
106
+ subscriptionOnly,
107
+ allowApiBilling: opts.allowApiBilling,
108
+ maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
109
+ concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
110
+ cwd: opts.cwd,
111
+ });
112
+ if (opts.json) {
113
+ emit.config({ format: "json" });
114
+ emit.data(report);
115
+ return;
116
+ }
117
+ const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
118
+ const billingPart = report.billing.length
119
+ ? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
120
+ : "";
121
+ emit.text(
122
+ `run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
123
+ `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
124
+ `journal: ${report.journalPath}\n` +
125
+ `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
126
+ );
127
+ } catch (err) {
128
+ emit.error({ code: "workflow_failed", message: (err as Error).message });
129
+ process.exit(1);
130
+ }
131
+ });
132
+ }
@@ -176,7 +176,7 @@ async function emitClaimRelease(
176
176
  owner: string,
177
177
  hb: { session_id?: string; platform?: string },
178
178
  path: string,
179
- reason: "explicit" | "heal",
179
+ reason: "explicit" | "heal" | "commit" | "checkout",
180
180
  ): Promise<void> {
181
181
  try {
182
182
  const { emit } = await import("./events/emit.ts");
@@ -475,11 +475,16 @@ async function handlePostCommit(root: string): Promise<number> {
475
475
  const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
476
476
 
477
477
  // Session-group-wide unclaim. `owner` is the parent's session_id which is
478
- // also the group key (parent + subagents share session_id).
478
+ // also the group key (parent + subagents share session_id). Each actual
479
+ // removal also emits a durable claim.release event — the projector rebuilds
480
+ // files_touched from the permanent Edit/Write events, so a file-only prune
481
+ // would resurrect on the next replay.
479
482
  if (req.owner && Array.isArray(req.prune)) {
480
483
  for (const path of req.prune) {
481
484
  try {
482
- groupUnclaim(root, req.owner, path);
485
+ for (const hit of groupUnclaim(root, req.owner, path)) {
486
+ await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
487
+ }
483
488
  } catch {
484
489
  /* best-effort */
485
490
  }
@@ -500,7 +505,9 @@ async function handlePostCheckout(root: string, _rest: string[]): Promise<number
500
505
  if (req.owner && Array.isArray(req.removed)) {
501
506
  for (const path of req.removed) {
502
507
  try {
503
- groupUnclaim(root, req.owner, path);
508
+ for (const hit of groupUnclaim(root, req.owner, path)) {
509
+ await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
510
+ }
504
511
  } catch {
505
512
  /* best-effort */
506
513
  }
@@ -17,6 +17,7 @@
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { emit } from "../events/emit.ts";
20
21
 
21
22
  const FRESHNESS_SECS = 600;
22
23
 
@@ -306,12 +307,36 @@ function pruneClaimFromPeer(coordRoot: string, instanceId: string, relPath: stri
306
307
  try {
307
308
  const body = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
308
309
  const files = (body.files_touched as string[] | undefined) ?? [];
309
- const next = files.filter((p) => p !== relPath);
310
+ // Normalize both sides: files_touched can hold absolute-under-coordRoot
311
+ // entries (legacy projections) as well as canonical relative ones — an
312
+ // exact-string filter silently no-ops on the mixed-form case and the
313
+ // stale claim never heals.
314
+ const norm = (p: string): string =>
315
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
316
+ const target = norm(relPath);
317
+ const next = files.filter((p) => norm(p) !== target);
310
318
  if (next.length === files.length) return;
311
319
  body.files_touched = next;
312
320
  const tmp = `${path}.tmp.${process.pid}`;
313
321
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
314
322
  renameSync(tmp, path);
323
+ // Durable subtraction: the projector rebuilds files_touched from the
324
+ // permanent Edit/Write events, so a file-only prune resurrects on the
325
+ // next replay. Soft-fail — the heartbeat write already landed.
326
+ try {
327
+ const platform = body.platform as string | undefined;
328
+ emit(coordRoot, {
329
+ event_type: "claim.release",
330
+ instance_id: instanceId,
331
+ session_id: (body.session_id as string | undefined) ?? instanceId,
332
+ harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
333
+ source: "agent-coord",
334
+ data: { path: target, reason: "heal" },
335
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
336
+ } as Parameters<typeof emit>[1]);
337
+ } catch {
338
+ /* soft-fail */
339
+ }
315
340
  } catch {
316
341
  /* silent */
317
342
  }
@@ -49,6 +49,14 @@ export interface StopHookRequest {
49
49
  turn_window?: { start_ms: number; end_ms: number };
50
50
  /** Bypass switch: operator escape hatch identical to HARNERY_AGENT_COORD_BYPASS_STOP. */
51
51
  bypass?: boolean;
52
+ /** Headless child spawned by `harn workflow` (HARNERY_WORKFLOW_CHILD=1).
53
+ * The end-of-turn ritual exists to surface status to a HUMAN reader; a
54
+ * workflow child reports to the engine's journal instead, so the ritual is
55
+ * meaningless there — worse, blocking burns the child's turn budget on
56
+ * re-prompts (observed as error_max_turns in the Phase 1 spike). Exempting
57
+ * here, rather than disabling the child's hooks wholesale, keeps heartbeat +
58
+ * event capture on: the child stays visible to peers and the coord layer. */
59
+ workflow_child?: boolean;
52
60
  }
53
61
 
54
62
  /**
@@ -95,6 +103,15 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
95
103
  };
96
104
  }
97
105
 
106
+ if (req.workflow_child) {
107
+ return {
108
+ allow: true,
109
+ exit_code: 0,
110
+ rule: "stop-hook.workflow_child",
111
+ reason: "HARNERY_WORKFLOW_CHILD=1: headless workflow child; ritual not applicable",
112
+ };
113
+ }
114
+
98
115
  let events: CanonicalEvent[];
99
116
  try {
100
117
  events = readRecentEvents(coordRoot, RECENT_EVENT_WINDOW_LINES);
@@ -24,6 +24,8 @@ export interface V2Heartbeat {
24
24
  platform?: string;
25
25
  subagent_call_id?: string;
26
26
  parent_session_id?: string;
27
+ /** Set iff this owner is a `workflow run` child (joins to the run journal). */
28
+ workflow_run_id?: string;
27
29
  started_at?: string;
28
30
  last_heartbeat: string;
29
31
  last_tool?: string;
@@ -184,6 +186,8 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
184
186
  if (subagentCallId) hb.subagent_call_id = subagentCallId;
185
187
  const parentSession = pickStr(d, "parent_session_id");
186
188
  if (parentSession) hb.parent_session_id = parentSession;
189
+ const workflowRunId = pickStr(d, "workflow_run_id");
190
+ if (workflowRunId) hb.workflow_run_id = workflowRunId;
187
191
  if (!hb.files_touched) hb.files_touched = [];
188
192
  }
189
193
  break;
@@ -248,8 +252,15 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
248
252
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
249
253
  const target = extractFilePath(d);
250
254
  if (target) {
255
+ // Canonicalize to repo-relative before storing: the claim guard
256
+ // writes canonical paths directly, so an absolute entry here would
257
+ // double-count the same file (inflated "N files" display) and
258
+ // defeat exact-match pruning on commit.
259
+ const canonical = target.startsWith(`${coordRoot}/`)
260
+ ? target.slice(coordRoot.length + 1)
261
+ : target;
251
262
  if (!hb.files_touched) hb.files_touched = [];
252
- if (!hb.files_touched.includes(target)) hb.files_touched.push(target);
263
+ if (!hb.files_touched.includes(canonical)) hb.files_touched.push(canonical);
253
264
  }
254
265
  }
255
266
  break;
@@ -437,6 +448,7 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
437
448
  setIfDefined(merged, "kind", hb.kind);
438
449
  setIfDefined(merged, "agent_id", hb.agent_id);
439
450
  setIfDefined(merged, "subagent_call_id", hb.subagent_call_id);
451
+ setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
440
452
  setIfDefined(merged, "model", hb.model);
441
453
  setIfDefined(merged, "platform", hb.platform);
442
454
  setIfDefined(merged, "started_at", hb.started_at);
@@ -184,20 +184,39 @@ export function releaseClaim(
184
184
  }));
185
185
  }
186
186
 
187
+ /** A heartbeat that actually dropped a path during a group unclaim. */
188
+ export interface GroupUnclaimHit {
189
+ instance_id: string;
190
+ session_id?: string;
191
+ platform?: string;
192
+ }
193
+
187
194
  /**
188
195
  * Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
189
196
  * (parent's session_id == group_id;
190
197
  * subagents inherit it) and removes the path from each one's files_touched.
191
- * Idempotent: heartbeats that don't hold the path are untouched.
198
+ * Idempotent: heartbeats that don't hold the path are untouched. Returns the
199
+ * heartbeats that actually dropped the path so the caller can emit the
200
+ * durable `claim.release` events — a file-only prune is silently reverted by
201
+ * the next projector replay.
202
+ *
203
+ * files_touched can hold either absolute-under-coordRoot or canonical
204
+ * repo-relative entries (legacy projections stored the raw tool_input path),
205
+ * so both sides are normalized before comparing — an exact-string match
206
+ * silently no-ops on the mixed-form case and the claim never releases.
192
207
  *
193
208
  * This is the Option B fix for post-commit's pid-map attribution hole: a
194
209
  * subagent-held claim that doesn't live on the parent's heartbeat still gets
195
210
  * pruned because the walk covers the whole group.
196
211
  */
197
- export function groupUnclaim(coordRoot: string, groupId: string, path: string): void {
198
- if (!groupId || !path) return;
212
+ export function groupUnclaim(coordRoot: string, groupId: string, path: string): GroupUnclaimHit[] {
213
+ const hits: GroupUnclaimHit[] = [];
214
+ if (!groupId || !path) return hits;
199
215
  const activeDir = join(coordRoot, ".harnery", "active");
200
- if (!existsSync(activeDir)) return;
216
+ if (!existsSync(activeDir)) return hits;
217
+ const norm = (p: string): string =>
218
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
219
+ const target = norm(path);
201
220
  for (const f of readdirSync(activeDir)) {
202
221
  if (!f.endsWith(".json")) continue;
203
222
  const hbPath = join(activeDir, f);
@@ -211,17 +230,23 @@ export function groupUnclaim(coordRoot: string, groupId: string, path: string):
211
230
  (body.session_id as string | undefined) ?? (body.instance_id as string | undefined);
212
231
  if (peerSession !== groupId) continue;
213
232
  const files = (body.files_touched as string[] | undefined) ?? [];
214
- const next = files.filter((p) => p !== path);
233
+ const next = files.filter((p) => norm(p) !== target);
215
234
  if (next.length === files.length) continue;
216
235
  body.files_touched = next;
217
236
  try {
218
237
  const tmp = `${hbPath}.tmp.${process.pid}`;
219
238
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
220
239
  renameSync(tmp, hbPath);
240
+ hits.push({
241
+ instance_id: (body.instance_id as string | undefined) ?? f.replace(/\.json$/, ""),
242
+ session_id: body.session_id as string | undefined,
243
+ platform: body.platform as string | undefined,
244
+ });
221
245
  } catch {
222
246
  /* silent */
223
247
  }
224
248
  }
249
+ return hits;
225
250
  }
226
251
 
227
252
  export function killHeartbeat(coordRoot: string, instanceId: string): boolean {
@@ -33,6 +33,19 @@ interface HarneryConfig {
33
33
  * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
34
34
  */
35
35
  hooksSetupHint?: string;
36
+ /**
37
+ * Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }`
38
+ * lets `grep` download the pinned, checksum-verified ripgrep into the
39
+ * harnery tools dir on first miss. Committed by a host repo once; absent →
40
+ * a missing rg only produces a rate-limited install hint.
41
+ */
42
+ tools?: { ripgrep?: { autoInstall?: boolean } };
43
+ /**
44
+ * Workflow-engine defaults. `{ subscriptionOnly: true }` pins every
45
+ * `workflow run` in this repo to subscription billing (API-key vars are
46
+ * scrubbed from child envs) without anyone having to remember the flag.
47
+ */
48
+ workflow?: { subscriptionOnly?: boolean };
36
49
  [k: string]: unknown;
37
50
  }
38
51
 
@@ -144,3 +157,37 @@ export function resolveHooksSetupHint(coordRoot?: string | null): string | null
144
157
  const hint = readConfig(root).hooksSetupHint;
145
158
  return typeof hint === "string" && hint.trim() ? hint.trim() : null;
146
159
  }
160
+
161
+ /**
162
+ * Whether the host project consented to automatic ripgrep provisioning:
163
+ * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
164
+ * A repo commits that once and every clone self-heals on first `grep`; without
165
+ * it, a missing rg only produces a rate-limited hint (`doctor --fix` installs
166
+ * explicitly). `HARNERY_TOOLS_AUTOINSTALL=1|0` overrides per process.
167
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
168
+ */
169
+ export function ripgrepAutoInstall(coordRoot?: string | null): boolean {
170
+ const env = coordEnv("TOOLS_AUTOINSTALL");
171
+ if (env === "1") return true;
172
+ if (env === "0") return false;
173
+ const root = coordRoot ?? findCoordRoot();
174
+ if (!root) return false;
175
+ return readConfig(root).tools?.ripgrep?.autoInstall === true;
176
+ }
177
+
178
+ /**
179
+ * Whether this repo pins workflow runs to subscription billing:
180
+ * `.harnery/config.jsonc` `{ "workflow": { "subscriptionOnly": true } }`.
181
+ * The `workflow run --subscription-only` flag turns it on per invocation;
182
+ * `HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=1|0` overrides per process (the `0`
183
+ * escape hatch exists for a key-only CI job inside a pinned repo).
184
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
185
+ */
186
+ export function workflowSubscriptionOnly(coordRoot?: string | null): boolean {
187
+ const env = coordEnv("WORKFLOW_SUBSCRIPTION_ONLY");
188
+ if (env === "1") return true;
189
+ if (env === "0") return false;
190
+ const root = coordRoot ?? findCoordRoot();
191
+ if (!root) return false;
192
+ return readConfig(root).workflow?.subscriptionOnly === true;
193
+ }