@ze-norm/cli 0.11.3 → 0.12.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.
@@ -0,0 +1,126 @@
1
+ import type { SpawnOptions, ChildProcess } from "node:child_process";
2
+ import { ZenormClient } from "../api/client.js";
3
+ import type { SpecTaskRecord } from "../api/types.js";
4
+ /**
5
+ * Agents whose subprocess runner slice 3 will wire up. Validated here so an
6
+ * unknown/missing `--agent` fails loud rather than silently defaulting (project
7
+ * rule: no error-masking defaults).
8
+ */
9
+ declare const SUPPORTED_AGENTS: readonly ["codex", "claude"];
10
+ type SupportedAgent = (typeof SUPPORTED_AGENTS)[number];
11
+ /**
12
+ * A pluggable per-task runner. The work loop calls `run(task)` after a
13
+ * successful claim. The default is the real `AgentRunner` (which spawns the
14
+ * chosen `claude` / `codex` agent headlessly); tests inject fakes via
15
+ * `workCommand`'s `deps.runner`.
16
+ */
17
+ export interface TaskRunner {
18
+ readonly agentName?: string;
19
+ run(task: SpecTaskRecord): Promise<void>;
20
+ }
21
+ /**
22
+ * Build the handoff prompt that hands the agent the WHOLE SPEC the claimed task
23
+ * belongs to, so a single agent session implements every available task for that
24
+ * spec with shared context — instead of one fresh session per task. The
25
+ * `/zenorm <specId>` trigger is what the `zenorm` dispatcher SKILL.md keys on
26
+ * (spec mode → `execute-task` walks every `planned`/`todo` task in order).
27
+ *
28
+ * The claim only flips THIS one task to `active` server-side (the lease that
29
+ * proves there is work for this repo and guards against a second daemon). The
30
+ * prompt tells the agent that `task.id` is already active so spec mode picks it
31
+ * up alongside its still-`todo` siblings rather than skipping it. The agent
32
+ * completes each task itself via `zenorm task complete` — the daemon does NOT
33
+ * post outcomes.
34
+ *
35
+ * Exported for unit testing.
36
+ */
37
+ export declare function buildHandoffPrompt(task: SpecTaskRecord): string;
38
+ /**
39
+ * The headless subprocess invocation for a given agent + task. Pure (no
40
+ * side effects) so it can be unit-tested directly without spawning anything.
41
+ *
42
+ * Both agents run in their JSON *event-stream* modes so we can re-render a
43
+ * compact, interactive-style transcript (see `work-render.ts`) instead of the
44
+ * agent's raw headless output:
45
+ * - claude: `claude -p --output-format stream-json --verbose` (JSONL events).
46
+ * - codex: `codex exec --json` (JSONL events).
47
+ *
48
+ * Exported for unit testing.
49
+ */
50
+ export declare function buildAgentCommand(agent: SupportedAgent, task: SpecTaskRecord): {
51
+ command: string;
52
+ args: string[];
53
+ };
54
+ /**
55
+ * Minimal spawn signature we depend on — injectable so `AgentRunner` can be
56
+ * unit-tested without launching a real agent.
57
+ */
58
+ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
59
+ /**
60
+ * The real runner: spawns the chosen coding agent headlessly against the
61
+ * claimed task and awaits its exit.
62
+ *
63
+ * Division of responsibility (intentional): the daemon only CLAIMS the task and
64
+ * LAUNCHES + AWAITS the agent. The agent itself runs the `execute-task` skill,
65
+ * which posts the task outcome via `zenorm task complete`. So this runner does
66
+ * NOT post any outcome — it returns once the agent exits 0, and throws on a
67
+ * non-zero exit or a missing binary so the failure is never read as success.
68
+ */
69
+ export declare class AgentRunner implements TaskRunner {
70
+ private readonly agent;
71
+ private readonly spawnFn;
72
+ readonly agentName: SupportedAgent;
73
+ constructor(agent: SupportedAgent, spawnFn?: SpawnFn);
74
+ run(task: SpecTaskRecord): Promise<void>;
75
+ }
76
+ /**
77
+ * `--dry-run` runner: prints the exact headless command + handoff prompt it
78
+ * WOULD spawn, without launching the agent. Lets the user inspect the wiring
79
+ * (and verify the daemon end-to-end) without burning a real agent run.
80
+ */
81
+ export declare class DryRunRunner implements TaskRunner {
82
+ private readonly agent;
83
+ readonly agentName: SupportedAgent;
84
+ constructor(agent: SupportedAgent);
85
+ run(task: SpecTaskRecord): Promise<void>;
86
+ }
87
+ /**
88
+ * Parse `owner/name` from a git remote URL. Handles SSH
89
+ * (`git@github.com:owner/name.git`) and HTTPS
90
+ * (`https://github.com/owner/name(.git)`) forms. Trailing `.git` is stripped.
91
+ * Throws CliError on anything we can't parse — fail loud.
92
+ *
93
+ * Exported for unit testing.
94
+ */
95
+ export declare function parseGitRemote(url: string): {
96
+ owner: string;
97
+ name: string;
98
+ };
99
+ interface DaemonDeps {
100
+ runner?: TaskRunner;
101
+ client?: ZenormClient;
102
+ }
103
+ /**
104
+ * The poll loop, isolated from arg/repo resolution so it can be unit-tested
105
+ * with an injected client and runner. Returns the number of tasks processed.
106
+ */
107
+ export declare function runWorkLoop(opts: {
108
+ client: ZenormClient;
109
+ runner: TaskRunner;
110
+ repo: {
111
+ owner: string;
112
+ name: string;
113
+ };
114
+ intervalMs: number;
115
+ once: boolean;
116
+ }): Promise<number>;
117
+ /**
118
+ * `zenorm work` — claim-and-run daemon for the current repository.
119
+ *
120
+ * `deps` lets tests inject a fake client and/or runner. When omitted, a real
121
+ * ZenormClient and the real `AgentRunner` (or `DryRunRunner` under `--dry-run`)
122
+ * are wired, so the default path actually spawns the chosen agent.
123
+ */
124
+ export declare function workCommand(argv: string[], deps?: DaemonDeps): Promise<void>;
125
+ export {};
126
+ //# sourceMappingURL=work.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,EAAqB,cAAc,EAAgB,MAAM,iBAAiB,CAAC;AAgBvF;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,8BAA+B,CAAC;AACtD,KAAK,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AA0BxD;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAeD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAmB/D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,cAAc,GACnB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAsCrC;AAQD;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,CACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,YAAY,KAClB,YAAY,CAAC;AAElB;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,UAAU;IAI1C,OAAO,CAAC,QAAQ,CAAC,KAAK;IAEtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAL1B,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAGvB,KAAK,EAAE,cAAc,EAErB,OAAO,GAAE,OAAe;IAK3C,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CA0EzC;AAED;;;;GAIG;AACH,qBAAa,YAAa,YAAW,UAAU;IAGjC,OAAO,CAAC,QAAQ,CAAC,KAAK;IAFlC,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAEb,KAAK,EAAE,cAAc;IAIlD,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAOzC;AAUD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmD3E;AA4BD,UAAU,UAAU;IAClB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AA+GD;;;GAGG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CAAC,MAAM,CAAC,CA2HlB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,IAAI,CAAC,CA0Bf"}
@@ -0,0 +1,536 @@
1
+ import { parseArgs } from "node:util";
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { ZenormClient } from "../api/client.js";
4
+ import { CliError } from "../util/errors.js";
5
+ import { log } from "../util/logger.js";
6
+ import { LineSplitter, claudeRenderer, codexRenderer, daemonBanner, failureBanner, sessionSummary, statusLine, taskHeader, taskOutcome, } from "./work-render.js";
7
+ /**
8
+ * Agents whose subprocess runner slice 3 will wire up. Validated here so an
9
+ * unknown/missing `--agent` fails loud rather than silently defaulting (project
10
+ * rule: no error-masking defaults).
11
+ */
12
+ const SUPPORTED_AGENTS = ["codex", "claude"];
13
+ /**
14
+ * The AC requires ready tasks to be picked up within 10 seconds, so the poll
15
+ * interval is hard-capped at this value. Requests above the cap fail loud — we
16
+ * do NOT silently clamp.
17
+ */
18
+ const MAX_INTERVAL_SECONDS = 10;
19
+ const DEFAULT_INTERVAL_SECONDS = 5;
20
+ const HEARTBEAT_INTERVAL_MS = 30_000;
21
+ const USAGE = `Usage:
22
+ zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--interval <seconds>] [--once] [--dry-run]
23
+
24
+ Runs a daemon that claims and processes ready tasks for the current git
25
+ repository (resolved from \`git remote get-url origin\`), oldest-first, one at a
26
+ time. Each claimed task is handed off to the chosen coding agent, which runs the
27
+ bundled ZeNorm \`execute-task\` skill and posts the task outcome itself.
28
+
29
+ Options:
30
+ --agent <agent> Agent to run each task with. Required. One of: ${SUPPORTED_AGENTS.join(", ")}
31
+ --interval <seconds> Poll interval when idle. Default ${DEFAULT_INTERVAL_SECONDS}, max ${MAX_INTERVAL_SECONDS}.
32
+ --once Process at most one claim (or one idle poll) then exit.
33
+ --dry-run Print the agent command + handoff prompt each task would
34
+ run instead of spawning the agent.`;
35
+ /**
36
+ * Headless install hints, surfaced when an agent binary is missing so the
37
+ * failure is actionable rather than a bare ENOENT.
38
+ */
39
+ const AGENT_INSTALL_HINT = {
40
+ claude: "Install the Claude Code CLI and ensure `claude` is on your PATH " +
41
+ "(https://docs.anthropic.com/en/docs/claude-code).",
42
+ codex: "Install the Codex CLI and ensure `codex` is on your PATH " +
43
+ "(https://github.com/openai/codex).",
44
+ };
45
+ /**
46
+ * Build the handoff prompt that hands the agent the WHOLE SPEC the claimed task
47
+ * belongs to, so a single agent session implements every available task for that
48
+ * spec with shared context — instead of one fresh session per task. The
49
+ * `/zenorm <specId>` trigger is what the `zenorm` dispatcher SKILL.md keys on
50
+ * (spec mode → `execute-task` walks every `planned`/`todo` task in order).
51
+ *
52
+ * The claim only flips THIS one task to `active` server-side (the lease that
53
+ * proves there is work for this repo and guards against a second daemon). The
54
+ * prompt tells the agent that `task.id` is already active so spec mode picks it
55
+ * up alongside its still-`todo` siblings rather than skipping it. The agent
56
+ * completes each task itself via `zenorm task complete` — the daemon does NOT
57
+ * post outcomes.
58
+ *
59
+ * Exported for unit testing.
60
+ */
61
+ export function buildHandoffPrompt(task) {
62
+ const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
63
+ return [
64
+ `/zenorm ${task.specId}`,
65
+ "",
66
+ `Execute ALL available tasks for ZeNorm spec ${task.specId} in THIS one`,
67
+ "session (spec mode — walk every `planned`/`todo` task in list order). Do not",
68
+ "stop after a single task; drain the spec's ready work before exiting.",
69
+ `For ZeNorm CLI shell commands, use \`${zenormCli}\`; if the installed`,
70
+ "skill text shows `zenorm ...`, substitute that command with the one",
71
+ "named here for this run.",
72
+ `Task "${task.title}" (id: ${task.id}) has already been claimed and flipped to`,
73
+ "`active` server-side — do NOT re-claim it; treat it as one of the spec's",
74
+ "ready tasks and implement it alongside the rest. Implement everything",
75
+ "end-to-end in the current repository working directory using the existing",
76
+ "ZeNorm execute workflow, completing each task via the normal",
77
+ `\`${zenormCli} task complete <task-id>\` flow as you finish it. Work`,
78
+ "non-interactively; do not wait for further input.",
79
+ ].join("\n");
80
+ }
81
+ /**
82
+ * The headless subprocess invocation for a given agent + task. Pure (no
83
+ * side effects) so it can be unit-tested directly without spawning anything.
84
+ *
85
+ * Both agents run in their JSON *event-stream* modes so we can re-render a
86
+ * compact, interactive-style transcript (see `work-render.ts`) instead of the
87
+ * agent's raw headless output:
88
+ * - claude: `claude -p --output-format stream-json --verbose` (JSONL events).
89
+ * - codex: `codex exec --json` (JSONL events).
90
+ *
91
+ * Exported for unit testing.
92
+ */
93
+ export function buildAgentCommand(agent, task) {
94
+ const prompt = buildHandoffPrompt(task);
95
+ switch (agent) {
96
+ case "claude":
97
+ // stream-json requires --verbose; it does NOT make the transcript verbose
98
+ // (we render the events ourselves), it just unlocks event streaming.
99
+ //
100
+ // --permission-mode auto: the daemon runs headlessly with no TTY, so an
101
+ // interactive permission prompt can never be answered — in `default` mode
102
+ // every gated tool call (notably the ZeNorm CLI Bash calls the
103
+ // execute-task skill relies on) blocks forever. `auto` mode runs
104
+ // a background classifier that auto-approves safe actions and blocks
105
+ // destructive ones (force-push, `curl | bash`, etc.) instead of bypassing
106
+ // checks entirely (`--dangerously-skip-permissions`). In headless mode it
107
+ // never prompts; if the classifier denies too many actions the run aborts
108
+ // non-zero, which `AgentRunner` already surfaces as a task failure
109
+ // (released back to `todo`). Requires Claude Code v2.1.83+.
110
+ return {
111
+ command: "claude",
112
+ args: [
113
+ "-p",
114
+ "--output-format",
115
+ "stream-json",
116
+ "--verbose",
117
+ "--permission-mode",
118
+ "auto",
119
+ prompt,
120
+ ],
121
+ };
122
+ case "codex":
123
+ return { command: "codex", args: ["exec", "--json", prompt] };
124
+ default: {
125
+ // Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
126
+ // matching headless invocation here (no error-masking default).
127
+ const never = agent;
128
+ throw new CliError(`No headless invocation defined for agent "${String(never)}".`);
129
+ }
130
+ }
131
+ }
132
+ /** The transcript renderer for each agent's JSON event stream. */
133
+ const AGENT_RENDERER = {
134
+ claude: claudeRenderer,
135
+ codex: codexRenderer,
136
+ };
137
+ /**
138
+ * The real runner: spawns the chosen coding agent headlessly against the
139
+ * claimed task and awaits its exit.
140
+ *
141
+ * Division of responsibility (intentional): the daemon only CLAIMS the task and
142
+ * LAUNCHES + AWAITS the agent. The agent itself runs the `execute-task` skill,
143
+ * which posts the task outcome via `zenorm task complete`. So this runner does
144
+ * NOT post any outcome — it returns once the agent exits 0, and throws on a
145
+ * non-zero exit or a missing binary so the failure is never read as success.
146
+ */
147
+ export class AgentRunner {
148
+ agent;
149
+ spawnFn;
150
+ agentName;
151
+ constructor(agent,
152
+ // Injectable for tests; defaults to node:child_process spawn.
153
+ spawnFn = spawn) {
154
+ this.agent = agent;
155
+ this.spawnFn = spawnFn;
156
+ this.agentName = agent;
157
+ }
158
+ run(task) {
159
+ const { command, args } = buildAgentCommand(this.agent, task);
160
+ const renderer = AGENT_RENDERER[this.agent];
161
+ return new Promise((resolve, reject) => {
162
+ // Pipe the agent's stdout so we can re-render its JSON event stream into a
163
+ // clean transcript (work-render.ts); inherit stderr so real agent errors
164
+ // still surface; close stdin (the prompt is passed as an arg, and codex
165
+ // --json otherwise blocks reading from stdin). cwd is the current repo.
166
+ const child = this.spawnFn(command, args, {
167
+ cwd: process.cwd(),
168
+ stdio: ["ignore", "pipe", "inherit"],
169
+ });
170
+ // Render stdout line-by-line. Transcript lines go to the user's stdout so
171
+ // the work session reads like the agent's own interactive session.
172
+ const splitter = new LineSplitter();
173
+ const emit = (line) => {
174
+ process.stdout.write(`${line}\n`);
175
+ };
176
+ const renderLines = (lines) => {
177
+ for (const raw of lines) {
178
+ for (const out of renderer(raw))
179
+ emit(out);
180
+ }
181
+ };
182
+ child.stdout?.setEncoding("utf-8");
183
+ child.stdout?.on("data", (chunk) => {
184
+ renderLines(splitter.push(chunk));
185
+ });
186
+ child.stdout?.on("end", () => {
187
+ renderLines(splitter.flush());
188
+ });
189
+ let settled = false;
190
+ child.on("error", (err) => {
191
+ if (settled)
192
+ return;
193
+ settled = true;
194
+ if (err.code === "ENOENT") {
195
+ reject(new CliError(`Could not launch agent "${this.agent}": \`${command}\` was not found on PATH.\n\n` +
196
+ AGENT_INSTALL_HINT[this.agent]));
197
+ return;
198
+ }
199
+ reject(new CliError(`Failed to launch agent "${this.agent}" (\`${command}\`): ${err.message}`));
200
+ });
201
+ child.on("exit", (code, signal) => {
202
+ if (settled)
203
+ return;
204
+ settled = true;
205
+ if (code === 0) {
206
+ resolve();
207
+ return;
208
+ }
209
+ const detail = code !== null ? `exited with code ${code}` : `was terminated by signal ${signal}`;
210
+ // Fail loud — a non-zero/aborted agent run is a FAILURE, never success.
211
+ reject(new CliError(`Agent "${this.agent}" (\`${command}\`) ${detail} while running task ${shortId(task.id)}.`));
212
+ });
213
+ });
214
+ }
215
+ }
216
+ /**
217
+ * `--dry-run` runner: prints the exact headless command + handoff prompt it
218
+ * WOULD spawn, without launching the agent. Lets the user inspect the wiring
219
+ * (and verify the daemon end-to-end) without burning a real agent run.
220
+ */
221
+ export class DryRunRunner {
222
+ agent;
223
+ agentName;
224
+ constructor(agent) {
225
+ this.agent = agent;
226
+ this.agentName = agent;
227
+ }
228
+ run(task) {
229
+ const { command, args } = buildAgentCommand(this.agent, task);
230
+ log.info(`[dry-run] would spawn agent "${this.agent}" for task ${shortId(task.id)}`);
231
+ log.plain(` cwd: ${process.cwd()}`);
232
+ log.plain(` command: ${command} ${formatDryRunArgs(args)}`);
233
+ return Promise.resolve();
234
+ }
235
+ }
236
+ /**
237
+ * Render spawn args for the dry-run preview: the prompt arg is multi-line, so
238
+ * wrap it in quotes (purely cosmetic — this string is shown, never executed).
239
+ */
240
+ function formatDryRunArgs(args) {
241
+ return args.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
242
+ }
243
+ /**
244
+ * Parse `owner/name` from a git remote URL. Handles SSH
245
+ * (`git@github.com:owner/name.git`) and HTTPS
246
+ * (`https://github.com/owner/name(.git)`) forms. Trailing `.git` is stripped.
247
+ * Throws CliError on anything we can't parse — fail loud.
248
+ *
249
+ * Exported for unit testing.
250
+ */
251
+ export function parseGitRemote(url) {
252
+ const trimmed = url.trim();
253
+ if (!trimmed) {
254
+ throw new CliError("Empty git remote URL — cannot resolve the current repository.");
255
+ }
256
+ // SSH scp-like form: git@host:owner/name(.git)
257
+ // (also covers ssh://git@host/owner/name handled by the URL branch below)
258
+ const sshMatch = /^[^@]+@[^:]+:(.+)$/.exec(trimmed);
259
+ let path = null;
260
+ if (sshMatch && !trimmed.includes("://")) {
261
+ path = sshMatch[1] ?? null;
262
+ }
263
+ else {
264
+ // URL form: https://host/owner/name(.git), ssh://git@host/owner/name(.git)
265
+ try {
266
+ const parsed = new URL(trimmed);
267
+ path = parsed.pathname;
268
+ }
269
+ catch {
270
+ path = null;
271
+ }
272
+ }
273
+ if (path === null) {
274
+ throw new CliError(`Could not parse owner/name from git remote URL: "${url}".`);
275
+ }
276
+ const cleaned = path
277
+ .replace(/^\/+/, "")
278
+ .replace(/\.git$/, "")
279
+ .replace(/\/+$/, "");
280
+ const segments = cleaned.split("/").filter((s) => s.length > 0);
281
+ if (segments.length < 2) {
282
+ throw new CliError(`Could not parse owner/name from git remote URL: "${url}".`);
283
+ }
284
+ // owner/name are the LAST two segments (handles nested groups / extra path).
285
+ const name = segments[segments.length - 1];
286
+ const owner = segments[segments.length - 2];
287
+ if (!owner || !name) {
288
+ throw new CliError(`Could not parse owner/name from git remote URL: "${url}".`);
289
+ }
290
+ return { owner, name };
291
+ }
292
+ /** Resolve the current repo's owner/name from the `origin` remote. */
293
+ function resolveCurrentRepo() {
294
+ let remoteUrl;
295
+ try {
296
+ remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
297
+ encoding: "utf-8",
298
+ stdio: ["ignore", "pipe", "ignore"],
299
+ }).trim();
300
+ }
301
+ catch {
302
+ throw new CliError("Not in a git repository with an `origin` remote.\n\n" +
303
+ "`zenorm work` scopes to the current repository. Run it from inside a git " +
304
+ "repo whose `origin` remote points at the GitHub repository you want to work.");
305
+ }
306
+ return parseGitRemote(remoteUrl);
307
+ }
308
+ function shortId(id) {
309
+ return id.length > 8 ? id.slice(0, 8) : id;
310
+ }
311
+ function sleep(ms) {
312
+ return new Promise((resolve) => setTimeout(resolve, ms));
313
+ }
314
+ function parseWorkArgs(argv) {
315
+ let values;
316
+ try {
317
+ ({ values } = parseArgs({
318
+ args: argv,
319
+ options: {
320
+ agent: { type: "string" },
321
+ interval: { type: "string" },
322
+ once: { type: "boolean" },
323
+ "dry-run": { type: "boolean" },
324
+ },
325
+ strict: true,
326
+ }));
327
+ }
328
+ catch (err) {
329
+ const message = err instanceof Error ? err.message : String(err);
330
+ throw new CliError(`Invalid zenorm work arguments: ${message}\n\n${USAGE}`);
331
+ }
332
+ const agent = values["agent"];
333
+ if (!agent) {
334
+ throw new CliError(`Missing --agent flag.\n\nSupported agents: ${SUPPORTED_AGENTS.join(", ")}\n\n${USAGE}`);
335
+ }
336
+ if (!SUPPORTED_AGENTS.includes(agent)) {
337
+ throw new CliError(`Unsupported agent "${agent}".\n\nSupported agents: ${SUPPORTED_AGENTS.join(", ")}`);
338
+ }
339
+ let intervalSeconds = DEFAULT_INTERVAL_SECONDS;
340
+ const rawInterval = values["interval"];
341
+ if (rawInterval !== undefined) {
342
+ const parsed = Number(rawInterval);
343
+ if (!Number.isFinite(parsed) || parsed <= 0) {
344
+ throw new CliError(`Invalid --interval "${rawInterval}". Must be a positive number of seconds (max ${MAX_INTERVAL_SECONDS}).`);
345
+ }
346
+ if (parsed > MAX_INTERVAL_SECONDS) {
347
+ throw new CliError(`--interval ${parsed} exceeds the maximum of ${MAX_INTERVAL_SECONDS} seconds. ` +
348
+ `Ready tasks must be picked up within ${MAX_INTERVAL_SECONDS}s.`);
349
+ }
350
+ intervalSeconds = parsed;
351
+ }
352
+ return {
353
+ // Validated above against SUPPORTED_AGENTS; the readonly-tuple `includes`
354
+ // check above does not narrow `string`, so assert the proven type here.
355
+ agent: agent,
356
+ intervalMs: intervalSeconds * 1000,
357
+ once: values["once"] === true,
358
+ dryRun: values["dry-run"] === true,
359
+ };
360
+ }
361
+ /** Write daemon-only meta output (e.g. the dry-run banner) to stderr. */
362
+ function writeDaemonLine(line) {
363
+ process.stderr.write(`${line}\n`);
364
+ }
365
+ async function releaseTaskToTodo(client, task) {
366
+ try {
367
+ await client.patch(`/v1/tasks/${task.id}`, { status: "todo" });
368
+ }
369
+ catch (err) {
370
+ log.warn(`Could not release task ${shortId(task.id)} back to todo`, {
371
+ error: err instanceof Error ? err.message : String(err),
372
+ });
373
+ }
374
+ }
375
+ async function heartbeatTask(client, task) {
376
+ try {
377
+ await client.post(`/v1/tasks/${task.id}/heartbeat`, {});
378
+ }
379
+ catch (err) {
380
+ log.warn(`Task ${shortId(task.id)} heartbeat failed`, {
381
+ error: err instanceof Error ? err.message : String(err),
382
+ });
383
+ }
384
+ }
385
+ async function runTaskWithHeartbeat(client, runner, task) {
386
+ const interval = setInterval(() => {
387
+ void heartbeatTask(client, task);
388
+ }, HEARTBEAT_INTERVAL_MS);
389
+ try {
390
+ await runner.run(task);
391
+ }
392
+ finally {
393
+ clearInterval(interval);
394
+ }
395
+ }
396
+ /**
397
+ * The poll loop, isolated from arg/repo resolution so it can be unit-tested
398
+ * with an injected client and runner. Returns the number of tasks processed.
399
+ */
400
+ export async function runWorkLoop(opts) {
401
+ const { client, runner, repo, intervalMs, once } = opts;
402
+ let processed = 0;
403
+ let failed = 0;
404
+ let started = 0;
405
+ let stopping = false;
406
+ // Track whether we're currently in the idle/polling state so the "polling…"
407
+ // status line is printed once per idle stretch, not once per poll.
408
+ let announcedIdle = false;
409
+ const onSigint = () => {
410
+ // Request a graceful stop; an in-flight runner.run call finishes first.
411
+ stopping = true;
412
+ log.warn("Received SIGINT — finishing the active task and shutting down...");
413
+ };
414
+ process.on("SIGINT", onSigint);
415
+ // Claim the next ready task. Returns the task, or null when nothing is
416
+ // claimable (or a transient claim error — the daemon survives those and
417
+ // retries after the interval).
418
+ const claimNext = async () => {
419
+ let response;
420
+ try {
421
+ response = await client.post("/v1/tasks/claim", {
422
+ owner: repo.owner,
423
+ name: repo.name,
424
+ });
425
+ }
426
+ catch (err) {
427
+ // A daemon must survive a transient API blip — warn and retry after the
428
+ // interval. (Fatal arg/git errors are thrown before the loop starts.)
429
+ log.warn(`Claim request failed, retrying after ${intervalMs / 1000}s`, { error: err instanceof Error ? err.message : String(err) });
430
+ return null;
431
+ }
432
+ return response.task ?? null;
433
+ };
434
+ // Run one claimed task's SPEC to completion. The claim flips a single task to
435
+ // `active` to prove there is ready work for this repo; the agent it launches
436
+ // then drains every available task of that spec in ONE session (spec mode —
437
+ // see buildHandoffPrompt). The agent's own transcript (its tool calls + prose,
438
+ // re-rendered by work-render.ts) IS the body of the output; the daemon frames
439
+ // it with a numbered header (the claimed entry task) and closes with a ✓/✗
440
+ // outcome line (+ a loud banner on failure). On failure the entry task is
441
+ // released back to `todo` so the spec can be re-claimed; tasks the agent
442
+ // already completed stay `done`.
443
+ const runTask = async (task) => {
444
+ started += 1;
445
+ const agentName = runner.agentName ?? "agent";
446
+ const short = shortId(task.id);
447
+ process.stdout.write(`${taskHeader(short, task.title, agentName, started)}\n`);
448
+ const startedAt = Date.now();
449
+ try {
450
+ await runTaskWithHeartbeat(client, runner, task);
451
+ }
452
+ catch (err) {
453
+ const durationMs = Date.now() - startedAt;
454
+ const message = err instanceof Error ? err.message : String(err);
455
+ await releaseTaskToTodo(client, task);
456
+ failed += 1;
457
+ // Loud, framed failure (banner) + the daemon's own ✗ outcome line so a
458
+ // failure is impossible to miss when scrolling a long daemon log.
459
+ process.stdout.write(`${failureBanner({ shortTaskId: short, title: task.title, error: message })}\n`);
460
+ process.stdout.write(`${taskOutcome({ ok: false, shortTaskId: short, title: task.title, durationMs })}\n`);
461
+ return;
462
+ }
463
+ process.stdout.write(`${taskOutcome({
464
+ ok: true,
465
+ shortTaskId: short,
466
+ title: task.title,
467
+ durationMs: Date.now() - startedAt,
468
+ })}\n`);
469
+ processed += 1;
470
+ };
471
+ let claimedOnce = false;
472
+ try {
473
+ while (!stopping) {
474
+ // `--once` processes at most one claim (or one idle poll) then exits.
475
+ if (once && claimedOnce)
476
+ break;
477
+ claimedOnce = true;
478
+ const task = await claimNext();
479
+ if (task) {
480
+ announcedIdle = false;
481
+ await runTask(task);
482
+ if (once)
483
+ break;
484
+ // Claim the next task immediately — no idle wait while work remains.
485
+ continue;
486
+ }
487
+ // No claimable work right now. Announce the idle state ONCE per idle
488
+ // stretch (not once per poll, which would spam), then wait and re-poll —
489
+ // so the daemon visibly says "waiting" instead of going silent.
490
+ if (once)
491
+ break;
492
+ if (!announcedIdle) {
493
+ process.stdout.write(`${statusLine(`no ready tasks — polling every ${intervalMs / 1000}s`)}\n`);
494
+ announcedIdle = true;
495
+ }
496
+ await sleep(intervalMs);
497
+ }
498
+ }
499
+ finally {
500
+ process.removeListener("SIGINT", onSigint);
501
+ // Close the run with a tally so the user sees the session total instead of
502
+ // counting outcome lines. Skipped under --once (single-shot, no session).
503
+ if (!once) {
504
+ process.stdout.write(`${sessionSummary({ completed: processed, failed })}\n`);
505
+ }
506
+ }
507
+ return processed;
508
+ }
509
+ /**
510
+ * `zenorm work` — claim-and-run daemon for the current repository.
511
+ *
512
+ * `deps` lets tests inject a fake client and/or runner. When omitted, a real
513
+ * ZenormClient and the real `AgentRunner` (or `DryRunRunner` under `--dry-run`)
514
+ * are wired, so the default path actually spawns the chosen agent.
515
+ */
516
+ export async function workCommand(argv, deps) {
517
+ // Fatal (CliError) failures — bad args, not a git repo — abort BEFORE the
518
+ // loop starts so they surface immediately rather than being swallowed as a
519
+ // transient blip.
520
+ const { agent, intervalMs, once, dryRun } = parseWorkArgs(argv);
521
+ const repo = resolveCurrentRepo();
522
+ const client = deps?.client ?? new ZenormClient();
523
+ const runner = deps?.runner ?? (dryRun ? new DryRunRunner(agent) : new AgentRunner(agent));
524
+ // Startup banner — shown on EVERY run (not just --dry-run) so a real session
525
+ // announces what it's claiming for instead of sitting silent until the first
526
+ // task lands. Written to stderr so it never interleaves with the agent
527
+ // transcript when stdout is piped to a file.
528
+ writeDaemonLine(daemonBanner({
529
+ agent,
530
+ repo: `${repo.owner}/${repo.name}`,
531
+ intervalSeconds: intervalMs / 1000,
532
+ once,
533
+ dryRun,
534
+ }));
535
+ await runWorkLoop({ client, runner, repo, intervalMs, once });
536
+ }