@ze-norm/cli 0.11.3 → 0.11.5

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