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
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Workflow engine: loads a workflow script (plain JS, `export default
3
+ * async (ctx) => …`), injects the ctx API, enforces the caps, journals every
4
+ * step to `.harnery/workflows/<run-id>/journal.jsonl`, and returns a RunReport.
5
+ *
6
+ * Guarantees the engine makes (the pitch, in code):
7
+ * - **Bounded**: hard total-agent ceiling + bounded parallel() concurrency.
8
+ * A runaway loop hits `maxAgents` and the run fails loud, not silently.
9
+ * - **Terminating**: the run is over when the script's default export
10
+ * returns. There is no recursive self-spawning path: subagents are leaf
11
+ * processes; only the top-level script can spawn.
12
+ * - **Schema-gated**: with `schema`, an agent's reply must strict-parse and
13
+ * validate; failures re-prompt with the validation errors appended, up to
14
+ * `maxAttempts`, then throw. Routing decisions read validated fields, so
15
+ * the deterministic script — not a model — decides what runs next.
16
+ * - **Journaled**: every stage/agent start+end lands in the run journal with
17
+ * cost, duration, and child session id (the resume + web-UI substrate).
18
+ */
19
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
20
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
21
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
22
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
23
+ });
24
+ }
25
+ return path;
26
+ };
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
29
+ import { isAbsolute, join, resolve } from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+ import { probeBilling } from "./billing.js";
32
+ import { parseStageOutput, validateAgainstSchema } from "./validate.js";
33
+ const DEFAULT_MAX_AGENTS = 50;
34
+ const DEFAULT_CONCURRENCY = 4;
35
+ const DEFAULT_MAX_ATTEMPTS = 2;
36
+ const DEFAULT_TIMEOUT_MS = 300_000;
37
+ const DEFAULT_MAX_TURNS = 25;
38
+ export async function runWorkflow(scriptPath, opts) {
39
+ const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
40
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(absScript).href)));
41
+ if (typeof mod.default !== "function") {
42
+ throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
43
+ }
44
+ const name = mod.meta?.name ?? scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
45
+ const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
46
+ const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
47
+ mkdirSync(runDir, { recursive: true });
48
+ const journalPath = join(runDir, "journal.jsonl");
49
+ const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
50
+ const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
51
+ const cwd = opts.cwd ?? opts.coordRoot;
52
+ const log = opts.onLog ?? ((line) => process.stderr.write(`${line}\n`));
53
+ const defaultHarness = opts.defaultHarness ?? "claude-code";
54
+ // Per-child fixed context overhead: children spawn in `cwd` and load its
55
+ // repo-instructions file into their system prompt, cache-writing it once
56
+ // per child. A fan-out multiplies this, so surface it BEFORE the burn.
57
+ const contextTokensPerChildEstimate = estimateInstructionTokens(cwd);
58
+ if (contextTokensPerChildEstimate > 0) {
59
+ log(`[context] each child cache-writes ~${Math.round(contextTokensPerChildEstimate / 1000)}K tokens of repo ` +
60
+ `instructions from ${cwd}; a fan-out multiplies this per agent`);
61
+ }
62
+ // Resume: journaled results of a prior run, keyed by agent-call identity.
63
+ const resumeCache = opts.resumeFrom
64
+ ? loadResumeCache(opts.coordRoot, opts.resumeFrom)
65
+ : new Map();
66
+ let agentsSpawned = 0;
67
+ let agentsCached = 0;
68
+ let costUsd = 0;
69
+ let currentStage = "";
70
+ let agentSeq = 0;
71
+ const billingProbed = new Map();
72
+ const journal = (event, data) => {
73
+ const line = JSON.stringify({
74
+ ts: new Date().toISOString(),
75
+ event,
76
+ stage: currentStage,
77
+ ...data,
78
+ });
79
+ appendFileSync(journalPath, `${line}\n`, "utf8");
80
+ };
81
+ // Bounded concurrency gate shared by every spawn in the run — direct
82
+ // `agent()` calls and `parallel()` thunks draw from the same slot pool, so
83
+ // the cap holds even when a script nests parallel() inside loops.
84
+ let inFlight = 0;
85
+ const waiters = [];
86
+ const acquire = async () => {
87
+ if (inFlight < concurrency) {
88
+ inFlight++;
89
+ return;
90
+ }
91
+ await new Promise((res) => waiters.push(res));
92
+ inFlight++;
93
+ };
94
+ const release = () => {
95
+ inFlight--;
96
+ waiters.shift()?.();
97
+ };
98
+ const agent = async (prompt, agentOpts = {}) => {
99
+ const harness = agentOpts.harness ?? defaultHarness;
100
+ const spawner = opts.spawners[harness];
101
+ if (!spawner) {
102
+ throw new Error(`no spawner registered for harness "${harness}" (registered: ${Object.keys(opts.spawners).join(", ") || "none"})`);
103
+ }
104
+ const id = `a${++agentSeq}`;
105
+ const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
106
+ const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
107
+ // Call identity for resume: same stage + harness + model + turns + schema
108
+ // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
109
+ const key = agentCallKey(currentStage, harness, agentOpts, prompt);
110
+ const cached = resumeCache.get(key);
111
+ if (cached) {
112
+ agentsCached++;
113
+ journal("agent.cached", { id, label, key, kind: cached.kind });
114
+ log(`[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`);
115
+ return cached.value;
116
+ }
117
+ // Billing safeguard: on a harness's FIRST spawn this run, classify which
118
+ // auth its children will use and refuse the silent-override state (an
119
+ // exported API key shadowing a stored subscription login) unless the
120
+ // caller explicitly opted into API billing. Cached agents never reach
121
+ // this — no spawn, no billing.
122
+ if (!billingProbed.has(harness)) {
123
+ const probe = (opts.probeBilling ?? probeBilling)(harness);
124
+ billingProbed.set(harness, probe);
125
+ journal("billing.probe", {
126
+ harness,
127
+ mode: opts.subscriptionOnly ? "subscription" : probe.mode,
128
+ api_key_source: probe.apiKeySource,
129
+ login: probe.login,
130
+ subscription_only: Boolean(opts.subscriptionOnly),
131
+ });
132
+ if (opts.subscriptionOnly) {
133
+ if (probe.login === "absent") {
134
+ throw new Error(`subscription-only: no stored login detected for ${harness}; ` +
135
+ `log the harness CLI in (or drop --subscription-only for a key-only host)`);
136
+ }
137
+ log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
138
+ }
139
+ else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
140
+ throw new Error(`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
141
+ `overrides your subscription auth, so children would bill per-token API rates. ` +
142
+ `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
143
+ `child envs, or pass --allow-api-billing if API billing is intended`);
144
+ }
145
+ else if (probe.mode === "api-key") {
146
+ log(`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
147
+ `children bill per-token rates`);
148
+ }
149
+ else if (probe.mode === "api-key-override") {
150
+ log(`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`);
151
+ }
152
+ else {
153
+ log(`[billing] ${harness}: subscription login`);
154
+ }
155
+ }
156
+ if (agentsSpawned >= maxAgents) {
157
+ throw new Error(`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`);
158
+ }
159
+ agentsSpawned++;
160
+ await acquire();
161
+ try {
162
+ journal("agent.start", { id, label, key, harness, model: agentOpts.model ?? null });
163
+ log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${harness}] ${label}`);
164
+ let attemptPrompt = prompt;
165
+ let last = null;
166
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
167
+ last = await spawner({
168
+ prompt: attemptPrompt,
169
+ model: agentOpts.model,
170
+ timeoutMs: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
171
+ maxTurns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
172
+ cwd,
173
+ runId,
174
+ subscriptionOnly: opts.subscriptionOnly,
175
+ });
176
+ costUsd += last.costUsd ?? 0;
177
+ if (!last.ok) {
178
+ journal("agent.attempt_failed", { id, attempt, error: last.error });
179
+ continue; // spawn-level failure: retry with the original prompt
180
+ }
181
+ if (!agentOpts.schema) {
182
+ journal("agent.end", {
183
+ id,
184
+ key,
185
+ attempts: attempt,
186
+ cost_usd: last.costUsd,
187
+ duration_ms: last.durationMs,
188
+ session_id: last.sessionId,
189
+ result_kind: "text",
190
+ result: last.text,
191
+ });
192
+ return last.text;
193
+ }
194
+ const parsed = parseStageOutput(last.text);
195
+ const problems = parsed.error !== undefined
196
+ ? [parsed.error]
197
+ : validateAgainstSchema(parsed.value, agentOpts.schema);
198
+ if (problems.length === 0) {
199
+ journal("agent.end", {
200
+ id,
201
+ key,
202
+ attempts: attempt,
203
+ cost_usd: last.costUsd,
204
+ duration_ms: last.durationMs,
205
+ session_id: last.sessionId,
206
+ result_kind: "json",
207
+ result: parsed.value,
208
+ });
209
+ return parsed.value;
210
+ }
211
+ journal("agent.schema_retry", { id, attempt, problems });
212
+ // Feed the validation failure back verbatim — the retry prompt carries
213
+ // exactly what was wrong, which is what makes bounded retry converge.
214
+ attemptPrompt =
215
+ `${prompt}\n\nYour previous reply failed validation:\n` +
216
+ `${problems.map((p) => ` - ${p}`).join("\n")}\n` +
217
+ `Reply with ONLY the corrected JSON object. No prose, no code fences.`;
218
+ }
219
+ const reason = last?.ok
220
+ ? `schema validation failed after ${maxAttempts} attempt(s)`
221
+ : (last?.error ?? "spawn failed");
222
+ journal("agent.failed", { id, error: reason });
223
+ throw new Error(`agent ${id} (${label}): ${reason}`);
224
+ }
225
+ finally {
226
+ release();
227
+ }
228
+ };
229
+ const parallel = async (thunks) => {
230
+ // Fire everything; the shared slot pool inside agent() bounds real
231
+ // concurrency. A rejected thunk lands as null so one bad item can't kill
232
+ // the batch — the script filters and routes.
233
+ return Promise.all(thunks.map((t) => t().catch((err) => {
234
+ journal("parallel.item_failed", { error: err.message });
235
+ return null;
236
+ })));
237
+ };
238
+ const stage = (title) => {
239
+ currentStage = title;
240
+ journal("stage.start", { title });
241
+ log(`[${name}] ── stage: ${title}`);
242
+ };
243
+ const ctx = { agent, parallel, stage, log };
244
+ const t0 = Date.now();
245
+ journal("run.start", { name, script: absScript, max_agents: maxAgents, concurrency });
246
+ try {
247
+ const result = await mod.default(ctx);
248
+ const report = {
249
+ runId,
250
+ name,
251
+ result,
252
+ agentsSpawned,
253
+ agentsCached,
254
+ costUsd: round4(costUsd),
255
+ durationMs: Date.now() - t0,
256
+ journalPath,
257
+ contextTokensPerChildEstimate,
258
+ billing: Array.from(billingProbed.values()).map((p) => ({
259
+ harness: p.harness,
260
+ mode: opts.subscriptionOnly ? "subscription" : p.mode,
261
+ })),
262
+ };
263
+ journal("run.end", {
264
+ ok: true,
265
+ agents: agentsSpawned,
266
+ cached: agentsCached,
267
+ cost_usd: report.costUsd,
268
+ duration_ms: report.durationMs,
269
+ });
270
+ return report;
271
+ }
272
+ catch (err) {
273
+ journal("run.end", {
274
+ ok: false,
275
+ error: err.message,
276
+ agents: agentsSpawned,
277
+ cached: agentsCached,
278
+ cost_usd: round4(costUsd),
279
+ });
280
+ throw err;
281
+ }
282
+ }
283
+ /** Stable identity for one agent() call, for the resume cache. The ORIGINAL
284
+ * prompt (never a retry-mutated one) plus everything that changes behavior. */
285
+ function agentCallKey(stage, harness, agentOpts, prompt) {
286
+ const basis = JSON.stringify([
287
+ stage,
288
+ harness,
289
+ agentOpts.model ?? null,
290
+ agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
291
+ agentOpts.schema ?? null,
292
+ prompt,
293
+ ]);
294
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
295
+ }
296
+ /** Per-child fixed context overhead: the repo-instructions file at the child
297
+ * cwd (CLAUDE.md preferred, AGENTS.md fallback) is loaded into every child's
298
+ * system prompt. bytes/4 token heuristic; 0 when neither file exists. */
299
+ function estimateInstructionTokens(cwd) {
300
+ for (const f of ["CLAUDE.md", "AGENTS.md"]) {
301
+ const p = join(cwd, f);
302
+ if (existsSync(p)) {
303
+ try {
304
+ return Math.round(statSync(p).size / 4);
305
+ }
306
+ catch {
307
+ return 0;
308
+ }
309
+ }
310
+ }
311
+ return 0;
312
+ }
313
+ /** Load a prior run's journal into a key → result map. Only `agent.end`
314
+ * entries (completed, validated) are resumable; failed or retried-out agents
315
+ * re-run live. Unreadable journal → error (a typo'd run id should fail loud,
316
+ * not silently run everything fresh). */
317
+ function loadResumeCache(coordRoot, resumeFrom) {
318
+ const path = join(coordRoot, ".harnery", "workflows", resumeFrom, "journal.jsonl");
319
+ if (!existsSync(path)) {
320
+ throw new Error(`--resume-from ${resumeFrom}: no journal at ${path}`);
321
+ }
322
+ const cache = new Map();
323
+ for (const line of readFileSync(path, "utf8").split("\n")) {
324
+ if (!line.trim())
325
+ continue;
326
+ try {
327
+ const e = JSON.parse(line);
328
+ if (e.event === "agent.end" && e.key && e.result_kind !== undefined) {
329
+ cache.set(e.key, { kind: e.result_kind, value: e.result });
330
+ }
331
+ }
332
+ catch {
333
+ /* skip malformed */
334
+ }
335
+ }
336
+ return cache;
337
+ }
338
+ function round4(n) {
339
+ return Math.round(n * 10_000) / 10_000;
340
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Per-harness CLI metadata: binary names plus install/login hints, shared by
3
+ * the spawn adapters (a not-found error should say how to fix it, not just
4
+ * that it happened) and `harn doctor`'s workflow-harness checks.
5
+ *
6
+ * Install commands are the vendors' official one-liners; they drift rarely
7
+ * but they do drift — keep this module the single place they live.
8
+ */
9
+ import type { HarnessName } from "./types.js";
10
+ export declare const HARNESS_BINARIES: Record<HarnessName, string>;
11
+ export declare const HARNESS_INSTALL_HINTS: Record<HarnessName, string>;
12
+ /** How to authenticate each CLI with a subscription login (the billing
13
+ * default — see billing.ts). */
14
+ export declare const HARNESS_LOGIN_HINTS: Record<HarnessName, string>;
15
+ /** One-line "it's missing, here's the fix" string for spawn adapters. */
16
+ export declare function notFoundError(harness: HarnessName): string;
17
+ //# sourceMappingURL=harnesses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harnesses.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/harnesses.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAIxD,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAI7D,CAAC;AAEF;gCACgC;AAChC,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAI3D,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAK1D"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Per-harness CLI metadata: binary names plus install/login hints, shared by
3
+ * the spawn adapters (a not-found error should say how to fix it, not just
4
+ * that it happened) and `harn doctor`'s workflow-harness checks.
5
+ *
6
+ * Install commands are the vendors' official one-liners; they drift rarely
7
+ * but they do drift — keep this module the single place they live.
8
+ */
9
+ export const HARNESS_BINARIES = {
10
+ "claude-code": "claude",
11
+ codex: "codex",
12
+ cursor: "cursor-agent",
13
+ };
14
+ export const HARNESS_INSTALL_HINTS = {
15
+ "claude-code": "npm install -g @anthropic-ai/claude-code",
16
+ codex: "npm install -g @openai/codex",
17
+ cursor: "curl https://cursor.com/install -fsS | bash",
18
+ };
19
+ /** How to authenticate each CLI with a subscription login (the billing
20
+ * default — see billing.ts). */
21
+ export const HARNESS_LOGIN_HINTS = {
22
+ "claude-code": "run `claude` and use /login",
23
+ codex: "codex login",
24
+ cursor: "cursor-agent login",
25
+ };
26
+ /** One-line "it's missing, here's the fix" string for spawn adapters. */
27
+ export function notFoundError(harness) {
28
+ return (`${HARNESS_BINARIES[harness]} CLI not found on PATH; ` +
29
+ `install: ${HARNESS_INSTALL_HINTS[harness]} then authenticate: ${HARNESS_LOGIN_HINTS[harness]}`);
30
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * claude-code spawn adapter: runs one subagent as a headless `claude -p`
3
+ * subprocess with `--output-format json` and unwraps the result envelope.
4
+ *
5
+ * Two hard-won rules from the Phase 1 spike, both load-bearing:
6
+ *
7
+ * 1. **Scrub inherited `CLAUDE*` env vars — delete, don't blank.** A workflow
8
+ * launched from inside a Claude Code session inherits session env that makes
9
+ * the nested CLI exit 1 with empty output. Setting a var to "" still reads
10
+ * as set; only deletion works.
11
+ * 2. **Mark the child as a workflow child instead of disabling hooks.** With
12
+ * the host repo's hooks active, the coordination Stop hook blocks a headless
13
+ * child for skipping the end-of-turn ritual (observed: num_turns burned on
14
+ * re-prompts → error_max_turns). `--settings '{"disableAllHooks":true}'`
15
+ * fixes that but also kills the coord capture that makes workflow children
16
+ * visible to peers — the point of running them under harnery. So the child
17
+ * gets HARNERY_WORKFLOW_CHILD=1 and the stop-hook rule exempts it
18
+ * (stop-hook.ts), keeping heartbeats + events on.
19
+ */
20
+ import type { Spawner } from "./types.js";
21
+ export declare const claudeCodeSpawner: Spawner;
22
+ //# sourceMappingURL=spawn-claude.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-claude.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/spawn-claude.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAA6B,MAAM,YAAY,CAAC;AAYrE,eAAO,MAAM,iBAAiB,EAAE,OA+D/B,CAAC"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * claude-code spawn adapter: runs one subagent as a headless `claude -p`
3
+ * subprocess with `--output-format json` and unwraps the result envelope.
4
+ *
5
+ * Two hard-won rules from the Phase 1 spike, both load-bearing:
6
+ *
7
+ * 1. **Scrub inherited `CLAUDE*` env vars — delete, don't blank.** A workflow
8
+ * launched from inside a Claude Code session inherits session env that makes
9
+ * the nested CLI exit 1 with empty output. Setting a var to "" still reads
10
+ * as set; only deletion works.
11
+ * 2. **Mark the child as a workflow child instead of disabling hooks.** With
12
+ * the host repo's hooks active, the coordination Stop hook blocks a headless
13
+ * child for skipping the end-of-turn ritual (observed: num_turns burned on
14
+ * re-prompts → error_max_turns). `--settings '{"disableAllHooks":true}'`
15
+ * fixes that but also kills the coord capture that makes workflow children
16
+ * visible to peers — the point of running them under harnery. So the child
17
+ * gets HARNERY_WORKFLOW_CHILD=1 and the stop-hook rule exempts it
18
+ * (stop-hook.ts), keeping heartbeats + events on.
19
+ */
20
+ import { exec } from "../../lib/exec.js";
21
+ import { buildChildEnv } from "./child-env.js";
22
+ import { notFoundError } from "./harnesses.js";
23
+ export const claudeCodeSpawner = async (req) => {
24
+ const t0 = Date.now();
25
+ const argv = [
26
+ "claude",
27
+ "-p",
28
+ req.prompt,
29
+ "--output-format",
30
+ "json",
31
+ "--max-turns",
32
+ String(req.maxTurns),
33
+ ];
34
+ if (req.model)
35
+ argv.push("--model", req.model);
36
+ const r = await exec(argv, {
37
+ cwd: req.cwd,
38
+ env: buildChildEnv(req.runId, { subscriptionOnly: req.subscriptionOnly }),
39
+ timeout: req.timeoutMs,
40
+ });
41
+ const durationMs = Date.now() - t0;
42
+ if (r.exitCode === 127) {
43
+ return { ok: false, text: "", durationMs, error: notFoundError("claude-code") };
44
+ }
45
+ if (r.exitCode !== 0) {
46
+ return {
47
+ ok: false,
48
+ text: "",
49
+ durationMs,
50
+ error: `claude exited ${r.exitCode}: ${(r.stderr || r.stdout).slice(0, 500)}`,
51
+ };
52
+ }
53
+ let envelope;
54
+ try {
55
+ envelope = JSON.parse(r.stdout);
56
+ }
57
+ catch {
58
+ return {
59
+ ok: false,
60
+ text: "",
61
+ durationMs,
62
+ error: `result envelope was not JSON: ${r.stdout.slice(0, 300)}`,
63
+ };
64
+ }
65
+ if (envelope.is_error) {
66
+ return {
67
+ ok: false,
68
+ text: String(envelope.result ?? ""),
69
+ sessionId: envelope.session_id,
70
+ costUsd: envelope.total_cost_usd,
71
+ durationMs,
72
+ error: `harness error (${envelope.subtype ?? "unknown"}): ${(envelope.errors ?? []).join("; ") || "see envelope"}`,
73
+ };
74
+ }
75
+ return {
76
+ ok: true,
77
+ text: String(envelope.result ?? ""),
78
+ sessionId: envelope.session_id,
79
+ costUsd: envelope.total_cost_usd,
80
+ durationMs,
81
+ };
82
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * codex spawn adapter: runs one subagent as a headless `codex exec`
3
+ * subprocess.
4
+ *
5
+ * Contract notes (LIVE-VERIFIED 2026-07-16 against codex-cli 0.144.5: flags
6
+ * present, schema-gated triage + text stages round-trip via `--harness codex`):
7
+ * - `codex exec "<prompt>"` is the non-interactive mode.
8
+ * - The final assistant message is captured via `--output-last-message <file>`
9
+ * (a temp file), which is far more drift-tolerant than parsing the
10
+ * experimental `--json` JSONL event stream.
11
+ * - `--skip-git-repo-check` keeps non-repo cwds working; `--sandbox
12
+ * workspace-write` matches workflow-stage expectations (children may edit).
13
+ * - No per-run cost or session-id surface in this mode → both left undefined.
14
+ * - No max-turns equivalent → `maxTurns` is accepted and ignored (documented
15
+ * in the CLI docs page).
16
+ */
17
+ import type { Spawner } from "./types.js";
18
+ export declare const codexSpawner: Spawner;
19
+ //# sourceMappingURL=spawn-codex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-codex.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/spawn-codex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AASH,OAAO,KAAK,EAAE,OAAO,EAA6B,MAAM,YAAY,CAAC;AAErE,eAAO,MAAM,YAAY,EAAE,OA+C1B,CAAC"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * codex spawn adapter: runs one subagent as a headless `codex exec`
3
+ * subprocess.
4
+ *
5
+ * Contract notes (LIVE-VERIFIED 2026-07-16 against codex-cli 0.144.5: flags
6
+ * present, schema-gated triage + text stages round-trip via `--harness codex`):
7
+ * - `codex exec "<prompt>"` is the non-interactive mode.
8
+ * - The final assistant message is captured via `--output-last-message <file>`
9
+ * (a temp file), which is far more drift-tolerant than parsing the
10
+ * experimental `--json` JSONL event stream.
11
+ * - `--skip-git-repo-check` keeps non-repo cwds working; `--sandbox
12
+ * workspace-write` matches workflow-stage expectations (children may edit).
13
+ * - No per-run cost or session-id surface in this mode → both left undefined.
14
+ * - No max-turns equivalent → `maxTurns` is accepted and ignored (documented
15
+ * in the CLI docs page).
16
+ */
17
+ import { randomBytes } from "node:crypto";
18
+ import { existsSync, readFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { exec } from "../../lib/exec.js";
22
+ import { buildChildEnv } from "./child-env.js";
23
+ import { notFoundError } from "./harnesses.js";
24
+ export const codexSpawner = async (req) => {
25
+ const t0 = Date.now();
26
+ const outFile = join(tmpdir(), `harnery-codex-${process.pid}-${randomBytes(4).toString("hex")}.txt`);
27
+ const argv = [
28
+ "codex",
29
+ "exec",
30
+ req.prompt,
31
+ "--output-last-message",
32
+ outFile,
33
+ "--skip-git-repo-check",
34
+ "--sandbox",
35
+ "workspace-write",
36
+ ];
37
+ if (req.model)
38
+ argv.push("--model", req.model);
39
+ try {
40
+ const r = await exec(argv, {
41
+ cwd: req.cwd,
42
+ env: buildChildEnv(req.runId, { subscriptionOnly: req.subscriptionOnly }),
43
+ timeout: req.timeoutMs,
44
+ });
45
+ const durationMs = Date.now() - t0;
46
+ if (r.exitCode === 127) {
47
+ return { ok: false, text: "", durationMs, error: notFoundError("codex") };
48
+ }
49
+ if (r.exitCode !== 0) {
50
+ return {
51
+ ok: false,
52
+ text: "",
53
+ durationMs,
54
+ error: `codex exited ${r.exitCode}: ${(r.stderr || r.stdout).slice(0, 500)}`,
55
+ };
56
+ }
57
+ if (!existsSync(outFile)) {
58
+ // Exit 0 but no last-message file: fall back to stdout (contract drift guard).
59
+ return { ok: true, text: r.stdout, durationMs };
60
+ }
61
+ return { ok: true, text: readFileSync(outFile, "utf8").trim(), durationMs };
62
+ }
63
+ finally {
64
+ rmSync(outFile, { force: true });
65
+ }
66
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * cursor spawn adapter: runs one subagent as a headless `cursor-agent -p`
3
+ * subprocess with `--output-format json`.
4
+ *
5
+ * Contract notes (LIVE-VERIFIED 2026-07-17 against cursor-agent
6
+ * 2026.07.16-899851b: schema-gated triage + text stages round-trip via
7
+ * `--harness cursor`, session_id parses from the envelope):
8
+ * - `cursor-agent -p "<prompt>" --output-format json` prints a single result
9
+ * envelope modeled on Claude Code's (`{type: "result", is_error, result,
10
+ * session_id, …}`).
11
+ * - `--trust` is required: headless runs refuse untrusted workspaces (exit 1,
12
+ * "Workspace Trust Required") — see the argv comment below.
13
+ * - Envelope drift guard: when stdout doesn't parse as JSON but the process
14
+ * exited 0, the raw stdout is returned as the reply text.
15
+ * - No per-run cost surface → undefined. No max-turns equivalent → `maxTurns`
16
+ * accepted and ignored (documented in the CLI docs page).
17
+ */
18
+ import type { Spawner } from "./types.js";
19
+ /** Exported for unit tests (no live binary to test against). */
20
+ export declare function parseCursorOutput(stdout: string): {
21
+ text: string;
22
+ sessionId?: string;
23
+ isError: boolean;
24
+ };
25
+ export declare const cursorSpawner: Spawner;
26
+ //# sourceMappingURL=spawn-cursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-cursor.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/spawn-cursor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAA6B,MAAM,YAAY,CAAC;AASrE,gEAAgE;AAChE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB,CAWA;AAED,eAAO,MAAM,aAAa,EAAE,OAwC3B,CAAC"}