harnery 0.8.0 → 0.10.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 (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +37 -0
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Billing-mode probe: which auth a headless harness child will actually use.
3
+ *
4
+ * The engine never handles credentials — children are plain harness CLIs and
5
+ * authenticate however that CLI does. But the CLIs prefer an exported API key
6
+ * over a stored (subscription) login when both are present, and that override
7
+ * is almost always an accident: a sourced .env or a leftover export silently
8
+ * moves the run from subsidized subscription billing to per-token API billing.
9
+ * This probe classifies the state per harness so the engine can refuse the
10
+ * silent-override case (see decision 0015 addendum) while leaving deliberate
11
+ * key-only hosts (CI boxes with no login) working.
12
+ *
13
+ * Detection is a heuristic over well-known credential locations; when a
14
+ * location can't prove presence or absence (e.g. Claude Code stores its OAuth
15
+ * token in the macOS keychain, not a file), the state is "unknown" and the
16
+ * engine never hard-fails on it — the harness CLI itself is the final
17
+ * authority and will error loudly if truly unauthenticated.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import type { HarnessName } from "./types.ts";
24
+
25
+ /** The env var each harness CLI reads as an API key (preferred over a stored
26
+ * login when set). Shared with the child-env builder's subscription-only
27
+ * scrub. */
28
+ export const API_KEY_VARS: Record<string, string> = {
29
+ "claude-code": "ANTHROPIC_API_KEY",
30
+ codex: "OPENAI_API_KEY",
31
+ cursor: "CURSOR_API_KEY",
32
+ };
33
+
34
+ export type LoginState = "present" | "absent" | "unknown";
35
+
36
+ export type BillingMode =
37
+ /** No API key exported; the child rides the stored (subscription) login. */
38
+ | "subscription"
39
+ /** API key present, no stored login detected: a deliberate key-only host. */
40
+ | "api-key"
41
+ /** API key present AND a stored login exists: the key silently overrides
42
+ * subsidized auth. The engine refuses this unless explicitly allowed. */
43
+ | "api-key-override";
44
+
45
+ export interface BillingProbe {
46
+ harness: HarnessName;
47
+ /** The env var checked (from API_KEY_VARS), or a note when the key was
48
+ * found in a credential file instead of the environment. */
49
+ apiKeySource: string | null;
50
+ apiKeyPresent: boolean;
51
+ login: LoginState;
52
+ mode: BillingMode;
53
+ }
54
+
55
+ export interface ProbeIo {
56
+ env?: NodeJS.ProcessEnv;
57
+ home?: string;
58
+ }
59
+
60
+ export type BillingProber = (harness: HarnessName) => BillingProbe;
61
+
62
+ export function probeBilling(harness: HarnessName, io: ProbeIo = {}): BillingProbe {
63
+ const env = io.env ?? process.env;
64
+ const home = io.home ?? homedir();
65
+ const keyVar = API_KEY_VARS[harness];
66
+ const envKey = keyVar ? Boolean(env[keyVar]?.trim()) : false;
67
+
68
+ let login: LoginState;
69
+ let apiKeyPresent = envKey;
70
+ let apiKeySource: string | null = envKey ? keyVar : null;
71
+
72
+ switch (harness) {
73
+ case "claude-code":
74
+ login = probeClaudeLogin(home);
75
+ break;
76
+ case "codex": {
77
+ const codex = probeCodexAuth(env, home);
78
+ login = codex.login;
79
+ // `codex login --api-key` stores the key in auth.json rather than the
80
+ // env; that is still API-key billing and should surface as such.
81
+ if (!envKey && codex.storedApiKey) {
82
+ apiKeyPresent = true;
83
+ apiKeySource = codex.storedApiKeyNote;
84
+ }
85
+ break;
86
+ }
87
+ case "cursor":
88
+ // cursor-agent's stored-login location is not yet verified against a
89
+ // live install (adapter itself is pending verification); never claim
90
+ // presence or absence we can't prove.
91
+ login = "unknown";
92
+ break;
93
+ default:
94
+ // External adapters own their authentication. The generic engine must
95
+ // not guess credential locations or fail a plugin on a built-in-only
96
+ // heuristic.
97
+ login = "unknown";
98
+ break;
99
+ }
100
+
101
+ const mode: BillingMode =
102
+ apiKeyPresent && login === "present"
103
+ ? "api-key-override"
104
+ : apiKeyPresent
105
+ ? "api-key"
106
+ : "subscription";
107
+
108
+ return { harness, apiKeySource, apiKeyPresent, login, mode };
109
+ }
110
+
111
+ /** Claude Code: OAuth login lives at ~/.claude/.credentials.json on Linux;
112
+ * macOS stores it in the keychain (dir exists, file doesn't → unknown). */
113
+ function probeClaudeLogin(home: string): LoginState {
114
+ const dir = join(home, ".claude");
115
+ const credFile = join(dir, ".credentials.json");
116
+ if (existsSync(credFile)) {
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(credFile, "utf8")) as {
119
+ claudeAiOauth?: { accessToken?: string };
120
+ };
121
+ return parsed.claudeAiOauth?.accessToken ? "present" : "absent";
122
+ } catch {
123
+ return "unknown";
124
+ }
125
+ }
126
+ return existsSync(dir) ? "unknown" : "absent";
127
+ }
128
+
129
+ /** codex: auth lives at $CODEX_HOME/auth.json (default ~/.codex). A `tokens`
130
+ * object means a ChatGPT (subscription) login; an OPENAI_API_KEY field means
131
+ * a stored API key (`codex login --api-key`). */
132
+ function probeCodexAuth(
133
+ env: NodeJS.ProcessEnv,
134
+ home: string,
135
+ ): { login: LoginState; storedApiKey: boolean; storedApiKeyNote: string } {
136
+ const authPath = join(env.CODEX_HOME?.trim() || join(home, ".codex"), "auth.json");
137
+ const storedApiKeyNote = `${authPath} (stored key)`;
138
+ if (!existsSync(authPath)) return { login: "absent", storedApiKey: false, storedApiKeyNote };
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(authPath, "utf8")) as {
141
+ tokens?: unknown;
142
+ OPENAI_API_KEY?: string | null;
143
+ };
144
+ return {
145
+ login: parsed.tokens ? "present" : "absent",
146
+ storedApiKey: !parsed.tokens && Boolean(parsed.OPENAI_API_KEY),
147
+ storedApiKeyNote,
148
+ };
149
+ } catch {
150
+ return { login: "unknown", storedApiKey: false, storedApiKeyNote };
151
+ }
152
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Child-process environment builder shared by every spawn adapter.
3
+ *
4
+ * Rules (the first is a hard-won Phase 1 spike finding):
5
+ * 1. **Delete inherited harness-session vars, never blank them.** A nested
6
+ * harness CLI under a live session inherits vars that make it exit 1 with
7
+ * empty output; an empty-string var still reads as set. Scrub all three
8
+ * families (CLAUDE*, CODEX*, CURSOR*) regardless of which adapter spawns —
9
+ * a codex child launched from inside a Claude Code session must not
10
+ * inherit CLAUDE* either.
11
+ * 2. **The scrub targets SESSION vars, not credentials.** CURSOR_API_KEY
12
+ * matches the CURSOR* prefix but is an auth credential, so it is carved
13
+ * out and re-added — a key-only cursor host must keep working. (The other
14
+ * two key vars, ANTHROPIC_API_KEY and OPENAI_API_KEY, don't collide with
15
+ * the scrub prefixes.)
16
+ * 3. **subscriptionOnly deletes every API-key var** (see billing.ts) so the
17
+ * child can only authenticate via its stored subscription login — the
18
+ * guarantee behind `workflow run --subscription-only`.
19
+ * 4. **Mark the child as a workflow child** (HARNERY_WORKFLOW_CHILD=1): the
20
+ * stop-hook rule exempts it from the human-facing end-of-turn ritual while
21
+ * hooks stay on, keeping heartbeat + event capture.
22
+ * 5. **Stamp the run id** (HARNERY_WORKFLOW_RUN_ID) so the coord layer and
23
+ * web UI can associate child sessions with their workflow run.
24
+ */
25
+
26
+ import { API_KEY_VARS } from "./billing.ts";
27
+
28
+ const SCRUB_PREFIXES = ["CLAUDE", "CODEX", "CURSOR"];
29
+
30
+ export interface ChildEnvOpts {
31
+ /** Delete all API-key vars so children can only use stored logins. */
32
+ subscriptionOnly?: boolean;
33
+ }
34
+
35
+ export function buildChildEnv(runId?: string, opts: ChildEnvOpts = {}): Record<string, string> {
36
+ const env: Record<string, string> = {};
37
+ const keyVars = new Set<string>(Object.values(API_KEY_VARS));
38
+ for (const [k, v] of Object.entries(process.env)) {
39
+ if (v === undefined) continue;
40
+ if (opts.subscriptionOnly && keyVars.has(k)) continue;
41
+ if (SCRUB_PREFIXES.some((p) => k.startsWith(p)) && !keyVars.has(k)) continue;
42
+ env[k] = v;
43
+ }
44
+ env.HARNERY_WORKFLOW_CHILD = "1";
45
+ if (runId) env.HARNERY_WORKFLOW_RUN_ID = runId;
46
+ return env;
47
+ }
@@ -0,0 +1,403 @@
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
+
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
+ import { isAbsolute, join, resolve } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+ import { type BillingProbe, probeBilling } from "./billing.ts";
25
+ import type {
26
+ AgentOpts,
27
+ EngineOpts,
28
+ HarnessName,
29
+ RunReport,
30
+ SpawnResult,
31
+ WorkflowContext,
32
+ WorkflowModule,
33
+ } from "./types.ts";
34
+ import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
35
+
36
+ const DEFAULT_MAX_AGENTS = 50;
37
+ const DEFAULT_CONCURRENCY = 4;
38
+ const DEFAULT_MAX_ATTEMPTS = 2;
39
+ const DEFAULT_TIMEOUT_MS = 300_000;
40
+ const DEFAULT_MAX_TURNS = 25;
41
+
42
+ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport> {
43
+ const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
44
+ const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
45
+ if (typeof mod.default !== "function") {
46
+ throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
47
+ }
48
+ const name = mod.meta?.name ?? scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
49
+
50
+ const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
51
+ const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
52
+ mkdirSync(runDir, { recursive: true });
53
+ const journalPath = join(runDir, "journal.jsonl");
54
+
55
+ const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
56
+ const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
57
+ const cwd = opts.cwd ?? opts.coordRoot;
58
+ const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
59
+ const defaultHarness: HarnessName = opts.defaultHarness ?? "claude-code";
60
+
61
+ // Per-child fixed context overhead: children spawn in `cwd` and load its
62
+ // repo-instructions file into their system prompt, cache-writing it once
63
+ // per child. A fan-out multiplies this, so surface it BEFORE the burn.
64
+ const contextTokensPerChildEstimate = estimateInstructionTokens(cwd);
65
+ if (contextTokensPerChildEstimate > 0) {
66
+ log(
67
+ `[context] each child cache-writes ~${Math.round(contextTokensPerChildEstimate / 1000)}K tokens of repo ` +
68
+ `instructions from ${cwd}; a fan-out multiplies this per agent`,
69
+ );
70
+ }
71
+
72
+ // Resume: journaled results of a prior run, keyed by agent-call identity.
73
+ const resumeCache = opts.resumeFrom
74
+ ? loadResumeCache(opts.coordRoot, opts.resumeFrom)
75
+ : new Map<string, { kind: "json" | "text"; value: unknown }>();
76
+
77
+ let agentsSpawned = 0;
78
+ let agentsCached = 0;
79
+ let costUsd = 0;
80
+ let currentStage = "";
81
+ let agentSeq = 0;
82
+ const billingProbed = new Map<HarnessName, BillingProbe>();
83
+
84
+ const journal = (event: string, data: Record<string, unknown>): void => {
85
+ const line = JSON.stringify({
86
+ ts: new Date().toISOString(),
87
+ event,
88
+ stage: currentStage,
89
+ ...data,
90
+ });
91
+ appendFileSync(journalPath, `${line}\n`, "utf8");
92
+ };
93
+
94
+ // Bounded concurrency gate shared by every spawn in the run — direct
95
+ // `agent()` calls and `parallel()` thunks draw from the same slot pool, so
96
+ // the cap holds even when a script nests parallel() inside loops.
97
+ let inFlight = 0;
98
+ const waiters: Array<() => void> = [];
99
+ const acquire = async (): Promise<void> => {
100
+ if (inFlight < concurrency) {
101
+ inFlight++;
102
+ return;
103
+ }
104
+ await new Promise<void>((res) => waiters.push(res));
105
+ inFlight++;
106
+ };
107
+ const release = (): void => {
108
+ inFlight--;
109
+ waiters.shift()?.();
110
+ };
111
+
112
+ const agent = async (prompt: string, agentOpts: AgentOpts = {}): Promise<unknown> => {
113
+ const harness = agentOpts.harness ?? defaultHarness;
114
+ const spawner = opts.spawners[harness];
115
+ if (!spawner) {
116
+ throw new Error(
117
+ `no spawner registered for harness "${harness}" (registered: ${Object.keys(opts.spawners).join(", ") || "none"})`,
118
+ );
119
+ }
120
+ const id = `a${++agentSeq}`;
121
+ const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
122
+ const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
123
+
124
+ // Call identity for resume: same stage + harness + model + effort + turns + schema
125
+ // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
126
+ const key = agentCallKey(currentStage, harness, agentOpts, prompt);
127
+ const cached = resumeCache.get(key);
128
+ if (cached) {
129
+ agentsCached++;
130
+ journal("agent.cached", { id, label, key, kind: cached.kind });
131
+ log(
132
+ `[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`,
133
+ );
134
+ return cached.value;
135
+ }
136
+
137
+ // Billing safeguard: on a harness's FIRST spawn this run, classify which
138
+ // auth its children will use and refuse the silent-override state (an
139
+ // exported API key shadowing a stored subscription login) unless the
140
+ // caller explicitly opted into API billing. Cached agents never reach
141
+ // this — no spawn, no billing.
142
+ if (!billingProbed.has(harness)) {
143
+ const probe = (opts.probeBilling ?? probeBilling)(harness);
144
+ billingProbed.set(harness, probe);
145
+ journal("billing.probe", {
146
+ harness,
147
+ mode: opts.subscriptionOnly ? "subscription" : probe.mode,
148
+ api_key_source: probe.apiKeySource,
149
+ login: probe.login,
150
+ subscription_only: Boolean(opts.subscriptionOnly),
151
+ });
152
+ if (opts.subscriptionOnly) {
153
+ if (probe.login === "absent") {
154
+ throw new Error(
155
+ `subscription-only: no stored login detected for ${harness}; ` +
156
+ `log the harness CLI in (or drop --subscription-only for a key-only host)`,
157
+ );
158
+ }
159
+ log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
160
+ } else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
161
+ throw new Error(
162
+ `${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
163
+ `overrides your subscription auth, so children would bill per-token API rates. ` +
164
+ `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
165
+ `child envs, or pass --allow-api-billing if API billing is intended`,
166
+ );
167
+ } else if (probe.mode === "api-key") {
168
+ log(
169
+ `[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
170
+ `children bill per-token rates`,
171
+ );
172
+ } else if (probe.mode === "api-key-override") {
173
+ log(
174
+ `[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
175
+ );
176
+ } else {
177
+ log(`[billing] ${harness}: subscription login`);
178
+ }
179
+ }
180
+
181
+ if (agentsSpawned >= maxAgents) {
182
+ throw new Error(
183
+ `workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
184
+ );
185
+ }
186
+ agentsSpawned++;
187
+
188
+ await acquire();
189
+ try {
190
+ journal("agent.start", {
191
+ id,
192
+ label,
193
+ key,
194
+ harness,
195
+ model: agentOpts.model ?? null,
196
+ effort: agentOpts.effort ?? null,
197
+ });
198
+ log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${harness}] ${label}`);
199
+
200
+ let attemptPrompt = prompt;
201
+ let last: SpawnResult | null = null;
202
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
203
+ last = await spawner({
204
+ prompt: attemptPrompt,
205
+ model: agentOpts.model,
206
+ effort: agentOpts.effort,
207
+ timeoutMs: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
208
+ maxTurns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
209
+ cwd,
210
+ runId,
211
+ subscriptionOnly: opts.subscriptionOnly,
212
+ });
213
+ costUsd += last.costUsd ?? 0;
214
+
215
+ if (!last.ok) {
216
+ journal("agent.attempt_failed", { id, attempt, error: last.error });
217
+ continue; // spawn-level failure: retry with the original prompt
218
+ }
219
+ if (!agentOpts.schema) {
220
+ journal("agent.end", {
221
+ id,
222
+ key,
223
+ attempts: attempt,
224
+ cost_usd: last.costUsd,
225
+ duration_ms: last.durationMs,
226
+ session_id: last.sessionId,
227
+ result_kind: "text",
228
+ result: last.text,
229
+ });
230
+ return last.text;
231
+ }
232
+
233
+ const parsed = parseStageOutput(last.text);
234
+ const problems =
235
+ parsed.error !== undefined
236
+ ? [parsed.error]
237
+ : validateAgainstSchema(parsed.value, agentOpts.schema);
238
+ if (problems.length === 0) {
239
+ journal("agent.end", {
240
+ id,
241
+ key,
242
+ attempts: attempt,
243
+ cost_usd: last.costUsd,
244
+ duration_ms: last.durationMs,
245
+ session_id: last.sessionId,
246
+ result_kind: "json",
247
+ result: parsed.value,
248
+ });
249
+ return parsed.value;
250
+ }
251
+
252
+ journal("agent.schema_retry", { id, attempt, problems });
253
+ // Feed the validation failure back verbatim — the retry prompt carries
254
+ // exactly what was wrong, which is what makes bounded retry converge.
255
+ attemptPrompt =
256
+ `${prompt}\n\nYour previous reply failed validation:\n` +
257
+ `${problems.map((p) => ` - ${p}`).join("\n")}\n` +
258
+ `Reply with ONLY the corrected JSON object. No prose, no code fences.`;
259
+ }
260
+
261
+ const reason = last?.ok
262
+ ? `schema validation failed after ${maxAttempts} attempt(s)`
263
+ : (last?.error ?? "spawn failed");
264
+ journal("agent.failed", { id, error: reason });
265
+ throw new Error(`agent ${id} (${label}): ${reason}`);
266
+ } finally {
267
+ release();
268
+ }
269
+ };
270
+
271
+ const parallel = async <T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>> => {
272
+ // Fire everything; the shared slot pool inside agent() bounds real
273
+ // concurrency. A rejected thunk lands as null so one bad item can't kill
274
+ // the batch — the script filters and routes.
275
+ return Promise.all(
276
+ thunks.map((t) =>
277
+ t().catch((err: unknown) => {
278
+ journal("parallel.item_failed", { error: (err as Error).message });
279
+ return null;
280
+ }),
281
+ ),
282
+ );
283
+ };
284
+
285
+ const stage = (title: string): void => {
286
+ currentStage = title;
287
+ journal("stage.start", { title });
288
+ log(`[${name}] ── stage: ${title}`);
289
+ };
290
+
291
+ const ctx: WorkflowContext = { agent, parallel, stage, log };
292
+
293
+ const t0 = Date.now();
294
+ journal("run.start", { name, script: absScript, max_agents: maxAgents, concurrency });
295
+ try {
296
+ const result = await mod.default(ctx);
297
+ const report: RunReport = {
298
+ runId,
299
+ name,
300
+ result,
301
+ agentsSpawned,
302
+ agentsCached,
303
+ costUsd: round4(costUsd),
304
+ durationMs: Date.now() - t0,
305
+ journalPath,
306
+ contextTokensPerChildEstimate,
307
+ billing: Array.from(billingProbed.values()).map((p) => ({
308
+ harness: p.harness,
309
+ mode: opts.subscriptionOnly ? "subscription" : p.mode,
310
+ })),
311
+ };
312
+ journal("run.end", {
313
+ ok: true,
314
+ agents: agentsSpawned,
315
+ cached: agentsCached,
316
+ cost_usd: report.costUsd,
317
+ duration_ms: report.durationMs,
318
+ });
319
+ return report;
320
+ } catch (err) {
321
+ journal("run.end", {
322
+ ok: false,
323
+ error: (err as Error).message,
324
+ agents: agentsSpawned,
325
+ cached: agentsCached,
326
+ cost_usd: round4(costUsd),
327
+ });
328
+ throw err;
329
+ }
330
+ }
331
+
332
+ /** Stable identity for one agent() call, for the resume cache. The ORIGINAL
333
+ * prompt (never a retry-mutated one) plus everything that changes behavior. */
334
+ function agentCallKey(
335
+ stage: string,
336
+ harness: string,
337
+ agentOpts: AgentOpts,
338
+ prompt: string,
339
+ ): string {
340
+ const basis = JSON.stringify([
341
+ stage,
342
+ harness,
343
+ agentOpts.model ?? null,
344
+ agentOpts.effort ?? null,
345
+ agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
346
+ agentOpts.schema ?? null,
347
+ prompt,
348
+ ]);
349
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
350
+ }
351
+
352
+ /** Per-child fixed context overhead: the repo-instructions file at the child
353
+ * cwd (CLAUDE.md preferred, AGENTS.md fallback) is loaded into every child's
354
+ * system prompt. bytes/4 token heuristic; 0 when neither file exists. */
355
+ function estimateInstructionTokens(cwd: string): number {
356
+ for (const f of ["CLAUDE.md", "AGENTS.md"]) {
357
+ const p = join(cwd, f);
358
+ if (existsSync(p)) {
359
+ try {
360
+ return Math.round(statSync(p).size / 4);
361
+ } catch {
362
+ return 0;
363
+ }
364
+ }
365
+ }
366
+ return 0;
367
+ }
368
+
369
+ /** Load a prior run's journal into a key → result map. Only `agent.end`
370
+ * entries (completed, validated) are resumable; failed or retried-out agents
371
+ * re-run live. Unreadable journal → error (a typo'd run id should fail loud,
372
+ * not silently run everything fresh). */
373
+ function loadResumeCache(
374
+ coordRoot: string,
375
+ resumeFrom: string,
376
+ ): Map<string, { kind: "json" | "text"; value: unknown }> {
377
+ const path = join(coordRoot, ".harnery", "workflows", resumeFrom, "journal.jsonl");
378
+ if (!existsSync(path)) {
379
+ throw new Error(`--resume-from ${resumeFrom}: no journal at ${path}`);
380
+ }
381
+ const cache = new Map<string, { kind: "json" | "text"; value: unknown }>();
382
+ for (const line of readFileSync(path, "utf8").split("\n")) {
383
+ if (!line.trim()) continue;
384
+ try {
385
+ const e = JSON.parse(line) as {
386
+ event?: string;
387
+ key?: string;
388
+ result_kind?: "json" | "text";
389
+ result?: unknown;
390
+ };
391
+ if (e.event === "agent.end" && e.key && e.result_kind !== undefined) {
392
+ cache.set(e.key, { kind: e.result_kind, value: e.result });
393
+ }
394
+ } catch {
395
+ /* skip malformed */
396
+ }
397
+ }
398
+ return cache;
399
+ }
400
+
401
+ function round4(n: number): number {
402
+ return Math.round(n * 10_000) / 10_000;
403
+ }
@@ -0,0 +1,33 @@
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
+
10
+ import { BUILTIN_HARNESS_PROFILES } from "../harnesses/profiles.ts";
11
+ import type { HarnessName } from "./types.ts";
12
+
13
+ export const HARNESS_BINARIES: Record<string, string> = Object.fromEntries(
14
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.binary]),
15
+ );
16
+
17
+ export const HARNESS_INSTALL_HINTS: Record<string, string> = Object.fromEntries(
18
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.installHint]),
19
+ );
20
+
21
+ /** How to authenticate each CLI with a subscription login (the billing
22
+ * default — see billing.ts). */
23
+ export const HARNESS_LOGIN_HINTS: Record<string, string> = Object.fromEntries(
24
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.loginHint]),
25
+ );
26
+
27
+ /** One-line "it's missing, here's the fix" string for spawn adapters. */
28
+ export function notFoundError(harness: HarnessName): string {
29
+ const binary = HARNESS_BINARIES[harness] ?? harness;
30
+ const install = HARNESS_INSTALL_HINTS[harness] ?? "install the harness CLI";
31
+ const login = HARNESS_LOGIN_HINTS[harness] ?? "authenticate the harness CLI";
32
+ return `${binary} CLI not found on PATH; ` + `install: ${install} then authenticate: ${login}`;
33
+ }