@elmundi/ship-cli 0.12.1 → 0.14.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/bin/shipctl.mjs CHANGED
@@ -140,13 +140,21 @@ try {
140
140
  process.exit(0);
141
141
  }
142
142
 
143
+ if (cmd === "process") {
144
+ const { processCommand } = await import("../lib/commands/process.mjs");
145
+ await processCommand(ctx, rest);
146
+ process.exit(0);
147
+ }
148
+
143
149
  if (cmd === "migrate") {
144
150
  const { migrateCommand } = await import("../lib/commands/migrate.mjs");
145
151
  await migrateCommand(ctx, rest);
146
152
  process.exit(0);
147
153
  }
148
154
 
149
- if (cmd === "run") {
155
+ if (cmd === "run" || cmd === "agent-run") {
156
+ // ``agent-run`` is a back-compat alias for ``run`` — older trigger
157
+ // workflows still spell it that way until they re-seed.
150
158
  const { runCommand } = await import("../lib/commands/run.mjs");
151
159
  await runCommand(ctx, rest);
152
160
  process.exit(0);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Cursor Cloud Agent runtime adapter.
3
+ *
4
+ * Wraps the public ``POST https://api.cursor.com/v0/agents`` API the
5
+ * ElMundi sibling repo uses (``tools/linear-agent/scripts/cloud-agent-launch.mjs``).
6
+ * The shape Ship needs:
7
+ *
8
+ * 1. Launch an agent against ``repo`` at ``ref`` with ``prompt``.
9
+ * 2. Poll until the agent's status is one of the terminal values
10
+ * (``FINISHED`` / ``ERRORED`` / ``CANCELLED``).
11
+ * 3. Return the branch name + final status to ``shipctl run``. Side
12
+ * effects (tracker writes / inbox rows) happen via the agent's
13
+ * own ``POST /agent-runs/finish`` call from inside Cursor — the
14
+ * CLI no longer reads a state file off the branch.
15
+ *
16
+ * Auth: ``CURSOR_API_KEY`` env var, sent as Basic ``<key>:`` per Cursor's
17
+ * docs.
18
+ */
19
+
20
+ import { Buffer } from "node:buffer";
21
+
22
+ const BASE = "https://api.cursor.com";
23
+ const TERMINAL_STATUSES = new Set(["FINISHED", "ERRORED", "CANCELLED"]);
24
+
25
+
26
+ function authHeader() {
27
+ const key = (process.env.CURSOR_API_KEY || "").trim();
28
+ if (!key) {
29
+ throw new Error("CURSOR_API_KEY is not set");
30
+ }
31
+ return "Basic " + Buffer.from(`${key}:`).toString("base64");
32
+ }
33
+
34
+
35
+ async function postJson(path, body) {
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: authHeader(),
41
+ },
42
+ body: JSON.stringify(body),
43
+ });
44
+ const text = await res.text();
45
+ if (!res.ok) {
46
+ throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
47
+ }
48
+ return text ? JSON.parse(text) : {};
49
+ }
50
+
51
+
52
+ async function getJson(path) {
53
+ const res = await fetch(`${BASE}${path}`, {
54
+ headers: { Authorization: authHeader() },
55
+ });
56
+ const text = await res.text();
57
+ if (!res.ok) {
58
+ throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
59
+ }
60
+ return text ? JSON.parse(text) : {};
61
+ }
62
+
63
+
64
+ /**
65
+ * Launch a single Cursor agent and poll until it terminates.
66
+ *
67
+ * @param {object} opts
68
+ * @param {string} opts.repoUrl e.g. ``https://github.com/owner/repo``
69
+ * @param {string} opts.ref branch the agent checks out (default ``main``)
70
+ * @param {string} opts.branchName branch the agent commits onto
71
+ * @param {string} opts.prompt full prompt body
72
+ * @param {boolean} [opts.autoCreatePr] open a PR when done (default false)
73
+ * @param {number} [opts.pollIntervalMs] poll cadence (default 15s)
74
+ * @param {number} [opts.timeoutMs] total deadline (default 30 min)
75
+ * @param {(line: string) => void} [opts.onLog] streaming log hook
76
+ * @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
77
+ */
78
+ export async function runCursorAgent({
79
+ repoUrl,
80
+ ref = "main",
81
+ branchName,
82
+ prompt,
83
+ autoCreatePr = false,
84
+ pollIntervalMs = 15_000,
85
+ timeoutMs = 30 * 60 * 1000,
86
+ onLog = (l) => console.error(`[cursor] ${l}`),
87
+ } = {}) {
88
+ if (!repoUrl) throw new Error("runCursorAgent: repoUrl required");
89
+ if (!branchName) throw new Error("runCursorAgent: branchName required");
90
+ if (!prompt || typeof prompt !== "string") {
91
+ throw new Error("runCursorAgent: prompt required");
92
+ }
93
+
94
+ const launch = await postJson("/v0/agents", {
95
+ prompt: { text: prompt },
96
+ source: { repository: repoUrl, ref },
97
+ target: {
98
+ branchName,
99
+ autoCreatePr,
100
+ openAsCursorGithubApp: false,
101
+ },
102
+ });
103
+ const agentId = launch.id || launch.agentId || launch.agent_id;
104
+ if (!agentId) {
105
+ throw new Error(`Cursor launch returned no agent id: ${JSON.stringify(launch).slice(0, 300)}`);
106
+ }
107
+ onLog(`launched agent=${agentId} branch=${branchName} ref=${ref}`);
108
+
109
+ const deadline = Date.now() + timeoutMs;
110
+ let lastStatus = launch.status || "CREATING";
111
+ while (Date.now() < deadline) {
112
+ if (TERMINAL_STATUSES.has(String(lastStatus).toUpperCase())) {
113
+ onLog(`agent terminal: status=${lastStatus}`);
114
+ return { agentId, branchName, status: lastStatus, raw: launch };
115
+ }
116
+ await sleep(pollIntervalMs);
117
+ let snapshot;
118
+ try {
119
+ snapshot = await getJson(`/v0/agents/${encodeURIComponent(agentId)}`);
120
+ } catch (err) {
121
+ onLog(`poll error (continuing): ${err.message}`);
122
+ continue;
123
+ }
124
+ const next = (snapshot.status || "").toString();
125
+ if (next && next !== lastStatus) {
126
+ onLog(`status: ${lastStatus} → ${next}`);
127
+ lastStatus = next;
128
+ }
129
+ if (TERMINAL_STATUSES.has(next.toUpperCase())) {
130
+ return { agentId, branchName, status: next, raw: snapshot };
131
+ }
132
+ }
133
+ throw new Error(
134
+ `Cursor agent ${agentId} did not reach a terminal state within ${Math.round(
135
+ timeoutMs / 1000,
136
+ )}s (last status: ${lastStatus})`,
137
+ );
138
+ }
139
+
140
+
141
+ function sleep(ms) {
142
+ return new Promise((r) => setTimeout(r, ms));
143
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Agent runtime dispatcher.
3
+ *
4
+ * E14 picks the runtime per ``.ship/config.yml`` ``agent.default.provider``
5
+ * (and per-routine override under ``agent.overrides.<routine>``). Today
6
+ * Cursor Cloud is the only one wired in; ``claude`` / ``codex`` slots
7
+ * exist so adding them later is a single new file plus an entry in
8
+ * ``RUNTIMES`` here.
9
+ */
10
+
11
+ import { runCursorAgent } from "./cursor.mjs";
12
+
13
+
14
+ const RUNTIMES = {
15
+ cursor: runCursorAgent,
16
+ // claude: runClaudeCodeAgent, // TODO when we add it
17
+ // codex: runCodexAgent, // TODO when we add it
18
+ };
19
+
20
+ const DEFAULT_PROVIDER = "cursor";
21
+
22
+
23
+ export function resolveProvider(config, routineId) {
24
+ const override = config?.agent?.overrides?.[routineId]?.provider;
25
+ if (override) return String(override).toLowerCase();
26
+ const def = config?.agent?.default?.provider;
27
+ if (def) return String(def).toLowerCase();
28
+ return DEFAULT_PROVIDER;
29
+ }
30
+
31
+
32
+ /**
33
+ * Run the configured agent runtime.
34
+ *
35
+ * @param {string} provider — one of ``RUNTIMES``' keys.
36
+ * @param {object} opts — passed straight through to the runtime.
37
+ * @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
38
+ */
39
+ export async function runAgent(provider, opts) {
40
+ const fn = RUNTIMES[provider];
41
+ if (!fn) {
42
+ const known = Object.keys(RUNTIMES).join(", ") || "(none)";
43
+ throw new Error(
44
+ `agent runtime '${provider}' is not wired in this build. Known: ${known}`,
45
+ );
46
+ }
47
+ return fn(opts);
48
+ }
49
+
50
+
51
+ export const SUPPORTED_PROVIDERS = Object.freeze(Object.keys(RUNTIMES));
@@ -1,20 +1,20 @@
1
1
  export function printHelp() {
2
- console.log(`shipctl — adopt Ship in a repo, sync the catalog, run lanes, report Runs.
2
+ console.log(`shipctl — adopt Ship in a repo, sync the catalog, run routines, report outcomes.
3
3
 
4
4
  Bootstrap a new or existing repo (init / new / doctor), pull the
5
- methodology catalog into .ship/cache (sync), execute one-shot lanes or
6
- emit prompts for the workspace runner (run / lanes / kickoff /
5
+ methodology catalog into .ship/cache (sync), execute one-shot routines or
6
+ emit prompts for the workspace runner (trigger / run / kickoff /
7
7
  callback). Talks to the methodology + orchestration APIs over HTTPS.
8
8
 
9
9
  VOCABULARY
10
- lanes: (.ship/config.yml) operator console: Automations
11
- pattern: (artifact kind) operator console: Plays
12
- pipeline_runs (DB / API) operator console: Runs
13
- attention surface → operator console: Inbox
10
+ process.routines: (.ship/config.yml) repo-local scheduled/manual work
11
+ pattern: (artifact kind) cached role/playbook prompt body
12
+ routine claim: (Ship API) idempotent schedule-window claim
13
+ attention surface → operator console: Inbox
14
14
 
15
- The protocol-stable terms (lanes:, pattern:, pipeline_runs) stay
16
- literal in YAML, CLI flags, and HTTP. Operator-facing prose uses the
17
- console nouns.
15
+ Legacy 'lanes:' configs and '--lane' flags are still accepted as aliases
16
+ so already-seeded repositories keep working while new repos use
17
+ 'process.routines'.
18
18
 
19
19
  GLOBAL FLAGS
20
20
  --base-url URL Methodology API (default: SHIP_API_BASE or
@@ -66,22 +66,19 @@ COMMANDS
66
66
  [--force-unpin] [--dry-run] [--lock] [--json] [--cwd <dir>]
67
67
  — fetch artifacts into .ship/cache. With --lock,
68
68
  also writes .ship/shipctl.lock.json covering
69
- every pattern the declared lanes depend on.
69
+ every pattern the declared routines depend on.
70
70
 
71
71
  Run
72
72
  shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
73
- ask Ship which configured lanes are
74
- due for the current GitHub trigger.
75
- shipctl run --lane <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
76
- [--trigger event|schedule|manual|once]
77
- [--dry-run] [--offline] [--json] [--cwd <dir>]
78
- [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
79
- one-shot dispatch entry point. 'kind: once'
80
- lanes execute fully here; 'kind: lane / event /
81
- schedule' lanes are queued for the workspace
82
- runner via .github/workflows/run-agent.yml.
83
- Reports its terminal status via the callback URL
84
- Ship injected into the workflow.
73
+ compute due routines locally, then claim
74
+ the schedule window in Ship.
75
+ shipctl run --routine <id> [--dry-run] [--json] [--cwd <dir>]
76
+ execute one routine end-to-end:
77
+ resolve pattern, fetch a ticket
78
+ (if FSM-staged), launch the agent
79
+ runtime, exit on terminal status.
80
+ 'shipctl agent-run' is a back-compat
81
+ alias.
85
82
  shipctl lanes install [--only <csv>] [--ref <git-ref>] [--owner <gh>] [--repo <name>]
86
83
  [--shipctl-version <v>] [--dry-run] [--force] [--json] [--cwd <dir>]
87
84
  shipctl lanes list [--json] [--cwd <dir>]
@@ -99,6 +96,17 @@ COMMANDS
99
96
  RunSummary outcome) back to Ship so it can
100
97
  render the outcome row and route any
101
98
  escalations into the Inbox.
99
+ shipctl process prompt --state <id> [--ticket-json <json>] [--policies-file <path>]
100
+ [--cwd <dir>] [--json]
101
+ — assemble a Process/FSM specialist prompt
102
+ bundle with ticket context, allowed
103
+ transitions, policies, and mandatory
104
+ knowledge-first guardrails.
105
+ shipctl process tickets --workspace <id> [--query <text>] [--tracker <kind>] [--json]
106
+ — read-only tracker picker for selecting
107
+ ticket context before building a process
108
+ prompt. Does not create, comment, or
109
+ transition tickets.
102
110
 
103
111
  Knowledge
104
112
  shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>] [--only <csv>] [--json]
@@ -44,10 +44,12 @@
44
44
 
45
45
  const VERSION = "v1";
46
46
 
47
- /** Ship ships two starter buckets today — keep in lockstep with
48
- * ``backend.app.services.catalog.KNOWLEDGE_STARTERS`` and
49
- * ``console/src/lib/api/client.ts#KNOWLEDGE_STARTERS``. */
50
- const KNOWN_SLUGS = ["code-style", "ui-runbook"];
47
+ /** Static starters with source markdown under ``artifacts/knowledge-starters``.
48
+ * Procedural catalog recipes are exposed by the backend under the
49
+ * ``ship-recipes/<pattern-id>`` prefix because that list is generated from
50
+ * on-disk pattern artifacts at runtime. */
51
+ export const STATIC_KNOWLEDGE_SLUGS = ["code-style", "ui-runbook"];
52
+ export const RECIPE_KNOWLEDGE_PREFIX = "ship-recipes/";
51
53
 
52
54
  /**
53
55
  * @param {{baseUrl?: string, json?: boolean}} ctx
@@ -100,7 +102,9 @@ INIT FLAGS
100
102
  Defaults to the most-recently activated repo in
101
103
  the resolved workspace.
102
104
  --only <csv> Comma-separated starter slugs. Defaults to the
103
- full catalog (${KNOWN_SLUGS.join(", ")}).
105
+ full backend catalog, including static starters
106
+ (${STATIC_KNOWLEDGE_SLUGS.join(", ")}) and generated
107
+ recipe starters under ${RECIPE_KNOWLEDGE_PREFIX}<pattern-id>.
104
108
  --base-url URL Workspace control-plane API. See env fallbacks.
105
109
  --json Emit a machine-readable JSON summary.
106
110
 
@@ -134,10 +138,10 @@ async function knowledgeInitCommand(ctx, args) {
134
138
 
135
139
  const selection = opts.only;
136
140
  if (selection !== null) {
137
- const unknown = selection.filter((s) => !KNOWN_SLUGS.includes(s));
141
+ const unknown = selection.filter((s) => !isKnownKnowledgeStarterSlug(s));
138
142
  if (unknown.length) {
139
143
  console.error(
140
- `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown: ${KNOWN_SLUGS.join(", ")}`,
144
+ `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown static slugs: ${STATIC_KNOWLEDGE_SLUGS.join(", ")}; recipe slugs must start with ${RECIPE_KNOWLEDGE_PREFIX}`,
141
145
  );
142
146
  process.exit(1);
143
147
  }
@@ -173,6 +177,13 @@ async function knowledgeInitCommand(ctx, args) {
173
177
  );
174
178
  }
175
179
 
180
+ export function isKnownKnowledgeStarterSlug(slug) {
181
+ return (
182
+ STATIC_KNOWLEDGE_SLUGS.includes(slug) ||
183
+ slug.startsWith(RECIPE_KNOWLEDGE_PREFIX)
184
+ );
185
+ }
186
+
176
187
  async function knowledgeFetchCommand(ctx, args) {
177
188
  const opts = parseFetchArgs(args);
178
189
  const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * `shipctl lanes` — generate and manage the thin GitHub Actions caller
3
- * workflows that delegate to the reusable `run-agent.yml` (RFC-0007 Phase 3).
3
+ * workflows that delegate to a **vendored** reusable `run-agent.yml` in the
4
+ * same repository (RFC-0007 Phase 3).
4
5
  *
5
6
  * Each lane in `.ship/config.yml` (v2) gets one file at
6
7
  * .github/workflows/ship-<lane_id>.yml
7
- * whose body is nothing but the `on:` triggers (derived from the lane
8
- * kind) and a single `uses: ElMundiUA/ship/.github/workflows/run-agent.yml@<ref>`
9
- * call. Any customisation flags, payload shape, callback URL wiring — is
10
- * centralised in the reusable workflow so upgrading customer fleets is a
11
- * single ref bump.
8
+ * whose body is the `on:` triggers (derived from the lane kind) and
9
+ * `uses: ./.github/workflows/run-agent.yml`
10
+ * `shipctl lanes install` also writes `.github/workflows/run-agent.yml`
11
+ * from the template bundled inside `@elmundi/ship-cli`.
12
12
  *
13
13
  * Subcommands:
14
14
  * install — render wrappers for every declared lane (or just --only X).
@@ -21,6 +21,7 @@
21
21
 
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
24
25
  import YAML from "yaml";
25
26
 
26
27
  import { findShipRoot, readConfig } from "../config/io.mjs";
@@ -28,7 +29,6 @@ import {
28
29
  validateConfig,
29
30
  CONFIG_SCHEMA_VERSION,
30
31
  lanePatterns,
31
- lanePrimaryPattern,
32
32
  laneFanout,
33
33
  } from "../config/schema.mjs";
34
34
 
@@ -42,9 +42,16 @@ const BANNER_HEADER = `${BANNER_MARKER}
42
42
  # Regenerate via: shipctl lanes install
43
43
  # Hand edits OUTSIDE the \`ship-cli:\` markers will NOT be overwritten.`;
44
44
 
45
- const DEFAULT_REUSABLE_OWNER = "ElMundiUA";
46
- const DEFAULT_REUSABLE_REPO = "ship";
47
- const DEFAULT_REUSABLE_PATH = ".github/workflows/run-agent.yml";
45
+ /** Same-repo reusable workflow path (GitHub Actions). */
46
+ const LOCAL_REUSABLE_USES = "./.github/workflows/run-agent.yml";
47
+
48
+ const RUN_AGENT_MARKER = "# ship-cli: run-agent v1";
49
+
50
+ function readRunAgentTemplate() {
51
+ const dir = path.dirname(fileURLToPath(import.meta.url));
52
+ const vendor = path.join(dir, "..", "vendor", "run-agent.workflow.yml");
53
+ return fs.readFileSync(vendor, "utf8");
54
+ }
48
55
 
49
56
  function printHelp() {
50
57
  console.log(`shipctl lanes — manage GitHub Actions caller workflows for the
@@ -62,11 +69,8 @@ USAGE
62
69
 
63
70
  FLAGS (install)
64
71
  --only <ids> Comma-separated lane ids to render (default: all).
65
- --ref <git-ref> Git ref of ElMundiUA/ship to pin the reusable workflow
66
- to (default: the shipctl_min version from config.yml,
67
- prefixed with 'v' — e.g. v0.12.0).
68
- --owner <gh-owner> GitHub owner of the ship repo (default: ElMundiUA).
69
- --repo <name> GitHub repo name (default: ship).
72
+ --ref, --owner, --repo Ignored (legacy); reusable workflow is always
73
+ ${LOCAL_REUSABLE_USES} in the caller repo.
70
74
  --shipctl-version <v> Pin the @elmundi/ship-cli version the reusable
71
75
  workflow installs on the runner (default: latest).
72
76
  --force Overwrite wrappers that exist but were not generated
@@ -97,6 +101,45 @@ export async function lanesCommand(ctx, rest) {
97
101
 
98
102
  /* ─────────────────────────── install ─────────────────────────── */
99
103
 
104
+ /**
105
+ * Copy vendored `run-agent.workflow.yml` into `.github/workflows/run-agent.yml`.
106
+ */
107
+ function syncRunAgentTemplate({ runAgentFile, runAgentRel, dryRun, force, installed, skipped }) {
108
+ let template;
109
+ try {
110
+ template = readRunAgentTemplate();
111
+ } catch (err) {
112
+ throw new Error(
113
+ `cannot read bundled run-agent template: ${err instanceof Error ? err.message : err}`,
114
+ );
115
+ }
116
+ const existed = fs.existsSync(runAgentFile);
117
+ if (existed) {
118
+ const cur = fs.readFileSync(runAgentFile, "utf8");
119
+ if (!cur.includes(RUN_AGENT_MARKER) && !force) {
120
+ skipped.push({ path: runAgentRel, reason: "run-agent-exists-without-banner" });
121
+ return;
122
+ }
123
+ if (cur === template) {
124
+ skipped.push({ path: runAgentRel, reason: "run-agent-up-to-date" });
125
+ return;
126
+ }
127
+ }
128
+ if (dryRun) {
129
+ installed.push({
130
+ path: runAgentRel,
131
+ action: existed ? "would-update-run-agent" : "would-write-run-agent",
132
+ });
133
+ return;
134
+ }
135
+ fs.mkdirSync(path.dirname(runAgentFile), { recursive: true });
136
+ fs.writeFileSync(runAgentFile, template, "utf8");
137
+ installed.push({
138
+ path: runAgentRel,
139
+ action: existed ? "updated-run-agent" : "wrote-run-agent",
140
+ });
141
+ }
142
+
100
143
  async function installCmd(ctx, rest) {
101
144
  const args = parseInstallArgs(rest);
102
145
  args.dryRun = args.dryRun || Boolean(ctx.dryRun);
@@ -112,20 +155,26 @@ async function installCmd(ctx, rest) {
112
155
  );
113
156
  }
114
157
 
115
- const ref = args.ref || deriveDefaultRef(config);
116
- const reusable = formatReusableRef({
117
- owner: args.owner || DEFAULT_REUSABLE_OWNER,
118
- repo: args.repo || DEFAULT_REUSABLE_REPO,
119
- ref,
120
- });
121
-
158
+ const reusable = LOCAL_REUSABLE_USES;
122
159
  const targetDir = path.join(shipRoot, WORKFLOW_DIR);
123
- if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
160
+ const runAgentFile = path.join(targetDir, "run-agent.yml");
161
+ const runAgentRel = path.relative(shipRoot, runAgentFile) || runAgentFile;
124
162
 
125
163
  const installed = [];
126
164
  const skipped = [];
127
165
  const errors = [];
128
166
 
167
+ syncRunAgentTemplate({
168
+ runAgentFile,
169
+ runAgentRel,
170
+ dryRun: args.dryRun,
171
+ force: args.force,
172
+ installed,
173
+ skipped,
174
+ });
175
+
176
+ if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
177
+
129
178
  for (const [laneId, lane] of wanted) {
130
179
  const file = path.join(targetDir, `ship-${laneId}.yml`);
131
180
  const rel = path.relative(shipRoot, file) || file;
@@ -169,7 +218,7 @@ async function installCmd(ctx, rest) {
169
218
  };
170
219
 
171
220
  emitSummary(ctx, args, payload, () => {
172
- console.log(`Ship lanes → ${reusable}`);
221
+ console.log(`Ship lanes → reusable ${reusable}`);
173
222
  for (const row of installed) console.log(` ${row.action}: ${row.path}`);
174
223
  for (const row of skipped) console.log(` skipped (${row.reason}): ${row.path}`);
175
224
  for (const row of errors) console.log(` ERROR: ${row.lane}: ${row.error}`);
@@ -191,7 +240,7 @@ function listCmd(ctx, rest) {
191
240
  kind: lane.kind,
192
241
  // ``pattern`` keeps the single-string shape for humans/scripts
193
242
  // that eyeball the first pattern; ``patterns`` always lists all
194
- // so multi-pattern lanes (RFC-0008 C3.1) surface correctly.
243
+ // so multi-pattern routines (RFC-0008 C3.1) surface correctly.
195
244
  pattern: pats[0] || null,
196
245
  patterns: pats,
197
246
  // ``fanout`` resolves to the runtime default (``matrix``) when
@@ -310,16 +359,6 @@ function selectLanes(config, onlyCsv) {
310
359
  return all.filter(([id]) => wanted.has(id));
311
360
  }
312
361
 
313
- function deriveDefaultRef(config) {
314
- const min = config.shipctl_min;
315
- if (typeof min === "string" && /^\d+\.\d+\.\d+/.test(min)) return `v${min}`;
316
- return "main";
317
- }
318
-
319
- function formatReusableRef({ owner, repo, ref }) {
320
- return `${owner}/${repo}/${DEFAULT_REUSABLE_PATH}@${ref}`;
321
- }
322
-
323
362
  /**
324
363
  * Render the caller workflow YAML. The body is structured first as a JS
325
364
  * object so the output is guaranteed to be syntactically valid; we then