@love-moon/conductor-cli 0.2.42 → 0.3.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,133 @@
1
+ # @love-moon/conductor-cli
2
+
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4e8d4e5: Include `CHANGELOG.md` in published npm tarballs.
8
+
9
+ The `files` array in each package's `package.json` previously only
10
+ listed the build output (`bin`/`src` for the CLI, `dist` for the
11
+ modules). npm's `files` whitelist replaces the default include set,
12
+ and CHANGELOG is not one of the auto-included files (only
13
+ `package.json`, `README*`, `LICENSE*`, and `main` are unconditional).
14
+
15
+ As a result, every release through 0.3.0 published tarballs with no
16
+ CHANGELOG, so a consumer running `npm install` or unpacking the brew
17
+ artifact had no way to see what changed in the version they just
18
+ installed. The repo `cli/CHANGELOG.md` and the GitHub Release body
19
+ remain the canonical source until 0.3.1 ships with this fix.
20
+
21
+ - Updated dependencies [4e8d4e5]
22
+ - @love-moon/conductor-sdk@0.3.1
23
+ - @love-moon/ai-sdk@0.3.1
24
+ - @love-moon/ai-manager@0.3.1
25
+
26
+ ## 0.3.0
27
+
28
+ ### Minor Changes
29
+
30
+ - be3b3cb: Project list now merges same-name git projects that share a remote URL across daemons into a single card.
31
+
32
+ - `ProjectContext.snapshot()` (SDK) now captures the origin remote URL via `git config --get remote.origin.url` and exposes a `normalizeGitRemoteUrl` helper for callers that need their own equality comparison.
33
+ - The daemon's `validate_project_path` response carries a new `git_remote_url` field. Old daemons that don't send it stay forward-compatible — the web server treats missing values as "unmergeable" so projects from those daemons render standalone until they're refreshed.
34
+ - New web API surface:
35
+ - `PATCH /api/projects { mergeOptOut: true }` opts a project out of auto-grouping (manual split for accidental name collisions).
36
+ - `PATCH /api/projects { refresh: true }` re-runs the daemon validation handshake and back-fills `git_remote_url` for projects created before this feature.
37
+ - `GET /api/issues?project_ids=a,b` fetches issues from multiple projects in one call; responses include `daemonHost` + `projectName` for cross-daemon attribution.
38
+ - Schema: two new optional columns on `projects`: `git_remote_url` (nullable string) and `merge_opt_out` (boolean, default false). Run `pnpm -C web db:push` before upgrading.
39
+
40
+ - 23ac015: Add `fire_tmux_mode` for the daemon. When enabled (via the `fire_tmux_mode: true`
41
+ key in `~/.conductor/config.yaml` or the `CONDUCTOR_FIRE_TMUX_MODE=true`
42
+ environment variable), each Fire process is launched inside a detached
43
+ `tmux new-session -d` so that it runs under the tmux server with no
44
+ parent/child relationship to the daemon. Restarting or terminating the daemon
45
+ no longer affects running Fire processes; explicit `stop_task` requests use
46
+ `tmux kill-session` to terminate the corresponding session.
47
+
48
+ If `fire_tmux_mode` is enabled but `tmux` is not installed on PATH, the daemon
49
+ logs a warning at startup and silently falls back to direct spawn instead of
50
+ failing every `create_task` with ENOENT.
51
+
52
+ If a tmux session fails to launch (e.g. duplicate session name, tmux server
53
+ crashed), the daemon now reports a terminal status to the backend instead of
54
+ leaving the task stuck on RUNNING.
55
+
56
+ Session names embed a per-spawn uniqueness suffix
57
+ (`conductor-fire-<taskId>-<base36-time><rand>`) so a re-spawn of the same task
58
+ id while a prior session is still alive does not collide. The daemon also runs
59
+ a periodic best-effort liveness reaper (default every 30s; override via
60
+ `config.TMUX_LIVENESS_POLL_MS`) that walks tmux-mode entries in
61
+ `activeTaskProcesses` and removes any whose session no longer exists, so the
62
+ in-memory map does not accumulate stale entries when Fire exits naturally
63
+ inside its session.
64
+
65
+ ### Other Changes (retroactively documented)
66
+
67
+ The following changes shipped in `@love-moon/conductor-cli@0.3.0` but were
68
+ merged without a `changeset` entry and so didn't make it into the
69
+ auto-generated section above. See
70
+ `claw/lessons/arch_release-packages-pnpm-changesets-20260512.md` for the
71
+ process gap and the rule that every PR touching `cli/**` or `modules/**`
72
+ must run `npm run changeset`.
73
+
74
+ **New CLI commands**
75
+
76
+ - `cli`: `conductor project|issue|task` entity commands (RFC 0025). Adds
77
+ scriptable CLI access to project/issue/task CRUD against the connected
78
+ daemon. (`2e10756`)
79
+ - `cli`: `conductor project` accepts `--daemon-host` to disambiguate
80
+ same-named projects across multiple daemons. (`08eefee`)
81
+ - `cli`: `conductor project list` now prints a daemon column. (`552731b`)
82
+
83
+ **Daemon — Fire tmux mode (companion to `fire_tmux_mode`)**
84
+
85
+ - Daemon now `tmux send-keys`-friendly: env vars are propagated via
86
+ `tmux -e` flags so the Fire process inherits the spawn environment.
87
+ (`3cd3022`)
88
+ - Live Fire output is now visible via `tmux attach` thanks to a `tee`
89
+ inside the session shell. (`f07fbc6`)
90
+ - `stop_task` and `cleanup_task_worktree` now reap orphan tmux sessions.
91
+ (`1f7ef28`)
92
+ - A killed tmux-mode task reports `KILLED` directly from the daemon
93
+ instead of waiting on Fire to flip the status. (`59c6472`)
94
+ - `restart_task` clears stale tmux entries before re-spawning and refuses
95
+ with a clearer error if the session can't be acquired. (`c659663`)
96
+
97
+ **Daemon stability**
98
+
99
+ - Fix: reconcile / stale-recovery no longer kills `init` successor tasks
100
+ that haven't received their initial websocket message yet. (`d9258ba`)
101
+ - Fix: late websocket send after disconnect no longer crashes daemon
102
+ restart. (`a3532cc`)
103
+ - Fix: stale fire task attach is now guarded against double-binding.
104
+ (`dc73be9`)
105
+
106
+ **Worktree**
107
+
108
+ - Worktree folders are now named by branch (slugified) rather than by
109
+ the task id, making them human-meaningful inside repos with many
110
+ concurrent tasks. (`ed124b5`)
111
+ - The worktree scanner now skips symlinks that are git-tracked, so user
112
+ symlinks inside a worktree don't get treated as candidates. (`5281952`)
113
+
114
+ **Quota / accounts UI**
115
+
116
+ - Codex quota snapshots are now restored from the daemon cache on
117
+ refresh, so the daemon page doesn't blank out while a fresh fetch is
118
+ in flight. (`130bd93`)
119
+
120
+ **Internal refactor (no consumer API change)**
121
+
122
+ - `modules/ai-sdk`: resume logic has been split into per-provider
123
+ modules under `src/resume/<provider>.js`. The public exports
124
+ (`createAiSession`, `BUILT_IN_BACKENDS`, etc.) are unchanged.
125
+ Source-level only; the published `@love-moon/ai-sdk` version pinned
126
+ in `cli@0.3.0`'s manifest is still `0.2.42`, so consumer behavior is
127
+ identical to before — the refactor will reach npm with the next
128
+ ai-sdk release that includes a `changeset`. (`846f05a`)
129
+
130
+ ### Patch Changes
131
+
132
+ - Updated dependencies [be3b3cb]
133
+ - @love-moon/conductor-sdk@0.3.0
@@ -1466,10 +1466,28 @@ function buildEnv() {
1466
1466
  return env;
1467
1467
  }
1468
1468
 
1469
- async function ensureTaskContext(conductor, opts) {
1470
- if (opts.providedTaskId) {
1469
+ function isBackendNotFoundError(error) {
1470
+ return Boolean(error && typeof error === "object" && Number(error.statusCode) === 404);
1471
+ }
1472
+
1473
+ export async function ensureTaskContext(conductor, opts) {
1474
+ const providedTaskId =
1475
+ typeof opts.providedTaskId === "string" ? opts.providedTaskId.trim() : "";
1476
+ if (providedTaskId) {
1477
+ if (typeof conductor.getTask === "function") {
1478
+ try {
1479
+ await conductor.getTask(providedTaskId);
1480
+ } catch (error) {
1481
+ if (isBackendNotFoundError(error)) {
1482
+ throw new Error(
1483
+ `CONDUCTOR_TASK_ID points to missing task ${providedTaskId}; unset CONDUCTOR_TASK_ID or use an existing task id`,
1484
+ );
1485
+ }
1486
+ throw error;
1487
+ }
1488
+ }
1471
1489
  return {
1472
- taskId: opts.providedTaskId,
1490
+ taskId: providedTaskId,
1473
1491
  appUrl: null,
1474
1492
  shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
1475
1493
  initialPromptDelivery: opts.initialPrompt ? "synthetic" : "none",
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * conductor issue — entity-oriented issue management.
5
+ *
6
+ * Subcommands:
7
+ * list [--project ...] [--status <s>] [--limit N]
8
+ * show <id>
9
+ * create --title <t> [--description <d> | --description-file FILE | --description-stdin]
10
+ * [--priority P1|P2|P3] [--status backlog|doing|done]
11
+ * [--client-request-id <key>] [--project ...]
12
+ * update <id> [--title ...] [--description ...] [--priority ...] [--status ...]
13
+ * start <id> (alias for update --status doing)
14
+ * done <id> [--evidence <text>|@FILE]
15
+ *
16
+ * Global flags supported on every write subcommand:
17
+ * --json, --dry-run, --project, --config-file
18
+ */
19
+
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import yargs from "yargs/yargs";
25
+ import { hideBin } from "yargs/helpers";
26
+
27
+ import {
28
+ EXIT,
29
+ buildApis,
30
+ buildAuditMetadata,
31
+ emitDryRun,
32
+ exitCodeForError,
33
+ makeDryRunPayload,
34
+ pad,
35
+ printJson,
36
+ printPretty,
37
+ readDescription,
38
+ readEvidence,
39
+ reportError,
40
+ resolveProject,
41
+ } from "../src/entity-helpers.js";
42
+
43
+ const isMainModule = (() => {
44
+ const currentFile = fileURLToPath(import.meta.url);
45
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
46
+ return entryFile === currentFile;
47
+ })();
48
+
49
+ function buildBaseUrl(config) {
50
+ const raw = (config?.backendUrl || "").replace(/\/+$/, "");
51
+ return raw || "http://localhost";
52
+ }
53
+
54
+ function issueAsObject(issue) {
55
+ if (!issue) return null;
56
+ if (typeof issue.asObject === "function") return issue.asObject();
57
+ return {
58
+ id: issue.id,
59
+ title: issue.title,
60
+ status: issue.status,
61
+ priority: issue.priority,
62
+ description: issue.description,
63
+ projectId: issue.projectId,
64
+ metadata: issue.metadata,
65
+ createdAt: issue.createdAt,
66
+ updatedAt: issue.updatedAt,
67
+ };
68
+ }
69
+
70
+ function parseStatusList(value) {
71
+ if (!value) return undefined;
72
+ return String(value)
73
+ .split(",")
74
+ .map((entry) => entry.trim())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ async function handleList(argv, deps) {
79
+ const apis = await buildApis(deps);
80
+ const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
81
+ const list = await apis.issues.listIssues({
82
+ projectId: project.id,
83
+ status: parseStatusList(argv.status),
84
+ limit: argv.limit ? Number(argv.limit) : undefined,
85
+ });
86
+ const objects = (Array.isArray(list) ? list : []).map(issueAsObject);
87
+ if (argv.json) {
88
+ printJson(deps.stdout, objects);
89
+ return EXIT.OK;
90
+ }
91
+ if (objects.length === 0) {
92
+ printPretty(deps.stdout, "(no issues)");
93
+ return EXIT.OK;
94
+ }
95
+ printPretty(deps.stdout, `${pad("ID", 24)} ${pad("STATUS", 9)} ${pad("PRIO", 5)} TITLE`);
96
+ for (const issue of objects) {
97
+ printPretty(
98
+ deps.stdout,
99
+ `${pad(issue.id, 24)} ${pad(issue.status, 9)} ${pad(issue.priority ?? "", 5)} ${issue.title ?? ""}`,
100
+ );
101
+ }
102
+ return EXIT.OK;
103
+ }
104
+
105
+ async function handleShow(argv, deps) {
106
+ const apis = await buildApis(deps);
107
+ const issue = await apis.issues.getIssue(argv.id);
108
+ if (!issue) {
109
+ const err = new Error(`Issue not found: ${argv.id}`);
110
+ err.statusCode = 404;
111
+ throw err;
112
+ }
113
+ const obj = issueAsObject(issue);
114
+ if (argv.json) {
115
+ printJson(deps.stdout, obj);
116
+ return EXIT.OK;
117
+ }
118
+ for (const [key, value] of Object.entries(obj)) {
119
+ if (value === undefined || value === null) continue;
120
+ if (typeof value === "object") {
121
+ printPretty(deps.stdout, `${key}: ${JSON.stringify(value)}`);
122
+ } else {
123
+ printPretty(deps.stdout, `${key}: ${value}`);
124
+ }
125
+ }
126
+ return EXIT.OK;
127
+ }
128
+
129
+ async function handleCreate(argv, deps) {
130
+ const apis = await buildApis(deps);
131
+ const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
132
+ const description = readDescription({
133
+ description: argv.description,
134
+ descriptionFile: argv.descriptionFile,
135
+ descriptionStdin: argv.descriptionStdin,
136
+ stdin: deps.stdin,
137
+ });
138
+ const metadata = buildAuditMetadata(deps.env);
139
+ const body = {
140
+ projectId: project.id,
141
+ title: String(argv.title),
142
+ ...(description !== undefined ? { description } : {}),
143
+ ...(argv.priority ? { priority: String(argv.priority) } : {}),
144
+ ...(argv.status ? { status: String(argv.status) } : {}),
145
+ ...(argv.clientRequestId ? { clientRequestId: String(argv.clientRequestId) } : {}),
146
+ metadata,
147
+ };
148
+ if (argv.dryRun) {
149
+ emitDryRun(
150
+ deps.stdout,
151
+ argv.json,
152
+ makeDryRunPayload("POST", `${buildBaseUrl(apis.config)}/api/issues`, body),
153
+ );
154
+ return EXIT.OK;
155
+ }
156
+ const created = await apis.issues.createIssue(body);
157
+ const obj = issueAsObject(created);
158
+ if (argv.json) {
159
+ printJson(deps.stdout, obj);
160
+ return EXIT.OK;
161
+ }
162
+ printPretty(deps.stdout, `Created issue ${obj.id}: ${obj.title}`);
163
+ return EXIT.OK;
164
+ }
165
+
166
+ async function handleUpdate(argv, deps, overrides = {}) {
167
+ const apis = await buildApis(deps);
168
+ const description = readDescription({
169
+ description: argv.description,
170
+ descriptionFile: argv.descriptionFile,
171
+ descriptionStdin: argv.descriptionStdin,
172
+ stdin: deps.stdin,
173
+ });
174
+ // `--description ""` is intentionally treated as a no-op (readDescription
175
+ // returns undefined). To clear an existing description, the server expects
176
+ // an explicit `null` — exposing that requires a follow-up flag and is out of
177
+ // scope here (review M5 — current behavior accepted as documented).
178
+ const status = overrides.status || argv.status;
179
+ const evidence = overrides.evidence;
180
+ const metadata = buildAuditMetadata(deps.env);
181
+ const body = {
182
+ ...(argv.title ? { title: String(argv.title) } : {}),
183
+ ...(description !== undefined ? { description } : {}),
184
+ ...(argv.priority ? { priority: String(argv.priority) } : {}),
185
+ ...(status ? { status: String(status) } : {}),
186
+ metadata,
187
+ };
188
+ if (Object.keys(body).filter((key) => key !== "metadata").length === 0) {
189
+ const err = new Error("Nothing to update: pass at least one of --title/--description/--priority/--status");
190
+ err.code = "ARGS";
191
+ throw err;
192
+ }
193
+ if (argv.dryRun) {
194
+ // Surface evidence in the preview body so the user sees what the SDK will
195
+ // merge into `metadata.qa.evidence` server-side. The real call still
196
+ // round-trips the existing metadata; this preview is an approximation.
197
+ const previewBody = evidence === undefined
198
+ ? body
199
+ : {
200
+ ...body,
201
+ metadata: {
202
+ ...body.metadata,
203
+ qa: { ...((body.metadata && body.metadata.qa) || {}), evidence },
204
+ },
205
+ };
206
+ const previewOptions = evidence !== undefined
207
+ ? { note: "preview omits server-side metadata round-trip; live PATCH merges existing metadata.qa fields" }
208
+ : {};
209
+ emitDryRun(
210
+ deps.stdout,
211
+ argv.json,
212
+ makeDryRunPayload(
213
+ "PATCH",
214
+ `${buildBaseUrl(apis.config)}/api/issues/${encodeURIComponent(argv.id)}`,
215
+ previewBody,
216
+ previewOptions,
217
+ ),
218
+ );
219
+ return EXIT.OK;
220
+ }
221
+ // Pick the SDK call:
222
+ // - If we have `evidence`, route through `updateIssueStatus` so the SDK
223
+ // round-trips the existing metadata and merges `qa.evidence` instead of
224
+ // clobbering. We pass `metadata` so the CLI's audit namespace flows
225
+ // through.
226
+ // - Otherwise call `updateIssue` so a multi-field patch (title + status)
227
+ // reaches the server intact (review B2: previously the CLI passed the
228
+ // full body as the SDK's third arg, which was ignored).
229
+ let updated;
230
+ if (evidence !== undefined && status && typeof apis.issues.updateIssueStatus === "function") {
231
+ updated = await apis.issues.updateIssueStatus(argv.id, String(status), {
232
+ evidence,
233
+ metadata,
234
+ });
235
+ } else {
236
+ updated = await apis.issues.updateIssue(argv.id, body);
237
+ }
238
+ const obj = issueAsObject(updated);
239
+ if (argv.json) {
240
+ printJson(deps.stdout, obj);
241
+ return EXIT.OK;
242
+ }
243
+ printPretty(deps.stdout, `Updated issue ${obj.id}`);
244
+ return EXIT.OK;
245
+ }
246
+
247
+ export async function main(argvInput = hideBin(process.argv), deps = {}) {
248
+ const stdout = deps.stdout || process.stdout;
249
+ const stderr = deps.stderr || process.stderr;
250
+ const env = deps.env || process.env;
251
+ const cwd = deps.cwd || process.cwd();
252
+ const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
253
+ const handlerDeps = { ...deps, stdout, stderr, env, cwd };
254
+
255
+ let exitCode = EXIT.OK;
256
+ try {
257
+ await yargs(argvInput)
258
+ .scriptName("conductor issue")
259
+ .strict()
260
+ .help()
261
+ .option("json", { type: "boolean", default: false })
262
+ .option("dry-run", { type: "boolean", default: false })
263
+ .option("project", { type: "string", describe: "Project id or name override" })
264
+ .option("config-file", { type: "string", describe: "Path to Conductor config file" })
265
+ .command(
266
+ "list",
267
+ "List issues",
268
+ (cmd) => cmd
269
+ .option("status", { type: "string", describe: "Comma-separated status filter (e.g. backlog,doing)" })
270
+ .option("limit", { type: "number" }),
271
+ async (argv) => {
272
+ exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
273
+ },
274
+ )
275
+ .command(
276
+ "show <id>",
277
+ "Show one issue's full detail",
278
+ (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
279
+ async (argv) => {
280
+ exitCode = await handleShow(argv, { ...handlerDeps, configFile: argv.configFile });
281
+ },
282
+ )
283
+ .command(
284
+ "create",
285
+ "Create a new issue",
286
+ (cmd) => cmd
287
+ .option("title", { type: "string", demandOption: true })
288
+ .option("description", { type: "string" })
289
+ .option("description-file", { type: "string" })
290
+ .option("description-stdin", { type: "boolean", default: false })
291
+ .option("priority", { choices: ["P1", "P2", "P3"] })
292
+ .option("status", { choices: ["backlog", "doing", "done"] })
293
+ .option("client-request-id", { type: "string" }),
294
+ async (argv) => {
295
+ exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
296
+ },
297
+ )
298
+ .command(
299
+ "update <id>",
300
+ "Update an issue's fields (any subset)",
301
+ (cmd) => cmd
302
+ .positional("id", { type: "string", demandOption: true })
303
+ .option("title", { type: "string" })
304
+ .option("description", { type: "string" })
305
+ .option("description-file", { type: "string" })
306
+ .option("description-stdin", { type: "boolean", default: false })
307
+ .option("priority", { choices: ["P1", "P2", "P3"] })
308
+ .option("status", { choices: ["backlog", "doing", "done"] }),
309
+ async (argv) => {
310
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
311
+ },
312
+ )
313
+ .command(
314
+ "start <id>",
315
+ "Mark issue as doing (alias for update --status doing)",
316
+ (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
317
+ async (argv) => {
318
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, { status: "doing" });
319
+ },
320
+ )
321
+ .command(
322
+ "done <id>",
323
+ "Mark issue as done (optionally attach QA evidence)",
324
+ (cmd) => cmd
325
+ .positional("id", { type: "string", demandOption: true })
326
+ .option("evidence", { type: "string", describe: "Inline text or @path/to/file" }),
327
+ async (argv) => {
328
+ const evidence = readEvidence(argv.evidence);
329
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, {
330
+ status: "done",
331
+ ...(evidence !== undefined ? { evidence } : {}),
332
+ });
333
+ },
334
+ )
335
+ .demandCommand(1)
336
+ .fail((msg, err) => {
337
+ if (err) {
338
+ throw err;
339
+ }
340
+ stderr.write(`${msg}\n`);
341
+ exitCode = EXIT.ARGS;
342
+ })
343
+ .parseAsync();
344
+ } catch (err) {
345
+ exitCode = reportError(consoleErr, err);
346
+ }
347
+ return exitCode;
348
+ }
349
+
350
+ if (isMainModule) {
351
+ main().then((code) => {
352
+ if (code !== 0) process.exit(code);
353
+ }).catch((err) => {
354
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
355
+ process.exit(exitCodeForError(err));
356
+ });
357
+ }