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.
- package/README.md +2 -1
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/callers.d.ts.map +1 -1
- package/dist/commands/callers.js +69 -17
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +84 -2
- package/dist/commands/grep.d.ts +77 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +598 -109
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +89 -0
- package/dist/core/agents/cli.js +10 -3
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +13 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +21 -4
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +38 -0
- package/dist/core/hooks/cli.js +29 -3
- package/dist/core/hooks/events/schema.d.ts +4 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +1 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +102 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +340 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +30 -0
- package/dist/core/workflow/spawn-claude.d.ts +22 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +82 -0
- package/dist/core/workflow/spawn-codex.d.ts +19 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +66 -0
- package/dist/core/workflow/spawn-cursor.d.ts +26 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +72 -0
- package/dist/core/workflow/types.d.ts +145 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/dist/lib/tools/ripgrep.d.ts +61 -0
- package/dist/lib/tools/ripgrep.d.ts.map +1 -0
- package/dist/lib/tools/ripgrep.js +217 -0
- package/package.json +1 -1
- package/src/commander.ts +11 -0
- package/src/commands/callers.ts +90 -26
- package/src/commands/doctor.ts +95 -2
- package/src/commands/grep.ts +737 -113
- package/src/commands/init.ts +138 -17
- package/src/commands/workflow.ts +132 -0
- package/src/core/agents/cli.ts +11 -4
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/state/heartbeat-projector.ts +13 -1
- package/src/core/agents/state/heartbeat-writer.ts +30 -5
- package/src/core/config.ts +47 -0
- package/src/core/hooks/cli.ts +30 -3
- package/src/core/hooks/events/schema.ts +4 -0
- package/src/core/hooks/harness/events.ts +8 -0
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/workflow/billing.ts +146 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +394 -0
- package/src/core/workflow/harnesses.ts +38 -0
- package/src/core/workflow/spawn-claude.ts +99 -0
- package/src/core/workflow/spawn-codex.ts +74 -0
- package/src/core/workflow/spawn-cursor.ts +89 -0
- package/src/core/workflow/types.ts +153 -0
- package/src/core/workflow/validate.ts +75 -0
- package/src/lib/tools/ripgrep.ts +244 -0
package/src/commands/init.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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;
|
|
173
|
-
* `agent-hook <subcommand
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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 =
|
|
218
|
+
const command = hookCommand(spec, agentHookPath, subcommand, harness);
|
|
206
219
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
package/src/core/agents/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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):
|
|
198
|
-
|
|
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 !==
|
|
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 {
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
+
}
|