@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.
- 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 +126 -0
- package/dist/commands/work.d.ts.map +1 -0
- package/dist/commands/work.js +536 -0
- package/dist/index.js +3 -0
- package/dist/zenorm-skills/skills/execute-task/SKILL.md +91 -25
- package/dist/zenorm-skills/skills/zenorm/SKILL.md +63 -67
- package/dist/zenorm-skills/skills/zenorm/evals/evals.json +41 -0
- package/dist/zenorm-skills/skills/zenorm/evals/stub-zenorm.sh +72 -0
- package/dist/zenorm-skills/skills/zenorm/scripts/resolve.sh +66 -0
- package/dist/zenorm-skills/skills/zenorm-sync/SKILL.md +7 -6
- package/package.json +4 -2
|
@@ -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
|
+
}
|