@ze-norm/cli 0.11.5 → 0.13.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/commands/interactive-agent.d.ts +92 -0
- package/dist/commands/interactive-agent.d.ts.map +1 -0
- package/dist/commands/interactive-agent.js +120 -0
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +23 -2
- package/dist/commands/work-render.d.ts +22 -0
- package/dist/commands/work-render.d.ts.map +1 -1
- package/dist/commands/work-render.js +33 -0
- package/dist/commands/work.d.ts +128 -9
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +420 -57
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +6 -0
- package/dist/util/markdown.d.ts +16 -0
- package/dist/util/markdown.d.ts.map +1 -1
- package/dist/util/markdown.js +34 -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 +1 -1
package/dist/commands/work.js
CHANGED
|
@@ -3,7 +3,9 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
3
3
|
import { ZenormClient } from "../api/client.js";
|
|
4
4
|
import { CliError } from "../util/errors.js";
|
|
5
5
|
import { log } from "../util/logger.js";
|
|
6
|
-
import {
|
|
6
|
+
import { loadConfig } from "../config/loader.js";
|
|
7
|
+
import { InteractiveAgent, assertUsableTty, processTtyProbe, } from "./interactive-agent.js";
|
|
8
|
+
import { LineSplitter, claudeRenderer, codexRenderer, daemonBanner, failureBanner, postAgentSummary, sessionSummary, statusLine, taskHeader, taskOutcome, } from "./work-render.js";
|
|
7
9
|
/**
|
|
8
10
|
* Agents whose subprocess runner slice 3 will wire up. Validated here so an
|
|
9
11
|
* unknown/missing `--agent` fails loud rather than silently defaulting (project
|
|
@@ -19,7 +21,7 @@ const MAX_INTERVAL_SECONDS = 10;
|
|
|
19
21
|
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
20
22
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
21
23
|
const USAGE = `Usage:
|
|
22
|
-
zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--interval <seconds>] [--once] [--dry-run]
|
|
24
|
+
zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--model <model>] [--interval <seconds>] [--once] [--dry-run] [--interactive]
|
|
23
25
|
|
|
24
26
|
Runs a daemon that claims and processes ready tasks for the current git
|
|
25
27
|
repository (resolved from \`git remote get-url origin\`), oldest-first, one at a
|
|
@@ -28,10 +30,23 @@ bundled ZeNorm \`execute-task\` skill and posts the task outcome itself.
|
|
|
28
30
|
|
|
29
31
|
Options:
|
|
30
32
|
--agent <agent> Agent to run each task with. Required. One of: ${SUPPORTED_AGENTS.join(", ")}
|
|
33
|
+
--model <model> Model the agent runs each task with (alias e.g. opus,
|
|
34
|
+
sonnet, or a full model id). Optional; when omitted the
|
|
35
|
+
agent uses its own configured default. For claude this
|
|
36
|
+
also retargets the server-side advisor tool to the same
|
|
37
|
+
model, so a gated \`advisorModel\` no longer 400s the run.
|
|
31
38
|
--interval <seconds> Poll interval when idle. Default ${DEFAULT_INTERVAL_SECONDS}, max ${MAX_INTERVAL_SECONDS}.
|
|
32
39
|
--once Process at most one claim (or one idle poll) then exit.
|
|
33
40
|
--dry-run Print the agent command + handoff prompt each task would
|
|
34
|
-
run instead of spawning the agent
|
|
41
|
+
run instead of spawning the agent.
|
|
42
|
+
--interactive Opt into coding-agent mode: instead of running each task
|
|
43
|
+
headlessly, launch the active coding agent (the one named
|
|
44
|
+
by --agent) as a LIVE interactive session seeded with the
|
|
45
|
+
spec handoff. Work with the agent, then exit it to release
|
|
46
|
+
the worker and continue to the next task. Local terminal
|
|
47
|
+
only — requires a usable TTY and fails fast without one.
|
|
48
|
+
Can also be enabled per project via \`interactive: true\`
|
|
49
|
+
in .zenorm.json.`;
|
|
35
50
|
/**
|
|
36
51
|
* Headless install hints, surfaced when an agent binary is missing so the
|
|
37
52
|
* failure is actionable rather than a bare ENOENT.
|
|
@@ -43,31 +58,59 @@ const AGENT_INSTALL_HINT = {
|
|
|
43
58
|
"(https://github.com/openai/codex).",
|
|
44
59
|
};
|
|
45
60
|
/**
|
|
46
|
-
* Build the handoff prompt that
|
|
47
|
-
*
|
|
48
|
-
*
|
|
61
|
+
* Build the handoff prompt that hands the agent the WHOLE SPEC the claimed task
|
|
62
|
+
* belongs to, so a single agent session implements every available task for that
|
|
63
|
+
* spec with shared context — instead of one fresh session per task. The
|
|
64
|
+
* `/zenorm <specId>` trigger is what the `zenorm` dispatcher SKILL.md keys on
|
|
65
|
+
* (spec mode → `execute-task` walks every `planned`/`todo` task in order).
|
|
49
66
|
*
|
|
50
|
-
* The task
|
|
51
|
-
*
|
|
52
|
-
*
|
|
67
|
+
* The claim only flips THIS one task to `active` server-side (the lease that
|
|
68
|
+
* proves there is work for this repo and guards against a second daemon). The
|
|
69
|
+
* prompt tells the agent that `task.id` is already active so spec mode picks it
|
|
70
|
+
* up alongside its still-`todo` siblings rather than skipping it. The agent
|
|
71
|
+
* completes each task itself via `zenorm task complete` — the daemon does NOT
|
|
72
|
+
* post outcomes.
|
|
53
73
|
*
|
|
54
74
|
* Exported for unit testing.
|
|
55
75
|
*/
|
|
56
76
|
export function buildHandoffPrompt(task) {
|
|
57
77
|
const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
|
|
58
78
|
return [
|
|
59
|
-
|
|
79
|
+
...handoffPromptBody(task, zenormCli),
|
|
80
|
+
// Headless runs have no operator to wait on — tell the agent to drive
|
|
81
|
+
// straight through without pausing for input. The interactive variant
|
|
82
|
+
// (buildInteractiveHandoffPrompt) deliberately omits this line.
|
|
83
|
+
"Work non-interactively; do not wait for further input.",
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The interactive (coding-agent mode) handoff prompt. Same spec-mode handoff as
|
|
88
|
+
* the headless prompt, MINUS the "work non-interactively" instruction: in this
|
|
89
|
+
* mode the agent runs as a live session the operator drives, so it SHOULD pause
|
|
90
|
+
* for input. Exported for unit testing.
|
|
91
|
+
*/
|
|
92
|
+
export function buildInteractiveHandoffPrompt(task) {
|
|
93
|
+
const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
|
|
94
|
+
return handoffPromptBody(task, zenormCli).join("\n");
|
|
95
|
+
}
|
|
96
|
+
/** Shared spec-mode handoff lines for both the headless and interactive prompts. */
|
|
97
|
+
function handoffPromptBody(task, zenormCli) {
|
|
98
|
+
return [
|
|
99
|
+
`/zenorm ${task.specId}`,
|
|
60
100
|
"",
|
|
61
|
-
`Execute
|
|
101
|
+
`Execute ALL available tasks for ZeNorm spec ${task.specId} in THIS one`,
|
|
102
|
+
"session (spec mode — walk every `planned`/`todo` task in list order). Do not",
|
|
103
|
+
"stop after a single task; drain the spec's ready work before exiting.",
|
|
62
104
|
`For ZeNorm CLI shell commands, use \`${zenormCli}\`; if the installed`,
|
|
63
105
|
"skill text shows `zenorm ...`, substitute that command with the one",
|
|
64
106
|
"named here for this run.",
|
|
65
|
-
"
|
|
66
|
-
"re-claim it
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
107
|
+
`Task "${task.title}" (id: ${task.id}) has already been claimed and flipped to`,
|
|
108
|
+
"`active` server-side — do NOT re-claim it; treat it as one of the spec's",
|
|
109
|
+
"ready tasks and implement it alongside the rest. Implement everything",
|
|
110
|
+
"end-to-end in the current repository working directory using the existing",
|
|
111
|
+
"ZeNorm execute workflow, completing each task via the normal",
|
|
112
|
+
`\`${zenormCli} task complete <task-id>\` flow as you finish it.`,
|
|
113
|
+
];
|
|
71
114
|
}
|
|
72
115
|
/**
|
|
73
116
|
* The headless subprocess invocation for a given agent + task. Pure (no
|
|
@@ -81,7 +124,7 @@ export function buildHandoffPrompt(task) {
|
|
|
81
124
|
*
|
|
82
125
|
* Exported for unit testing.
|
|
83
126
|
*/
|
|
84
|
-
export function buildAgentCommand(agent, task) {
|
|
127
|
+
export function buildAgentCommand(agent, task, model) {
|
|
85
128
|
const prompt = buildHandoffPrompt(task);
|
|
86
129
|
switch (agent) {
|
|
87
130
|
case "claude":
|
|
@@ -107,11 +150,29 @@ export function buildAgentCommand(agent, task) {
|
|
|
107
150
|
"--verbose",
|
|
108
151
|
"--permission-mode",
|
|
109
152
|
"auto",
|
|
153
|
+
// --model sets the main session model; it does NOT retarget the
|
|
154
|
+
// server-side advisor tool, which carries its OWN model from the
|
|
155
|
+
// `advisorModel` setting. If that setting points at a model the
|
|
156
|
+
// account can't access (e.g. a gated `claude-fable-5`), EVERY turn
|
|
157
|
+
// 400s with `tools.N.model: ... is not available`. So when the user
|
|
158
|
+
// pins a model we also override `advisorModel` for THIS session via
|
|
159
|
+
// --settings, keeping the advisor on a model we know is reachable.
|
|
160
|
+
...(model
|
|
161
|
+
? [
|
|
162
|
+
"--model",
|
|
163
|
+
model,
|
|
164
|
+
"--settings",
|
|
165
|
+
JSON.stringify({ advisorModel: model }),
|
|
166
|
+
]
|
|
167
|
+
: []),
|
|
110
168
|
prompt,
|
|
111
169
|
],
|
|
112
170
|
};
|
|
113
171
|
case "codex":
|
|
114
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
command: "codex",
|
|
174
|
+
args: ["exec", "--json", ...(model ? ["-m", model] : []), prompt],
|
|
175
|
+
};
|
|
115
176
|
default: {
|
|
116
177
|
// Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
|
|
117
178
|
// matching headless invocation here (no error-masking default).
|
|
@@ -120,11 +181,112 @@ export function buildAgentCommand(agent, task) {
|
|
|
120
181
|
}
|
|
121
182
|
}
|
|
122
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The INTERACTIVE invocation for a given agent + task: launch the agent as a
|
|
186
|
+
* live session (NOT headless) seeded with the spec handoff, so the operator
|
|
187
|
+
* works with it directly in its own TUI. Pure (no side effects) so it can be
|
|
188
|
+
* unit-tested without spawning anything.
|
|
189
|
+
*
|
|
190
|
+
* Unlike `buildAgentCommand`, there is no `-p`/`exec`/`--json` and no
|
|
191
|
+
* stream-json: both agents start an interactive session by default, and the
|
|
192
|
+
* agent's own TUI renders straight to the inherited terminal (so there is no
|
|
193
|
+
* raw JSON event stream for us to re-render, and nothing to dump as raw text).
|
|
194
|
+
* - claude: `claude --permission-mode auto [--model <m>] "<prompt>"`
|
|
195
|
+
* (interactive by default; `-p` would make it headless, so it is deliberately
|
|
196
|
+
* absent). `--permission-mode auto` runs the background classifier that
|
|
197
|
+
* auto-approves safe actions and only blocks destructive ones, so the
|
|
198
|
+
* operator is not interrupted by a permission prompt on every gated tool call
|
|
199
|
+
* — same guardrail the headless path uses, just keeping the session live.
|
|
200
|
+
* - codex: `codex --ask-for-approval never --sandbox workspace-write [-m <m>]
|
|
201
|
+
* "<prompt>"` (interactive by default; the `exec` subcommand is what makes it
|
|
202
|
+
* non-interactive, so it is deliberately absent). `--ask-for-approval never`
|
|
203
|
+
* is codex's full-auto: the operator is never interrupted by an approval
|
|
204
|
+
* prompt, while `--sandbox workspace-write` still confines writes to the
|
|
205
|
+
* workspace (NOT `--dangerously-bypass-approvals-and-sandbox`, which would
|
|
206
|
+
* drop the sandbox entirely).
|
|
207
|
+
*
|
|
208
|
+
* Exported for unit testing.
|
|
209
|
+
*/
|
|
210
|
+
export function buildInteractiveAgentCommand(agent, task, model) {
|
|
211
|
+
const prompt = buildInteractiveHandoffPrompt(task);
|
|
212
|
+
switch (agent) {
|
|
213
|
+
case "claude":
|
|
214
|
+
return {
|
|
215
|
+
command: "claude",
|
|
216
|
+
args: ["--permission-mode", "auto", ...(model ? ["--model", model] : []), prompt],
|
|
217
|
+
};
|
|
218
|
+
case "codex":
|
|
219
|
+
return {
|
|
220
|
+
command: "codex",
|
|
221
|
+
args: [
|
|
222
|
+
"--ask-for-approval",
|
|
223
|
+
"never",
|
|
224
|
+
"--sandbox",
|
|
225
|
+
"workspace-write",
|
|
226
|
+
...(model ? ["-m", model] : []),
|
|
227
|
+
prompt,
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
default: {
|
|
231
|
+
// Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
|
|
232
|
+
// matching interactive invocation here (no error-masking default).
|
|
233
|
+
const never = agent;
|
|
234
|
+
throw new CliError(`No interactive invocation defined for agent "${String(never)}".`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
123
238
|
/** The transcript renderer for each agent's JSON event stream. */
|
|
124
239
|
const AGENT_RENDERER = {
|
|
125
240
|
claude: claudeRenderer,
|
|
126
241
|
codex: codexRenderer,
|
|
127
242
|
};
|
|
243
|
+
/** The real working-tree snapshot: `git status --porcelain` in the current repo. */
|
|
244
|
+
function realGitStatus() {
|
|
245
|
+
return execFileSync("git", ["status", "--porcelain"], {
|
|
246
|
+
cwd: process.cwd(),
|
|
247
|
+
encoding: "utf-8",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Parse one `git status --porcelain` line into its path. The format is a 2-char
|
|
252
|
+
* status code + a space, then the path; a rename/copy renders as `old -> new`,
|
|
253
|
+
* for which we take the NEW path (`new`). Returns null for a blank line.
|
|
254
|
+
*/
|
|
255
|
+
function porcelainPath(line) {
|
|
256
|
+
if (line.trim().length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
// Drop the 3-char status prefix ("XY ").
|
|
259
|
+
const rest = line.slice(3);
|
|
260
|
+
const arrow = rest.indexOf(" -> ");
|
|
261
|
+
return arrow >= 0 ? rest.slice(arrow + 4) : rest;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Diff two `git status --porcelain` snapshots into the set of files the agent
|
|
265
|
+
* touched during the session. We compare the porcelain LINE per path across
|
|
266
|
+
* before/after: a path is "touched" when its line differs (added, removed, or
|
|
267
|
+
* its status code changed). This deliberately IGNORES pre-existing dirt whose
|
|
268
|
+
* status did not change during the session (so unrelated stray files already in
|
|
269
|
+
* the tree don't pollute the summary). Returns a sorted, deduped path list.
|
|
270
|
+
*/
|
|
271
|
+
export function diffTouchedFiles(before, after) {
|
|
272
|
+
const index = (porcelain) => {
|
|
273
|
+
const map = new Map();
|
|
274
|
+
for (const line of porcelain.split("\n")) {
|
|
275
|
+
const path = porcelainPath(line);
|
|
276
|
+
if (path !== null)
|
|
277
|
+
map.set(path, line);
|
|
278
|
+
}
|
|
279
|
+
return map;
|
|
280
|
+
};
|
|
281
|
+
const beforeLines = index(before);
|
|
282
|
+
const afterLines = index(after);
|
|
283
|
+
const touched = new Set();
|
|
284
|
+
for (const path of new Set([...beforeLines.keys(), ...afterLines.keys()])) {
|
|
285
|
+
if (beforeLines.get(path) !== afterLines.get(path))
|
|
286
|
+
touched.add(path);
|
|
287
|
+
}
|
|
288
|
+
return [...touched].sort();
|
|
289
|
+
}
|
|
128
290
|
/**
|
|
129
291
|
* The real runner: spawns the chosen coding agent headlessly against the
|
|
130
292
|
* claimed task and awaits its exit.
|
|
@@ -138,16 +300,50 @@ const AGENT_RENDERER = {
|
|
|
138
300
|
export class AgentRunner {
|
|
139
301
|
agent;
|
|
140
302
|
spawnFn;
|
|
303
|
+
model;
|
|
304
|
+
interactive;
|
|
305
|
+
gitStatus;
|
|
306
|
+
fetchTaskStatus;
|
|
141
307
|
agentName;
|
|
142
308
|
constructor(agent,
|
|
143
309
|
// Injectable for tests; defaults to node:child_process spawn.
|
|
144
|
-
spawnFn = spawn
|
|
310
|
+
spawnFn = spawn,
|
|
311
|
+
// Optional model pin, threaded into the agent invocation.
|
|
312
|
+
model,
|
|
313
|
+
// When true, coding-agent (interactive) mode is on: instead of running the
|
|
314
|
+
// agent headlessly and re-rendering its JSON event stream, the agent is
|
|
315
|
+
// launched as a LIVE session that owns the terminal (inherited stdio). The
|
|
316
|
+
// operator works with it until they close it; its exit releases the worker.
|
|
317
|
+
// The caller is responsible for having already passed the fail-fast TTY
|
|
318
|
+
// check (assertUsableTty).
|
|
319
|
+
interactive = false,
|
|
320
|
+
// Working-tree snapshot for the interactive post-agent summary. Injectable
|
|
321
|
+
// for tests; defaults to a real `git status --porcelain`. Only the
|
|
322
|
+
// interactive path reads it (before open() + after a clean exit).
|
|
323
|
+
gitStatus = realGitStatus,
|
|
324
|
+
// Reads the claimed task's current server-side status for the summary.
|
|
325
|
+
// Optional: when absent (e.g. the existing interactive unit tests that
|
|
326
|
+
// construct the runner with no client) the summary falls back to status
|
|
327
|
+
// `unknown` without warning — only a real fetch FAILURE warns. Wired to the
|
|
328
|
+
// real client in `workCommand`.
|
|
329
|
+
fetchTaskStatus) {
|
|
145
330
|
this.agent = agent;
|
|
146
331
|
this.spawnFn = spawnFn;
|
|
332
|
+
this.model = model;
|
|
333
|
+
this.interactive = interactive;
|
|
334
|
+
this.gitStatus = gitStatus;
|
|
335
|
+
this.fetchTaskStatus = fetchTaskStatus;
|
|
147
336
|
this.agentName = agent;
|
|
148
337
|
}
|
|
149
338
|
run(task) {
|
|
150
|
-
|
|
339
|
+
return this.interactive ? this.runInteractive(task) : this.runHeadless(task);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Default (non-interactive) path — UNCHANGED behavior: spawn the agent
|
|
343
|
+
* headlessly, pipe + re-render its JSON event stream, await its exit.
|
|
344
|
+
*/
|
|
345
|
+
runHeadless(task) {
|
|
346
|
+
const { command, args } = buildAgentCommand(this.agent, task, this.model);
|
|
151
347
|
const renderer = AGENT_RENDERER[this.agent];
|
|
152
348
|
return new Promise((resolve, reject) => {
|
|
153
349
|
// Pipe the agent's stdout so we can re-render its JSON event stream into a
|
|
@@ -158,16 +354,13 @@ export class AgentRunner {
|
|
|
158
354
|
cwd: process.cwd(),
|
|
159
355
|
stdio: ["ignore", "pipe", "inherit"],
|
|
160
356
|
});
|
|
161
|
-
// Render stdout line-by-line
|
|
162
|
-
//
|
|
357
|
+
// Render stdout line-by-line to the user's stdout so the work session
|
|
358
|
+
// reads like the agent's own interactive session.
|
|
163
359
|
const splitter = new LineSplitter();
|
|
164
|
-
const emit = (line) => {
|
|
165
|
-
process.stdout.write(`${line}\n`);
|
|
166
|
-
};
|
|
167
360
|
const renderLines = (lines) => {
|
|
168
361
|
for (const raw of lines) {
|
|
169
362
|
for (const out of renderer(raw))
|
|
170
|
-
|
|
363
|
+
process.stdout.write(`${out}\n`);
|
|
171
364
|
}
|
|
172
365
|
};
|
|
173
366
|
child.stdout?.setEncoding("utf-8");
|
|
@@ -177,30 +370,125 @@ export class AgentRunner {
|
|
|
177
370
|
child.stdout?.on("end", () => {
|
|
178
371
|
renderLines(splitter.flush());
|
|
179
372
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
373
|
+
this.wireChild(child, command, task, resolve, reject);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Coding-agent (interactive) path. Launch the agent as a LIVE session via
|
|
378
|
+
* `InteractiveAgent`: inherited stdio so the agent's own TUI owns the terminal
|
|
379
|
+
* (no piping, no re-render — and therefore no raw text dumping), seeded with
|
|
380
|
+
* the spec handoff. The operator works with it; when they close the agent it
|
|
381
|
+
* exits, which resolves this run and RELEASES THE WORKER so the loop claims
|
|
382
|
+
* the next task. A clean operator exit (code 0) is success; a crash/non-zero
|
|
383
|
+
* exit is still a loud failure.
|
|
384
|
+
*/
|
|
385
|
+
runInteractive(task) {
|
|
386
|
+
const { command, args } = buildInteractiveAgentCommand(this.agent, task, this.model);
|
|
387
|
+
// A fresh session per task (the daemon reuses one runner across tasks, so
|
|
388
|
+
// the single-operator InteractiveAgent instance must not be shared).
|
|
389
|
+
const session = new InteractiveAgent(command, args, this.spawnFn);
|
|
390
|
+
// Snapshot the working tree BEFORE the agent runs so we can diff it against
|
|
391
|
+
// the post-session state and report which files the agent touched.
|
|
392
|
+
const before = this.gitStatus();
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
let child;
|
|
395
|
+
try {
|
|
396
|
+
// open() enforces the single-operator invariant and local-only spawn
|
|
397
|
+
// options, then spawns the agent. A throw here (e.g. local-option guard)
|
|
398
|
+
// means nothing was spawned, so there is no child to orphan.
|
|
399
|
+
child = session.open();
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
reject(err instanceof Error ? err : new CliError(String(err)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// On a CLEAN exit, print the one-line post-agent summary BEFORE releasing
|
|
406
|
+
// the worker (the real `resolve` fires only inside summarizeThenRelease).
|
|
407
|
+
// `wireChild`'s exit handler stays synchronous (shared with the headless
|
|
408
|
+
// path); we hand it a wrapper that defers the resolve until the summary is
|
|
409
|
+
// written. A non-zero exit still rejects through `wireChild` unchanged.
|
|
410
|
+
const releaseAfterSummary = () => {
|
|
411
|
+
void this.summarizeThenRelease(task, before, resolve);
|
|
412
|
+
};
|
|
413
|
+
this.wireChild(child, command, task, releaseAfterSummary, reject);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Print the ONE-LINE post-agent change summary, then RESOLVE (releasing the
|
|
418
|
+
* worker) — in that order, per the spec constraint "Summary before worker
|
|
419
|
+
* release". Computes the touched-file set from the before/after porcelain
|
|
420
|
+
* snapshots and reads the claimed task's current server-side status.
|
|
421
|
+
*
|
|
422
|
+
* Robustness: this runs after a CLEAN agent exit, so it must NEVER turn that
|
|
423
|
+
* success into a failure. The whole body is wrapped so `resolve()` always
|
|
424
|
+
* fires even if the summary work throws. The status read is the one sanctioned
|
|
425
|
+
* soft-fallback — a fetch failure warns and reports `unknown` rather than
|
|
426
|
+
* blocking the release; an absent status fetcher (no client wired, e.g. unit
|
|
427
|
+
* tests) is silently `unknown`.
|
|
428
|
+
*/
|
|
429
|
+
async summarizeThenRelease(task, before, resolve) {
|
|
430
|
+
try {
|
|
431
|
+
const after = this.gitStatus();
|
|
432
|
+
const filesTouched = diffTouchedFiles(before, after);
|
|
433
|
+
let status = "unknown";
|
|
434
|
+
if (this.fetchTaskStatus) {
|
|
435
|
+
try {
|
|
436
|
+
status = await this.fetchTaskStatus(task);
|
|
189
437
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (code === 0) {
|
|
197
|
-
resolve();
|
|
198
|
-
return;
|
|
438
|
+
catch (err) {
|
|
439
|
+
// The status read must not block worker release — warn and fall back
|
|
440
|
+
// to `unknown` (the one allowed soft-fallback) instead of throwing.
|
|
441
|
+
log.warn(`Could not read task ${shortId(task.id)} status for summary`, {
|
|
442
|
+
error: err instanceof Error ? err.message : String(err),
|
|
443
|
+
});
|
|
199
444
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
445
|
+
}
|
|
446
|
+
process.stdout.write(`${postAgentSummary({
|
|
447
|
+
filesTouched,
|
|
448
|
+
status,
|
|
449
|
+
shortTaskId: shortId(task.id),
|
|
450
|
+
})}\n`);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
// A summary failure must never convert a clean agent exit into a failure;
|
|
454
|
+
// warn and still release the worker below.
|
|
455
|
+
log.warn(`Could not print post-agent summary for task ${shortId(task.id)}`, {
|
|
456
|
+
error: err instanceof Error ? err.message : String(err),
|
|
203
457
|
});
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
resolve();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Shared exit/error handling for both paths: ENOENT → install hint, non-zero
|
|
465
|
+
* exit → loud failure, code 0 → success (releases the worker). `settled`
|
|
466
|
+
* guards against a double-settle if error and exit both fire.
|
|
467
|
+
*/
|
|
468
|
+
wireChild(child, command, task, resolve, reject) {
|
|
469
|
+
let settled = false;
|
|
470
|
+
child.on("error", (err) => {
|
|
471
|
+
if (settled)
|
|
472
|
+
return;
|
|
473
|
+
settled = true;
|
|
474
|
+
if (err.code === "ENOENT") {
|
|
475
|
+
reject(new CliError(`Could not launch agent "${this.agent}": \`${command}\` was not found on PATH.\n\n` +
|
|
476
|
+
AGENT_INSTALL_HINT[this.agent]));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
reject(new CliError(`Failed to launch agent "${this.agent}" (\`${command}\`): ${err.message}`));
|
|
480
|
+
});
|
|
481
|
+
child.on("exit", (code, signal) => {
|
|
482
|
+
if (settled)
|
|
483
|
+
return;
|
|
484
|
+
settled = true;
|
|
485
|
+
if (code === 0) {
|
|
486
|
+
resolve();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const detail = code !== null ? `exited with code ${code}` : `was terminated by signal ${signal}`;
|
|
490
|
+
// Fail loud — a non-zero/aborted agent run is a FAILURE, never success.
|
|
491
|
+
reject(new CliError(`Agent "${this.agent}" (\`${command}\`) ${detail} while running task ${shortId(task.id)}.`));
|
|
204
492
|
});
|
|
205
493
|
}
|
|
206
494
|
}
|
|
@@ -211,13 +499,15 @@ export class AgentRunner {
|
|
|
211
499
|
*/
|
|
212
500
|
export class DryRunRunner {
|
|
213
501
|
agent;
|
|
502
|
+
model;
|
|
214
503
|
agentName;
|
|
215
|
-
constructor(agent) {
|
|
504
|
+
constructor(agent, model) {
|
|
216
505
|
this.agent = agent;
|
|
506
|
+
this.model = model;
|
|
217
507
|
this.agentName = agent;
|
|
218
508
|
}
|
|
219
509
|
run(task) {
|
|
220
|
-
const { command, args } = buildAgentCommand(this.agent, task);
|
|
510
|
+
const { command, args } = buildAgentCommand(this.agent, task, this.model);
|
|
221
511
|
log.info(`[dry-run] would spawn agent "${this.agent}" for task ${shortId(task.id)}`);
|
|
222
512
|
log.plain(` cwd: ${process.cwd()}`);
|
|
223
513
|
log.plain(` command: ${command} ${formatDryRunArgs(args)}`);
|
|
@@ -309,9 +599,11 @@ function parseWorkArgs(argv) {
|
|
|
309
599
|
args: argv,
|
|
310
600
|
options: {
|
|
311
601
|
agent: { type: "string" },
|
|
602
|
+
model: { type: "string" },
|
|
312
603
|
interval: { type: "string" },
|
|
313
604
|
once: { type: "boolean" },
|
|
314
605
|
"dry-run": { type: "boolean" },
|
|
606
|
+
interactive: { type: "boolean" },
|
|
315
607
|
},
|
|
316
608
|
strict: true,
|
|
317
609
|
}));
|
|
@@ -327,6 +619,17 @@ function parseWorkArgs(argv) {
|
|
|
327
619
|
if (!SUPPORTED_AGENTS.includes(agent)) {
|
|
328
620
|
throw new CliError(`Unsupported agent "${agent}".\n\nSupported agents: ${SUPPORTED_AGENTS.join(", ")}`);
|
|
329
621
|
}
|
|
622
|
+
// --model is optional: when omitted the agent uses its own configured
|
|
623
|
+
// default. But an explicit empty `--model ""` is a mistake we fail loud on
|
|
624
|
+
// rather than silently passing a blank model through to the agent.
|
|
625
|
+
const rawModel = values["model"];
|
|
626
|
+
let model;
|
|
627
|
+
if (rawModel !== undefined) {
|
|
628
|
+
if (rawModel.trim() === "") {
|
|
629
|
+
throw new CliError(`Invalid --model "${rawModel}". Provide a model alias (e.g. opus, sonnet) or full model id.`);
|
|
630
|
+
}
|
|
631
|
+
model = rawModel;
|
|
632
|
+
}
|
|
330
633
|
let intervalSeconds = DEFAULT_INTERVAL_SECONDS;
|
|
331
634
|
const rawInterval = values["interval"];
|
|
332
635
|
if (rawInterval !== undefined) {
|
|
@@ -344,11 +647,25 @@ function parseWorkArgs(argv) {
|
|
|
344
647
|
// Validated above against SUPPORTED_AGENTS; the readonly-tuple `includes`
|
|
345
648
|
// check above does not narrow `string`, so assert the proven type here.
|
|
346
649
|
agent: agent,
|
|
650
|
+
model,
|
|
347
651
|
intervalMs: intervalSeconds * 1000,
|
|
348
652
|
once: values["once"] === true,
|
|
349
653
|
dryRun: values["dry-run"] === true,
|
|
654
|
+
interactive: values["interactive"] === true,
|
|
350
655
|
};
|
|
351
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Resolve whether interactive mode is on. Opt-in is explicit by design (the
|
|
659
|
+
* spec keeps existing runs non-interactive unless enabled): the `--interactive`
|
|
660
|
+
* flag, or `interactive: true` in `.zenorm.json`. The flag wins; config only
|
|
661
|
+
* supplies the default when the flag is absent. A bad config value already
|
|
662
|
+
* threw in `validateConfig`, so this never silently coerces.
|
|
663
|
+
*/
|
|
664
|
+
export function resolveInteractive(flagPassed, config) {
|
|
665
|
+
if (flagPassed)
|
|
666
|
+
return true;
|
|
667
|
+
return config?.interactive === true;
|
|
668
|
+
}
|
|
352
669
|
/** Write daemon-only meta output (e.g. the dry-run banner) to stderr. */
|
|
353
670
|
function writeDaemonLine(line) {
|
|
354
671
|
process.stderr.write(`${line}\n`);
|
|
@@ -363,6 +680,22 @@ async function releaseTaskToTodo(client, task) {
|
|
|
363
680
|
});
|
|
364
681
|
}
|
|
365
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Read the claimed task's CURRENT server-side status for the interactive
|
|
685
|
+
* post-agent summary. `GET /v1/specs/:specId/tasks` returns the spec's tasks;
|
|
686
|
+
* we find this one by id and return its status. Throws if the task is missing
|
|
687
|
+
* from the response (fail loud rather than masking a wrong/empty list) — the
|
|
688
|
+
* caller (`summarizeThenRelease`) catches and degrades to `unknown` so a status
|
|
689
|
+
* read never blocks worker release.
|
|
690
|
+
*/
|
|
691
|
+
async function fetchTaskStatus(client, task) {
|
|
692
|
+
const { tasks } = await client.get(`/v1/specs/${task.specId}/tasks`);
|
|
693
|
+
const found = tasks.find((t) => t.id === task.id);
|
|
694
|
+
if (!found) {
|
|
695
|
+
throw new CliError(`Task ${shortId(task.id)} not found in spec ${task.specId} tasks list.`);
|
|
696
|
+
}
|
|
697
|
+
return found.status;
|
|
698
|
+
}
|
|
366
699
|
async function heartbeatTask(client, task) {
|
|
367
700
|
try {
|
|
368
701
|
await client.post(`/v1/tasks/${task.id}/heartbeat`, {});
|
|
@@ -422,11 +755,15 @@ export async function runWorkLoop(opts) {
|
|
|
422
755
|
}
|
|
423
756
|
return response.task ?? null;
|
|
424
757
|
};
|
|
425
|
-
// Run
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
758
|
+
// Run one claimed task's SPEC to completion. The claim flips a single task to
|
|
759
|
+
// `active` to prove there is ready work for this repo; the agent it launches
|
|
760
|
+
// then drains every available task of that spec in ONE session (spec mode —
|
|
761
|
+
// see buildHandoffPrompt). The agent's own transcript (its tool calls + prose,
|
|
762
|
+
// re-rendered by work-render.ts) IS the body of the output; the daemon frames
|
|
763
|
+
// it with a numbered header (the claimed entry task) and closes with a ✓/✗
|
|
764
|
+
// outcome line (+ a loud banner on failure). On failure the entry task is
|
|
765
|
+
// released back to `todo` so the spec can be re-claimed; tasks the agent
|
|
766
|
+
// already completed stay `done`.
|
|
430
767
|
const runTask = async (task) => {
|
|
431
768
|
started += 1;
|
|
432
769
|
const agentName = runner.agentName ?? "agent";
|
|
@@ -504,16 +841,42 @@ export async function workCommand(argv, deps) {
|
|
|
504
841
|
// Fatal (CliError) failures — bad args, not a git repo — abort BEFORE the
|
|
505
842
|
// loop starts so they surface immediately rather than being swallowed as a
|
|
506
843
|
// transient blip.
|
|
507
|
-
const { agent, intervalMs, once, dryRun } = parseWorkArgs(argv);
|
|
844
|
+
const { agent, model, intervalMs, once, dryRun, interactive: interactiveFlag } = parseWorkArgs(argv);
|
|
508
845
|
const repo = resolveCurrentRepo();
|
|
846
|
+
// Resolve the interactive opt-in (flag OR project config). `loadConfig` walks
|
|
847
|
+
// up from cwd for `.zenorm.json`; a malformed config throws (no masked errors).
|
|
848
|
+
const interactive = resolveInteractive(interactiveFlag, loadConfig());
|
|
849
|
+
if (interactive && dryRun) {
|
|
850
|
+
// --dry-run never spawns an agent, so there is no live agent session to drop
|
|
851
|
+
// into. Refuse the combination loudly rather than silently ignoring one.
|
|
852
|
+
throw new CliError("`--interactive` cannot be combined with `--dry-run`: dry-run never " +
|
|
853
|
+
"launches an agent, so there is no interactive coding-agent session to open.");
|
|
854
|
+
}
|
|
855
|
+
// Fail-fast gate (spec: "Fail-fast without usable TTY"). Done BEFORE the loop
|
|
856
|
+
// and BEFORE any claim so a misconfigured interactive run errors immediately
|
|
857
|
+
// instead of claiming a task and then hanging. Non-interactive runs skip this
|
|
858
|
+
// entirely — they never require a TTY and never prompt.
|
|
859
|
+
if (interactive) {
|
|
860
|
+
assertUsableTty(deps?.ttyProbe ?? processTtyProbe());
|
|
861
|
+
}
|
|
509
862
|
const client = deps?.client ?? new ZenormClient();
|
|
510
|
-
const runner = deps?.runner ??
|
|
863
|
+
const runner = deps?.runner ??
|
|
864
|
+
(dryRun
|
|
865
|
+
? new DryRunRunner(agent, model)
|
|
866
|
+
: new AgentRunner(agent, spawn, model, interactive,
|
|
867
|
+
// Real working-tree snapshot for the interactive post-agent summary.
|
|
868
|
+
realGitStatus,
|
|
869
|
+
// Real status read: list the spec's tasks and pick out this task's
|
|
870
|
+
// current status (it is likely `done` once the agent inside the
|
|
871
|
+
// session ran `zenorm task complete`).
|
|
872
|
+
(task) => fetchTaskStatus(client, task)));
|
|
511
873
|
// Startup banner — shown on EVERY run (not just --dry-run) so a real session
|
|
512
874
|
// announces what it's claiming for instead of sitting silent until the first
|
|
513
875
|
// task lands. Written to stderr so it never interleaves with the agent
|
|
514
876
|
// transcript when stdout is piped to a file.
|
|
515
877
|
writeDaemonLine(daemonBanner({
|
|
516
878
|
agent,
|
|
879
|
+
model,
|
|
517
880
|
repo: `${repo.owner}/${repo.name}`,
|
|
518
881
|
intervalSeconds: intervalMs / 1000,
|
|
519
882
|
once,
|