@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.
- package/dist/api/client.d.ts +3 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +11 -0
- package/dist/api/types.d.ts +8 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/work-render.d.ts +124 -0
- package/dist/commands/work-render.d.ts.map +1 -0
- package/dist/commands/work-render.js +590 -0
- package/dist/commands/work.d.ts +121 -0
- package/dist/commands/work.d.ts.map +1 -0
- package/dist/commands/work.js +523 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
|
@@ -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,
|