@miraland-labs/conduit-bridge 0.9.0 → 0.9.2

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/README.md CHANGED
@@ -2,71 +2,76 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Current npm:** `0.8.0` — **multi-driver lanes** on one Connect computer (`drivers online|offline`), shared concurrent slots **1–4** (sum across online IDEs, not per IDE), per-attempt worktrees, optional `--ensure-checkout`, safe resume.
5
+ **Current npm:** `0.9.2` — **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, optional `--ensure-checkout`.
6
6
 
7
7
  ## Prerequisites
8
8
 
9
9
  - Node.js 20+
10
- - One or more supported agent CLIs on PATH (common: install several on one Mac):
11
- - **Claude Code** — `claude` (Conduit pump or Anthropic login)
12
- - **Codex** — `codex` (Conduit pump or ChatGPT/OpenAI login). Since the July 2026 ChatGPT desktop merge, Bridge also finds the CLI bundled inside ChatGPT.app when `codex` is not on PATH (macOS)
13
- - **OpenCode** — `opencode` (Conduit pump or local provider login)
14
- - **Cursor Agent** — `agent` (local fuel; set with `drivers fuel cursor local` or machine `fuel local`)
10
+ - One or more supported agent CLIs:
11
+ - **Claude Code** — `claude`
12
+ - **Codex** — `codex`
13
+ - **OpenCode** — `opencode`
14
+ - **Cursor Agent** — `agent` (local fuel)
15
15
  - **Kiro CLI** — `kiro-cli` (local fuel)
16
16
  - **Antigravity** — `agy` (local fuel)
17
- - macOS or Linux for `install-service` (Windows: keep a terminal runner open)
18
17
 
19
- ## Install / run
18
+ ## Easy path (all platforms)
19
+
20
+ One config file + the same `ops` commands on macOS, Linux, and Windows:
20
21
 
21
22
  ```bash
22
- npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
23
+ # 1) Config once
24
+ mkdir -p ~/.config/conduit
25
+ # Windows: mkdir %USERPROFILE%\.config\conduit
26
+ npx @miraland-labs/conduit-bridge@latest init-ops
27
+ cp ~/conduit/ops/env.example ~/.config/conduit/ops.env
28
+ # edit ops.env (URL, WORKSPACE, optional REPO, DRIVERS, ROLES)
29
+
30
+ # 2) Connect → approve in browser → install
31
+ npx @miraland-labs/conduit-bridge@latest ops connect
32
+ npx @miraland-labs/conduit-bridge@latest ops install
33
+ npx @miraland-labs/conduit-bridge@latest ops status
23
34
  ```
24
35
 
25
- After approval, seed detects installed CLIs as **offline lanes**. Bring one or more online (subscription failover / concurrent lanes):
36
+ Later:
26
37
 
27
38
  ```bash
28
- npx @miraland-labs/conduit-bridge drivers # list lanes
29
- npx @miraland-labs/conduit-bridge drivers online claude-code
30
- npx @miraland-labs/conduit-bridge drivers online claude-code codex # both can run at once
31
- npx @miraland-labs/conduit-bridge drivers offline claude-code # finish in-flight; no new claims
32
- npx @miraland-labs/conduit-bridge runner --workspace /path/to/repo
39
+ npx @miraland-labs/conduit-bridge@latest ops online cursor
40
+ npx @miraland-labs/conduit-bridge@latest ops offline cursor
41
+ npx @miraland-labs/conduit-bridge@latest ops disconnect
33
42
  ```
34
43
 
35
- Legacy single-driver process override (does not change saved online set):
44
+ Optional local shortcuts after `init-ops` (same verbs):
36
45
 
37
- ```bash
38
- npx @miraland-labs/conduit-bridge runner --agent claude-code --workspace /path/to/repo
39
- ```
46
+ | Platform | Examples |
47
+ | --- | --- |
48
+ | macOS / Linux | `~/conduit/ops/connect.sh`, `install.sh`, … |
49
+ | Windows | `%USERPROFILE%\conduit\ops\connect.cmd`, `install.cmd`, … |
40
50
 
41
- Persist (macOS/Linux) one LaunchAgent; honors the drivers online set:
51
+ **Windows note:** `ops install` brings lanes online and prints a `runner --workspace …` command to keep open (no LaunchAgent). macOS/Linux install a background service.
42
52
 
43
- ```bash
44
- npx @miraland-labs/conduit-bridge install-service --workspace /path/to/repo
45
- # optional: bring a lane online at install time
46
- npx @miraland-labs/conduit-bridge install-service --agent claude-code --workspace /path/to/repo
47
- ```
53
+ `ops.env` keys: `CONDUIT_URL`, `CONDUIT_ORG` (optional), `CONDUIT_WORKSPACE`, `CONDUIT_REPO` (optional), `CONDUIT_DRIVERS` (default `cursor`), `CONDUIT_ROLES` (default `implement research`). Values expand `$HOME`, `%USERPROFILE%`, and `~`.
48
54
 
49
- Reinstall after upgrading from Bridge versions before 0.8 if the old unit baked a single `--agent`.
55
+ ## Advanced CLI
50
56
 
51
- For a Start-created GitHub repo that is not checked out yet, point `--workspace` at the intended path and add `--ensure-checkout <https://github.com/…>` so Bridge clones with the machine’s Git credentials before the first heartbeat (never the control-plane PAT).
57
+ ```bash
58
+ npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
59
+ npx @miraland-labs/conduit-bridge drivers online cursor
60
+ npx @miraland-labs/conduit-bridge install-service --workspace /path/to/repo --ensure-checkout https://github.com/org/repo.git
61
+ npx @miraland-labs/conduit-bridge disconnect --yes
62
+ ```
52
63
 
53
- **Capacity:** Connect-approved `lease_capacity` (1–4) is a **shared pool** for all online lanes on that computer. Claude alone may use all 4 slots; Claude + Codex share those 4 — never 4×N.
64
+ **Capacity:** Connect-approved `lease_capacity` (1–4) is a **shared pool** for all online lanes on that computer.
54
65
 
55
66
  ## Grant enforcement
56
67
 
57
- Every driver enforces the assignment's granted actions with its CLI's native mechanism — never just the prompt:
58
-
59
68
  | Driver | Fuel | Mechanism |
60
69
  | --- | --- | --- |
61
- | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant; bounded verification commands |
62
- | `codex` | pump or local | sandbox `read-only` / `workspace-write`; network enabled only with `pr_create`; resumes by thread id |
63
- | `opencode` | pump or local | `plan`/`build` agents; per-run `opencode.json` bash deny list (shared `deniedCommands`); `--session` resume; `--format json` |
64
- | `cursor` | local only | per-run `.cursor/cli.json` `permissions` allow/deny (no `--force`); read-only runs in `--mode plan` |
65
- | `kiro` | local only | `--trust-tools=fs_read,fs_write,execute_bash` allowlist; `--no-interactive`; `--resume-id`. Tool-level only — no per-command scoping, so a bash grant trusts `execute_bash` wholesale (Antigravity-tier) |
66
- | `antigravity` | local only | `-p --mode plan` (read-only) / `--mode accept-edits --sandbox` (write). Mode-level, coarser than per-tool; plain-text output; resume not surfaced |
67
-
68
- Every driver refuses to start when grants map to nothing, fails closed on missing logins, and shares one denied-command list (merge, force-push, deploy, kubectl, terraform).
69
-
70
- **Verification status:**
71
- - `claude-code`, `codex`, `cursor`, `opencode`, `antigravity` — verified end-to-end against their real CLIs (agy 1.1.3: `-p --mode` args, Google login, plain-text output confirmed through the driver).
72
- - `kiro` — flag surface and login preflight verified against kiro-cli 2.13.0 (`chat --no-interactive --trust-tools --resume-id`; tool names; `whoami`-based login check that avoids the browser-login hang). A full run is **pending a `KIRO_API_KEY`** — kiro-cli requires auth to execute and cannot be exercised offline.
70
+ | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant |
71
+ | `codex` | pump or local | sandbox `read-only` / `workspace-write` |
72
+ | `opencode` | pump or local | per-run `opencode.json` bash deny list |
73
+ | `cursor` | local only | per-run `.cursor/cli.json` permissions |
74
+ | `kiro` | local only | `--trust-tools` allowlist |
75
+ | `antigravity` | local only | `-p --mode plan` / `accept-edits` |
76
+
77
+ Cursor/Kiro/Antigravity require local fuel (`ops install` sets this for you).
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { resolve, dirname, join as pathJoin } from "node:path";
4
- import { hostname, userInfo } from "node:os";
4
+ import { hostname, homedir, platform, userInfo } from "node:os";
5
5
  import { spawn } from "node:child_process";
6
- import { readFileSync } from "node:fs";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
8
+ import { stdin as input, stdout as output } from "node:process";
7
9
  import { fileURLToPath } from "node:url";
8
- import { ConduitClient } from "./client.js";
9
- import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
+ import { ConduitClient, ConduitRequestError } from "./client.js";
11
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
12
  import { runMcp } from "./mcp.js";
11
13
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
12
14
  import { DRIVERS } from "./driver.js";
@@ -15,6 +17,7 @@ import { buildWorkspaceBrief } from "./brief.js";
15
17
  import { ensureCheckout } from "./checkout.js";
16
18
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
17
19
  import { installRunnerService, uninstallRunnerService } from "./service.js";
20
+ import { OPS_VERBS, runOps } from "./ops.js";
18
21
  const [command] = process.argv.slice(2);
19
22
  /** Read our own package version so every runner start logs exactly which build is live. */
20
23
  function bridgeVersion() {
@@ -54,6 +57,27 @@ async function connect() {
54
57
  throw new Error(data.error?.message ?? `Connect failed (${response.status})`);
55
58
  await finishConnection(baseUrl, data, parseFuelSource(values.fuel));
56
59
  }
60
+ async function promptLine(question) {
61
+ if (!input.isTTY || !output.isTTY) {
62
+ throw new Error("Interactive input is unavailable; pass the value on the command line");
63
+ }
64
+ const rl = createInterface({ input, output });
65
+ try {
66
+ return (await rl.question(question)).trim();
67
+ }
68
+ finally {
69
+ rl.close();
70
+ }
71
+ }
72
+ async function resolveJoinOrganization(explicit) {
73
+ const raw = explicit?.trim() || await promptLine("Join which org? ");
74
+ const organization = raw.toLowerCase();
75
+ if (!organization)
76
+ throw new Error("Organization slug is required");
77
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
78
+ throw new Error("Organization must be its lowercase workspace slug");
79
+ return organization;
80
+ }
57
81
  async function join() {
58
82
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
59
83
  url: { type: "string" }, resume: { type: "boolean" }, machine: { type: "string" }, operator: { type: "string" },
@@ -75,13 +99,11 @@ async function join() {
75
99
  if (pending && !values.resume)
76
100
  console.log(`Resuming the pending connection request for ${pending.baseUrl}.`);
77
101
  if (!pending) {
78
- if (!values.url || !values.organization) {
79
- throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization", "<slug>", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
102
+ if (!values.url) {
103
+ throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "[--organization <slug>]", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
80
104
  }
81
105
  const baseUrl = normalizeBaseUrl(values.url);
82
- const organization = values.organization.trim().toLowerCase();
83
- if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
84
- throw new Error("Organization must be its lowercase workspace slug");
106
+ const organization = await resolveJoinOrganization(values.organization);
85
107
  // A config that fails validation (for example pre-organization) must not
86
108
  // block rejoining — join replaces it with a complete identity.
87
109
  const connected = await loadConfigIfPresent().catch(() => null);
@@ -296,6 +318,56 @@ async function uninstallService() {
296
318
  const result = await uninstallRunnerService();
297
319
  console.log(`Removed Conduit runner service (${result.platform}): ${result.path}`);
298
320
  }
321
+ /** Copy cross-platform ops launchers to ~/conduit/ops (default). Env stays in ~/.config. */
322
+ async function initOps() {
323
+ const targetArg = process.argv[3];
324
+ const target = resolve(targetArg ?? pathJoin(homedir(), "conduit", "ops"));
325
+ const packageRoot = pathJoin(dirname(fileURLToPath(import.meta.url)), "..");
326
+ const source = pathJoin(packageRoot, "ops");
327
+ if (!existsSync(source)) {
328
+ throw new Error(`Ops kit missing from package at ${source}. Reinstall @miraland-labs/conduit-bridge.`);
329
+ }
330
+ mkdirSync(target, { recursive: true });
331
+ for (const name of readdirSync(source)) {
332
+ cpSync(pathJoin(source, name), pathJoin(target, name));
333
+ if (name.endsWith(".sh") || name === "conduit-ops")
334
+ chmodSync(pathJoin(target, name), 0o755);
335
+ }
336
+ const configDir = pathJoin(homedir(), ".config", "conduit");
337
+ const envPath = pathJoin(configDir, "ops.env");
338
+ const host = platform();
339
+ console.log(`Ops launchers installed at ${target}`);
340
+ console.log(`Config file (separate): ${envPath}`);
341
+ console.log("");
342
+ console.log("Works the same on macOS, Linux, and Windows via:");
343
+ console.log(` ${bridgeUsage("ops", "<connect|install|online|offline|status|disconnect|uninstall>")}`);
344
+ console.log("");
345
+ console.log("1. Edit env once:");
346
+ console.log(` Create folder: ${configDir}`);
347
+ console.log(` Copy ${pathJoin(target, "env.example")} → ${envPath}`);
348
+ console.log(` # edit ${envPath}`);
349
+ console.log("2. Connect (approve in browser):");
350
+ if (host === "win32") {
351
+ console.log(` ${pathJoin(target, "connect.cmd")}`);
352
+ console.log(" # or: npx @miraland-labs/conduit-bridge@latest ops connect");
353
+ console.log("3. Install / start runner:");
354
+ console.log(` ${pathJoin(target, "install.cmd")}`);
355
+ }
356
+ else {
357
+ console.log(` ${pathJoin(target, "connect.sh")}`);
358
+ console.log(" # or: npx @miraland-labs/conduit-bridge@latest ops connect");
359
+ console.log("3. Install runner:");
360
+ console.log(` ${pathJoin(target, "install.sh")}`);
361
+ }
362
+ console.log("Later: status · online · offline · disconnect (same names .sh / .cmd)");
363
+ }
364
+ async function opsCommand() {
365
+ const verb = process.argv[3];
366
+ if (!verb || !OPS_VERBS.includes(verb)) {
367
+ throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|online|offline|status|disconnect|uninstall>", "[driver…]")}`);
368
+ }
369
+ await runOps(verb, process.argv.slice(4));
370
+ }
299
371
  async function fuelCommand() {
300
372
  const mode = process.argv[3];
301
373
  if (mode !== "local" && mode !== "conduit") {
@@ -448,11 +520,45 @@ async function heartbeat(client, config, brief, processOnlineIds) {
448
520
  ...(brief ? { workspace_brief: brief } : {}),
449
521
  }) });
450
522
  }
523
+ async function disconnect() {
524
+ const { values } = parseArgs({ args: process.argv.slice(3), options: { yes: { type: "boolean", short: "y" } } });
525
+ const config = await loadConfigIfPresent().catch(() => null);
526
+ if (!config) {
527
+ await clearLocalConnection();
528
+ console.log("This Bridge is not connected to an organization.");
529
+ return;
530
+ }
531
+ if (!values.yes) {
532
+ const answer = (await promptLine(`Disengage this computer from the organization and clear local credentials? [y/N] `)).toLowerCase();
533
+ if (answer !== "y" && answer !== "yes") {
534
+ console.log("Cancelled.");
535
+ return;
536
+ }
537
+ }
538
+ try {
539
+ await new ConduitClient(config).request("/runner/v1/disengage", { method: "POST", body: "{}" });
540
+ console.log("Conduit disengaged this computer from the organization.");
541
+ }
542
+ catch (error) {
543
+ // Local clear still proceeds when the server already revoked the key (admin disengage).
544
+ if (error instanceof ConduitRequestError && (error.status === 401 || error.status === 404)) {
545
+ console.warn(`Server disengage skipped (${error.status}): ${redactSecrets(error.message)}`);
546
+ }
547
+ else {
548
+ throw error;
549
+ }
550
+ }
551
+ await clearLocalConnection();
552
+ console.log("Local Bridge credentials cleared. Run join to connect again (same or another org).");
553
+ console.log(`Tip: stop a background runner with \`${bridgeUsage("uninstall-service")}\` if one is installed.`);
554
+ }
451
555
  try {
452
556
  if (command === "connect")
453
557
  await connect();
454
558
  else if (command === "join")
455
559
  await join();
560
+ else if (command === "disconnect")
561
+ await disconnect();
456
562
  else if (command === "fuel")
457
563
  await fuelCommand();
458
564
  else if (command === "drivers")
@@ -465,8 +571,12 @@ try {
465
571
  await installService();
466
572
  else if (command === "uninstall-service")
467
573
  await uninstallService();
574
+ else if (command === "init-ops")
575
+ await initOps();
576
+ else if (command === "ops")
577
+ await opsCommand();
468
578
  else
469
- throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
579
+ throw new Error(`Usage: ${BRIDGE_NPX} <join|disconnect|connect|fuel|drivers|mcp|runner|install-service|uninstall-service|init-ops|ops>`);
470
580
  }
471
581
  catch (error) {
472
582
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/config.js CHANGED
@@ -260,6 +260,17 @@ export async function clearPendingConnection() {
260
260
  throw error;
261
261
  });
262
262
  }
263
+ /** Drop local org credentials after a server-side disengage. Keeps installation.json. */
264
+ export async function clearLocalConnection() {
265
+ await withConfigLock(async () => {
266
+ for (const target of [path, runtimePath, pendingPath]) {
267
+ await unlink(target).catch((error) => {
268
+ if (error.code !== "ENOENT")
269
+ throw error;
270
+ });
271
+ }
272
+ });
273
+ }
263
274
  export async function loadOrCreateInstallationId() {
264
275
  try {
265
276
  const identity = JSON.parse(await readFile(installationPath, "utf8"));
package/dist/driver.js CHANGED
@@ -3,6 +3,15 @@ import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
+ function deliveryLanguageRule(language) {
7
+ if (language === "zh") {
8
+ return "- Write every human-facing delivery packet string (outcome, changes, verification, assumptions, risks, limitations, criterion text) in Chinese (Simplified). Keep JSON keys and enum token values in English.";
9
+ }
10
+ if (language === "en") {
11
+ return "- Write every human-facing delivery packet string (outcome, changes, verification, assumptions, risks, limitations, criterion text) in English. Keep JSON keys and enum token values in English.";
12
+ }
13
+ return null;
14
+ }
6
15
  export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
7
16
  export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["exact criterion text supported by this evidence"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}';
8
17
  /**
@@ -131,7 +140,8 @@ export function buildAssignmentPrompt(context) {
131
140
  ...(context.grants.includes("branch_create") ? branchCreateCommands : []),
132
141
  ...(context.grants.includes("pr_create") ? prCreateCommands : []),
133
142
  ];
134
- lines.push("", "RULES", "- Stay within the change scope and boundaries.",
143
+ const languageRule = deliveryLanguageRule(packageContext?.working_language ?? spec.working_language);
144
+ lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []),
135
145
  // Name the executable commands. The shell allow-list is exact, so an agent that guesses at
136
146
  // `bash scripts/validate-pack.sh`, `python3 ...` or `echo` gets every call rejected and concludes
137
147
  // it has no shell at all — observed twice on live Daily Signal runs, both times while the
package/dist/execution.js CHANGED
@@ -44,6 +44,7 @@ const taskSpecSchema = z.object({
44
44
  change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
45
45
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
46
46
  risk_level: z.string().optional(),
47
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
47
48
  });
48
49
  const executionContractSchema = z.object({
49
50
  repository_fingerprint: z.string().nullable().optional().default(null),
@@ -58,6 +59,7 @@ const workPackageSchema = z.object({
58
59
  acceptance: z.array(z.string()).optional(),
59
60
  change_scope: z.array(z.string()).optional(),
60
61
  required_evidence: z.array(z.string()).optional(),
62
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
61
63
  initiative: z.object({
62
64
  title: z.string().nullable().optional(),
63
65
  desired_outcome: z.string().nullable().optional(),
@@ -65,6 +67,7 @@ const workPackageSchema = z.object({
65
67
  }).optional(),
66
68
  decisions: z.array(z.object({ question: z.string(), decision: z.string() })).optional(),
67
69
  rework_feedback: z.string().nullable().optional(),
70
+ working_language: z.enum(["en", "zh"]).optional(),
68
71
  }).nullable().optional();
69
72
  export async function renewLeases(client, config) {
70
73
  for (const active of Object.values(config.activeAttempts)) {
@@ -272,7 +275,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
272
275
  const task = taskDetailSchema.parse(detail.task);
273
276
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
274
277
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
275
- const spec = parseTaskSpec(task.spec_json);
278
+ const parsedSpec = parseTaskSpec(task.spec_json);
279
+ const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
280
+ const spec = { ...parsedSpec, deliverable };
281
+ const artifactDelivery = deliverable === "artifact";
276
282
  const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
277
283
  const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
278
284
  // Recompile current state at claim time — earlier packages may have moved the repo.
@@ -309,6 +315,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
309
315
  }
310
316
  }
311
317
  let attemptWorkspace;
318
+ let deliverySubmitted = false;
312
319
  if (options.existingWorktree) {
313
320
  attemptWorkspace = options.existingWorktree;
314
321
  }
@@ -486,20 +493,26 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
486
493
  }
487
494
  await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
488
495
  await submitFinishedDelivery(client, taskId);
496
+ deliverySubmitted = true;
489
497
  console.log(`Assignment ${taskId} delivered for review and acceptance.`);
490
498
  }
491
499
  finally {
492
500
  clearInterval(renewTimer);
493
501
  if (heartbeatTimer)
494
502
  clearInterval(heartbeatTimer);
495
- // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
496
- // Terminal submit may already have cleared activeAttempts never throw from cleanup.
497
- const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
498
- await removeAttemptWorktree(workspace, path).catch((error) => {
499
- console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
500
- });
501
- if (config.activeAttempts[taskId]) {
502
- await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
503
+ if (artifactDelivery && !deliverySubmitted) {
504
+ console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
505
+ }
506
+ else {
507
+ // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
508
+ // Terminal submit may already have cleared activeAttempts — never throw from cleanup.
509
+ const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
510
+ await removeAttemptWorktree(workspace, path).catch((error) => {
511
+ console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
512
+ });
513
+ if (config.activeAttempts[taskId]) {
514
+ await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
515
+ }
503
516
  }
504
517
  }
505
518
  }
@@ -658,6 +671,24 @@ async function prepareDelivery(client, attemptId, taskId, report) {
658
671
  }
659
672
  export function validateDeliveryReport(report, spec, grants = []) {
660
673
  validateChangeScope(report, spec.change_scope ?? []);
674
+ if (spec.deliverable === "artifact") {
675
+ const published = report.evidence.some((item) => {
676
+ if (!["preview", "research", "documentation"].includes(item.kind))
677
+ return false;
678
+ if (!item.uri || !item.digest || !/^sha256:[0-9a-f]{64}$/i.test(item.digest))
679
+ return false;
680
+ try {
681
+ const protocol = new URL(item.uri).protocol;
682
+ return protocol === "https:" || protocol === "http:";
683
+ }
684
+ catch {
685
+ return false;
686
+ }
687
+ });
688
+ if (!published) {
689
+ throw new Error("Artifact delivery requires a published HTTP(S) URL and sha256 digest");
690
+ }
691
+ }
661
692
  // Invariant 9: grants bound what an attempt may do. Cursor/plan-mode are soft hints the headless
662
693
  // agent can leave, so repository-write authority is enforced here on the reported delivery: a task
663
694
  // without repo_write must not report repository changes.
package/dist/ops.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Cross-platform operator helpers (macOS / Linux / Windows).
3
+ * Invoked as: npx @miraland-labs/conduit-bridge ops <connect|install|…>
4
+ */
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir, platform } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ export const OPS_VERBS = [
10
+ "connect", "install", "online", "offline", "status", "disconnect", "uninstall",
11
+ ];
12
+ const LOCAL_FUEL_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
13
+ export function defaultOpsEnvPath(home = homedir()) {
14
+ return join(home, ".config", "conduit", "ops.env");
15
+ }
16
+ /** Expand $HOME, %USERPROFILE%, and leading ~ for cross-platform env values. */
17
+ export function expandOpsValue(value, home = homedir()) {
18
+ let next = value.trim();
19
+ if ((next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
20
+ next = next.slice(1, -1);
21
+ }
22
+ next = next.replace(/%USERPROFILE%/gi, home).replace(/%HOME%/gi, home);
23
+ next = next.replace(/\$HOME\b/g, home).replace(/\$\{HOME\}/g, home);
24
+ if (next.startsWith("~/") || next === "~")
25
+ next = join(home, next.slice(2));
26
+ return next;
27
+ }
28
+ export function parseOpsEnvFile(text, home = homedir()) {
29
+ const out = {};
30
+ for (const raw of text.split(/\r?\n/)) {
31
+ const line = raw.trim();
32
+ if (!line || line.startsWith("#"))
33
+ continue;
34
+ const eq = line.indexOf("=");
35
+ if (eq <= 0)
36
+ continue;
37
+ const key = line.slice(0, eq).trim();
38
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
39
+ continue;
40
+ out[key] = expandOpsValue(line.slice(eq + 1), home);
41
+ }
42
+ return out;
43
+ }
44
+ export function loadOpsEnv(home = homedir(), cwd = process.cwd()) {
45
+ const candidates = [
46
+ process.env.CONDUIT_OPS_ENV,
47
+ join(cwd, ".conduit-ops.env"),
48
+ defaultOpsEnvPath(home),
49
+ ].filter((path) => Boolean(path));
50
+ let loadedFrom = null;
51
+ let fileValues = {};
52
+ for (const path of candidates) {
53
+ if (!existsSync(path))
54
+ continue;
55
+ fileValues = parseOpsEnvFile(readFileSync(path, "utf8"), home);
56
+ loadedFrom = path;
57
+ break;
58
+ }
59
+ const pick = (key, fallback) => {
60
+ const fromEnv = process.env[key];
61
+ if (fromEnv !== undefined && fromEnv !== "")
62
+ return expandOpsValue(fromEnv, home);
63
+ if (fileValues[key])
64
+ return fileValues[key];
65
+ return fallback;
66
+ };
67
+ return {
68
+ CONDUIT_URL: pick("CONDUIT_URL", ""),
69
+ CONDUIT_ORG: pick("CONDUIT_ORG", ""),
70
+ CONDUIT_WORKSPACE: pick("CONDUIT_WORKSPACE", ""),
71
+ CONDUIT_REPO: pick("CONDUIT_REPO", ""),
72
+ CONDUIT_DRIVERS: pick("CONDUIT_DRIVERS", "cursor"),
73
+ CONDUIT_ROLES: pick("CONDUIT_ROLES", "implement research"),
74
+ loadedFrom,
75
+ };
76
+ }
77
+ export function requireOpsEnv(env) {
78
+ if (env.loadedFrom || env.CONDUIT_URL || env.CONDUIT_WORKSPACE)
79
+ return;
80
+ const path = defaultOpsEnvPath();
81
+ throw new Error(`Missing ops env. Create it once:\n` +
82
+ ` Create folder: ${join(homedir(), ".config", "conduit")}\n` +
83
+ ` Copy env.example to: ${path}\n` +
84
+ ` Edit that file, then retry.`);
85
+ }
86
+ export function splitOpsList(value) {
87
+ return value.split(/[,\s]+/).map((item) => item.trim()).filter(Boolean);
88
+ }
89
+ export function resolveDrivers(env, argv) {
90
+ const fromArgs = argv.map((item) => item.trim()).filter(Boolean);
91
+ const drivers = fromArgs.length ? fromArgs : splitOpsList(env.CONDUIT_DRIVERS);
92
+ if (!drivers.length)
93
+ throw new Error("No drivers. Set CONDUIT_DRIVERS or pass ids.");
94
+ return drivers;
95
+ }
96
+ /** Quote args for a copy-pasteable shell/cmd line (paths with spaces). */
97
+ export function shellQuoteArgs(args) {
98
+ return args.map((arg) => {
99
+ if (!/[^\w@%+=:,./\\-]/i.test(arg))
100
+ return arg;
101
+ return `"${arg.replaceAll('"', '\\"')}"`;
102
+ }).join(" ");
103
+ }
104
+ function defaultRunBridge(args, options = {}) {
105
+ // Re-enter this package's CLI in a child so ops stays a thin orchestrator.
106
+ const cli = process.argv[1];
107
+ const node = process.execPath;
108
+ const result = spawnSync(node, [cli, ...args], { stdio: "inherit", env: process.env });
109
+ if (result.error) {
110
+ if (options.allowFail)
111
+ return;
112
+ throw result.error;
113
+ }
114
+ if (result.status !== 0) {
115
+ if (options.allowFail)
116
+ return;
117
+ process.exit(result.status ?? 1);
118
+ }
119
+ }
120
+ export async function runOps(verb, argv = [], deps = {}) {
121
+ const runBridge = deps.runBridge ?? defaultRunBridge;
122
+ const host = deps.host ?? platform();
123
+ const env = deps.env ?? loadOpsEnv();
124
+ if (verb === "disconnect") {
125
+ runBridge(["uninstall-service"], { allowFail: true });
126
+ runBridge(["disconnect", "--yes"]);
127
+ console.log("Disconnected. Run: npx @miraland-labs/conduit-bridge@latest ops connect");
128
+ return;
129
+ }
130
+ if (verb === "uninstall") {
131
+ runBridge(["uninstall-service"]);
132
+ return;
133
+ }
134
+ if (verb === "status") {
135
+ if (env.loadedFrom) {
136
+ console.log(`Env: ${env.loadedFrom}`);
137
+ console.log(`URL: ${env.CONDUIT_URL || "(unset)"}`);
138
+ console.log(`Workspace: ${env.CONDUIT_WORKSPACE || "(unset)"}`);
139
+ console.log(`Repo: ${env.CONDUIT_REPO || "(none)"}`);
140
+ console.log(`Drivers: ${env.CONDUIT_DRIVERS}`);
141
+ console.log(`Roles: ${env.CONDUIT_ROLES}`);
142
+ console.log("");
143
+ }
144
+ else {
145
+ console.log("No ops.env loaded (showing drivers only).\n");
146
+ }
147
+ runBridge(["drivers"]);
148
+ return;
149
+ }
150
+ // Lane toggles only need Bridge config + optional driver ids — not a full ops.env.
151
+ if (verb === "online" || verb === "offline") {
152
+ const drivers = resolveDrivers(env, argv);
153
+ runBridge(["drivers", verb, ...drivers]);
154
+ return;
155
+ }
156
+ requireOpsEnv(env);
157
+ if (verb === "connect") {
158
+ if (!env.CONDUIT_URL)
159
+ throw new Error(`Set CONDUIT_URL in ${defaultOpsEnvPath()}`);
160
+ const args = ["join", "--url", env.CONDUIT_URL];
161
+ if (env.CONDUIT_ORG)
162
+ args.push("--organization", env.CONDUIT_ORG);
163
+ for (const role of splitOpsList(env.CONDUIT_ROLES))
164
+ args.push("--capability", role);
165
+ console.log(`Connecting to ${env.CONDUIT_URL} (roles: ${env.CONDUIT_ROLES})`);
166
+ console.log("Approve this computer in Connect when the browser opens.");
167
+ runBridge(args);
168
+ return;
169
+ }
170
+ if (verb === "install") {
171
+ if (!env.CONDUIT_WORKSPACE)
172
+ throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
173
+ const workspace = resolve(expandOpsValue(env.CONDUIT_WORKSPACE));
174
+ const drivers = resolveDrivers(env, argv);
175
+ for (const id of drivers) {
176
+ if (LOCAL_FUEL_DRIVERS.has(id))
177
+ runBridge(["drivers", "fuel", id, "local"]);
178
+ }
179
+ runBridge(["drivers", "online", ...drivers]);
180
+ if (host === "win32") {
181
+ const runnerArgs = ["runner", "--workspace", workspace];
182
+ if (env.CONDUIT_REPO)
183
+ runnerArgs.push("--ensure-checkout", env.CONDUIT_REPO);
184
+ console.log("Windows: background install-service is not available.");
185
+ console.log("Keep a terminal open and run:");
186
+ console.log(` npx @miraland-labs/conduit-bridge@latest ${shellQuoteArgs(runnerArgs)}`);
187
+ console.log(`Drivers online: ${drivers.join(", ")}`);
188
+ return;
189
+ }
190
+ const installArgs = ["install-service", "--workspace", workspace];
191
+ if (env.CONDUIT_REPO)
192
+ installArgs.push("--ensure-checkout", env.CONDUIT_REPO);
193
+ console.log(`Installing runner for ${workspace} (drivers: ${drivers.join(", ")})`);
194
+ runBridge(installArgs);
195
+ console.log("Done. Check with: npx @miraland-labs/conduit-bridge@latest ops status");
196
+ return;
197
+ }
198
+ throw new Error(`Unknown ops verb: ${verb}`);
199
+ }
package/dist/service.js CHANGED
@@ -34,7 +34,24 @@ export function runnerProgramArguments(options = {}) {
34
34
  args.push("--agent-timeout-minutes", options.agentTimeoutMinutes);
35
35
  return args;
36
36
  }
37
- export function launchdPlist(programArguments, stdoutPath) {
37
+ /**
38
+ * PATH for LaunchAgent/systemd so agent CLIs in ~/.local/bin and Homebrew resolve
39
+ * without the operator hand-editing the unit.
40
+ */
41
+ export function runnerServicePath(home = homedir(), nodeBin = dirname(process.execPath)) {
42
+ const parts = [
43
+ nodeBin,
44
+ join(home, ".local", "bin"),
45
+ "/opt/homebrew/bin",
46
+ "/usr/local/bin",
47
+ "/usr/bin",
48
+ "/bin",
49
+ "/usr/sbin",
50
+ "/sbin",
51
+ ];
52
+ return [...new Set(parts.filter(Boolean))].join(":");
53
+ }
54
+ export function launchdPlist(programArguments, stdoutPath, pathEnv = runnerServicePath()) {
38
55
  const argsXml = programArguments.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
39
56
  return `<?xml version="1.0" encoding="UTF-8"?>
40
57
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -46,6 +63,11 @@ export function launchdPlist(programArguments, stdoutPath) {
46
63
  <array>
47
64
  ${argsXml}
48
65
  </array>
66
+ <key>EnvironmentVariables</key>
67
+ <dict>
68
+ <key>PATH</key>
69
+ <string>${escapeXml(pathEnv)}</string>
70
+ </dict>
49
71
  <key>RunAtLoad</key>
50
72
  <true/>
51
73
  <key>KeepAlive</key>
@@ -58,7 +80,7 @@ ${argsXml}
58
80
  </plist>
59
81
  `;
60
82
  }
61
- export function systemdUserUnit(programArguments, workingDirectory) {
83
+ export function systemdUserUnit(programArguments, workingDirectory, pathEnv = runnerServicePath()) {
62
84
  const execStart = programArguments.map(escapeSystemd).join(" ");
63
85
  return `[Unit]
64
86
  Description=Conduit Bridge runner
@@ -68,6 +90,7 @@ Wants=network-online.target
68
90
  [Service]
69
91
  Type=simple
70
92
  WorkingDirectory=${workingDirectory}
93
+ Environment=PATH=${escapeSystemd(pathEnv)}
71
94
  ExecStart=${execStart}
72
95
  Restart=always
73
96
  RestartSec=5
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: ./conduit-ops <connect|install|online|offline|status|disconnect|uninstall> [args…]
3
+ set -euo pipefail
4
+ if [[ $# -lt 1 ]]; then
5
+ echo "Usage: $0 <connect|install|online|offline|status|disconnect|uninstall> [args…]" >&2
6
+ exit 1
7
+ fi
8
+ verb=$1
9
+ shift
10
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops "$verb" "$@"
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ REM Usage: conduit-ops.cmd <connect|install|online|offline|status|disconnect|uninstall> [args…]
3
+ if "%~1"=="" (
4
+ echo Usage: %~nx0 ^<connect^|install^|online^|offline^|status^|disconnect^|uninstall^> [args…]
5
+ exit /b 1
6
+ )
7
+ npx --yes @miraland-labs/conduit-bridge@latest ops %*
8
+ if errorlevel 1 exit /b %errorlevel%
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use connect.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops connect %*
4
+ if errorlevel 1 exit /b %errorlevel%
package/ops/connect.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use connect.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops connect "$@"
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use disconnect.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops disconnect %*
4
+ if errorlevel 1 exit /b %errorlevel%
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use disconnect.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops disconnect "$@"
@@ -0,0 +1,13 @@
1
+ # Configuration (not scripts). Copy to ~/.config/conduit/ops.env and edit once.
2
+ # Same file works on macOS, Linux, and Windows ($HOME / %USERPROFILE% / ~ are expanded).
3
+ #
4
+ # Cross-platform commands (any OS):
5
+ # npx @miraland-labs/conduit-bridge@latest ops connect
6
+ # npx @miraland-labs/conduit-bridge@latest ops install
7
+ # Local shortcuts after init-ops: ~/conduit/ops/*.sh (Mac/Linux) or *.cmd (Windows)
8
+ CONDUIT_URL=https://api.conduit.miraland.io
9
+ # CONDUIT_ORG=your-org-slug
10
+ CONDUIT_WORKSPACE=$HOME/agentic/promote-pr402
11
+ # CONDUIT_REPO=https://github.com/miralandlabs/promote-pr402.git
12
+ CONDUIT_DRIVERS=cursor
13
+ CONDUIT_ROLES=implement research
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use install.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops install %*
4
+ if errorlevel 1 exit /b %errorlevel%
package/ops/install.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use install.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops install "$@"
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use offline.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops offline %*
4
+ if errorlevel 1 exit /b %errorlevel%
package/ops/offline.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use offline.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops offline "$@"
package/ops/online.cmd ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use online.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops online %*
4
+ if errorlevel 1 exit /b %errorlevel%
package/ops/online.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use online.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops online "$@"
package/ops/status.cmd ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use status.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops status %*
4
+ if errorlevel 1 exit /b %errorlevel%
package/ops/status.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use status.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops status "$@"
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Windows launcher. macOS/Linux: use uninstall.sh
3
+ npx --yes @miraland-labs/conduit-bridge@latest ops uninstall %*
4
+ if errorlevel 1 exit /b %errorlevel%
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-platform launcher (macOS/Linux). Windows: use uninstall.cmd
3
+ set -euo pipefail
4
+ exec npx --yes @miraland-labs/conduit-bridge@latest ops uninstall "$@"
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.0",
4
- "description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
3
+ "version": "0.9.2",
4
+ "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "ops",
11
12
  "README.md"
12
13
  ],
13
14
  "engines": {