@elmundi/ship-cli 0.12.2 → 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.
@@ -1,119 +1,72 @@
1
1
  /**
2
- * `shipctl run` — single entry-point for executing a Ship routine.
2
+ * `shipctl run` — E14 routine entry-point.
3
3
  *
4
- * Today's scope (Phase 1):
5
- * - `kind=once` routines run with local idempotency markers.
6
- * - `kind=event` and `kind=schedule` routines execute when `shipctl trigger`
7
- * says they are due; Ship only claims the schedule window.
4
+ * Customer's GH Actions cron fires this once per routine slot. The
5
+ * pipeline:
8
6
  *
9
- * The command intentionally does not fork an agent subprocess. The
10
- * reusable workflow pipes shipctl's stdout into the customer's agent
11
- * (Cursor Cloud, Claude Code, Codex, …) the same way `shipctl kickoff`
12
- * does today. That keeps the CLI agnostic about which agent runtime is
13
- * in use.
7
+ * 1. Read the routine from `.ship/config.yml` (pattern_id, optional
8
+ * user-authored prompt).
9
+ * 2. Fetch the pattern body+frontmatter from Ship server
10
+ * (`POST /fetch kind=pattern id=<pattern_id>`).
11
+ * 3. If the pattern declares `spec.fsm_stage`, ask Ship server for
12
+ * the next ticket in that FSM stage
13
+ * (`GET /v1/.../tracker/next?state=<stage>`). Server picks the
14
+ * adapter (Linear / GH Issues / etc.) — CLI doesn't care.
15
+ * 4. Mint a `run_id` and render the prompt: pattern body + ticket
16
+ * details + a finish-protocol block with `SHIP_API_BASE`,
17
+ * `SHIP_API_TOKEN`, `SHIP_WORKSPACE_ID`, `RUN_ID`, `TICKET_REF`,
18
+ * `FSM_STAGE` already substituted so the agent can call the
19
+ * finish endpoint directly.
20
+ * 5. Launch the configured agent runtime (`cli/lib/agents/`) — Cursor
21
+ * Cloud today. Block until the runtime terminates.
22
+ * 6. The agent itself calls
23
+ * `POST /v1/workspaces/{ws}/agent-runs/finish` with its outcome.
24
+ * Ship's server applies tracker side-effects via the workspace
25
+ * Linear OAuth — the CLI doesn't read any branch / state file.
26
+ * 7. CLI exits 0 on Cursor `FINISHED`, non-0 if the runtime crashed.
27
+ * Whether the agent actually called `/finish` is observable via
28
+ * the audit log; the smoke test for "did the right thing happen"
29
+ * is the tracker label/state itself.
14
30
  *
15
- * Callback behaviour: if a callback URL is available via flags or env,
16
- * `shipctl run` reports `status=ok` on success and `status=fail` on any
17
- * failure path. Callback errors do not override the primary exit code
18
- * (a successful routine with a flaky callback still exits 0, but prints a
19
- * warning to stderr).
31
+ * Env contract (typically wired in `ship-trigger-schedule.yml`):
32
+ * - SHIP_API_BASE — Ship server, e.g. https://api.ship.elmundi.com
33
+ * - SHIP_API_TOKEN — workspace API token (admin scope; rendered
34
+ * into the agent's prompt so it can call
35
+ * /agent-runs/finish from inside Cursor)
36
+ * - SHIP_WORKSPACE_ID — UUID of the workspace this run belongs to
37
+ * - CURSOR_API_KEY — Cursor Cloud agent API key (when provider=cursor)
38
+ * - GITHUB_REPOSITORY — owner/repo (the repo the agent will check out)
39
+ *
40
+ * Note: there is **no SHIP_REPO_ID** — a workspace is the project, so
41
+ * the tracker is workspace-scoped. ``GITHUB_REPOSITORY`` only tells the
42
+ * agent runtime which checkout to spawn for code work. Branchless agents
43
+ * (intake, BA, planner) don't push commits at all.
20
44
  */
21
45
 
22
- import fs from "node:fs";
46
+ import crypto from "node:crypto";
23
47
  import path from "node:path";
24
48
 
49
+ import yaml from "yaml";
50
+
25
51
  import { readConfig, findShipRoot } from "../config/io.mjs";
26
- import {
27
- validateConfig,
28
- CONFIG_SCHEMA_VERSION,
29
- LANE_FANOUT_MODES,
30
- } from "../config/schema.mjs";
31
- import {
32
- executableFanout,
33
- executableIds,
34
- executablePatterns,
35
- resolveExecutable,
36
- } from "../runtime/routines.mjs";
52
+ import { resolveExecutable } from "../runtime/routines.mjs";
37
53
  import { fetchArtifact } from "../http.mjs";
38
- import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
39
54
  import { readArtifactFile } from "../artifacts/fs-index.mjs";
40
- import { decideRun, readMarker, writeMarker, sha256 } from "../state/idempotency.mjs";
41
- import { readLockfile, lookupLock, verifyBody } from "../state/lockfile.mjs";
55
+ import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
56
+ import { resolveProvider, runAgent } from "../agents/index.mjs";
57
+
42
58
 
43
59
  const EXIT_OK = 0;
44
60
  const EXIT_USAGE = 1;
45
- const EXIT_V1_CONFIG = 2;
46
- const EXIT_CALLBACK = 3;
47
- const EXIT_IDEMPOTENCY = 4;
61
+ const EXIT_NO_TASK = 0; // intentional: no eligible ticket is a clean noop
62
+ const EXIT_AGENT_FAIL = 7;
48
63
 
49
- const VALID_TRIGGERS = new Set(["event", "schedule", "manual", "once"]);
50
64
 
51
- function printHelp() {
52
- console.log(`shipctl run — execute a Ship routine.
53
-
54
- WHAT THIS COMMAND IS FOR
55
- shipctl run is the **Run** dispatch entry point. It resolves a
56
- routine from .ship/config.yml, fetches its pattern body, checks
57
- idempotency, and emits the prompt for an agent to consume. Behaviour
58
- by routine trigger:
59
- - kind: once — executed fully here, locally.
60
- - kind: lane / event / — recognised but NOT executed locally;
61
- schedule those run via the workspace's GitHub
62
- Actions runner using the reusable
63
- .github/workflows/run-agent.yml. shipctl
64
- run exits 0 with a no-op summary so CI
65
- wrappers can wire them safely.
66
-
67
- USAGE
68
- shipctl run --routine <id> [--pattern <id>] [--fanout <matrix|sequential|concurrent>]
69
- [--trigger <event|schedule|manual|once>]
70
- [--dry-run] [--offline]
71
- [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
72
- [--cwd <dir>] [--json]
73
-
74
- FLAGS
75
- --routine <id> Routine id declared in process.routines. Required.
76
- --lane <id> Back-compat alias for --routine.
77
- --pattern <id> For multi-pattern lanes: run only this pattern. This
78
- is the per-entry call issued by the matrix workflow
79
- (one matrix job per pattern). Must be one of the
80
- lane's declared patterns.
81
- --fanout <mode> Override the lane's configured fan-out for this run
82
- (matrix|sequential|concurrent). Meaningful only
83
- when the lane has ≥2 patterns and --pattern is not
84
- set. Matrix mode without --pattern is rejected;
85
- it requires a driving workflow.
86
- --trigger <kind> Force the trigger context (event|schedule|manual|once).
87
- If omitted, inferred from GITHUB_EVENT_NAME / SHIP_RUN_TRIGGER.
88
- --dry-run Print the plan without touching idempotency markers or callback.
89
- --offline Resolve patterns exclusively via .ship/shipctl.lock.json
90
- and .ship/cache/ — never talks to the methodology API.
91
- Fails if the lockfile or a cached body is missing.
92
- Generate one with 'shipctl sync --lock'.
93
- --ship-run-id <uuid> Pipeline run id. Falls back to SHIP_RUN_ID env.
94
- --ship-callback-url <url> Full callback URL. Falls back to SHIP_CALLBACK_URL env.
95
- --ship-run-token <jwt> Short-lived bearer. Falls back to SHIP_RUN_TOKEN env.
96
- --cwd <dir> Repo root. Default: search upward for .ship/config.yml.
97
- --json Emit a structured summary on stdout.
98
- --help Show this help.
65
+ // ---------------------------------------------------------------------------
66
+ // Entry point
67
+ // ---------------------------------------------------------------------------
99
68
 
100
- EXIT
101
- 0 lane executed or no-op
102
- 1 usage / config error
103
- 2 config is v1 — run 'shipctl migrate' first
104
- 3 callback failed (lane itself may have succeeded)
105
- 4 idempotency marker read/write failure
106
- 10 missing SHIP_RUN_TOKEN when a callback URL is configured
107
-
108
- EXAMPLE (CI step emitted by the reusable workflow)
109
- shipctl run --routine daily_digest | feed-to-agent
110
- `);
111
- }
112
69
 
113
- /**
114
- * @param {{json?: boolean, dryRun?: boolean, baseUrl?: string}} ctx
115
- * @param {string[]} rest
116
- */
117
70
  export async function runCommand(ctx, rest) {
118
71
  const args = parseArgs(rest);
119
72
  if (args.help) {
@@ -121,760 +74,476 @@ export async function runCommand(ctx, rest) {
121
74
  process.exit(EXIT_OK);
122
75
  }
123
76
  if (!args.routine) {
124
- die(EXIT_USAGE, "`--routine <id>` is required (legacy alias: `--lane <id>`).\nRun: shipctl run --help");
77
+ die(EXIT_USAGE, "`--routine <id>` is required.\nRun: shipctl run --help");
125
78
  }
126
79
 
127
80
  const cwd = args.cwd || process.cwd();
128
81
  const root = findShipRoot(cwd);
129
82
  if (!root) {
130
- die(
131
- EXIT_USAGE,
132
- `.ship/config.yml not found (searched from ${path.resolve(cwd)} upward). Run 'shipctl init' first.`,
133
- );
134
- }
135
-
136
- let config;
137
- try {
138
- const read = readConfig(cwd);
139
- config = read.config;
140
- } catch (err) {
141
- die(EXIT_USAGE, err instanceof Error ? err.message : String(err));
142
- }
143
-
144
- if (config.version !== CONFIG_SCHEMA_VERSION) {
145
- die(
146
- EXIT_V1_CONFIG,
147
- `.ship/config.yml is at v${config.version}; shipctl run requires v${CONFIG_SCHEMA_VERSION}.\nRun 'shipctl migrate' to upgrade.`,
148
- );
149
- }
150
-
151
- const validation = validateConfig(config);
152
- if (!validation.ok) {
153
- const msg = [
154
- "config is invalid:",
155
- ...validation.errors.map((e) => ` - ${e}`),
156
- ].join("\n");
157
- die(EXIT_USAGE, msg);
83
+ die(EXIT_USAGE, `.ship/config.yml not found (searched from ${path.resolve(cwd)} upward).`);
158
84
  }
159
85
 
86
+ const { config } = readConfig(cwd);
160
87
  const resolved = resolveExecutable(config, args.routine);
161
88
  if (!resolved) {
162
- const known = executableIds(config);
163
- const joined = [...known.routines, ...known.lanes].sort();
164
- die(
165
- EXIT_USAGE,
166
- `unknown lane/routine '${args.routine}'. Known routines: ${joined.length ? joined.join(", ") : "(none)"}`,
167
- );
168
- }
169
- const executable = resolved.executable;
170
-
171
- const effectiveTrigger = resolveTrigger(args.trigger, executable.kind);
172
- if (!effectiveTrigger.fits) {
173
- /* Not an error — scheduler fired us but the lane doesn't want this
174
- * trigger. Exit 0 so parallel lanes in the same workflow don't all
175
- * fail just because one didn't match. */
176
- const summary = {
177
- routine: args.routine,
178
- lane: resolved.kind === "lane" ? args.routine : undefined,
179
- kind: executable.kind,
180
- trigger: effectiveTrigger.trigger,
181
- status: "noop",
182
- reason: `routine.kind=${executable.kind} does not accept trigger=${effectiveTrigger.trigger}`,
183
- };
184
- emitSummary(ctx, args, summary);
185
- process.exit(EXIT_OK);
186
- }
187
-
188
- // RFC-0008 C3.1/C3.2: resolve the list of patterns that this invocation
189
- // should execute.
190
- //
191
- // --pattern <id> → run only that pattern (the per-entry call
192
- // issued by the matrix workflow). The pattern
193
- // must be one of the lane's declared patterns,
194
- // otherwise we refuse so typos don't silently
195
- // execute an unrelated pattern.
196
- // (none) → run every pattern the lane declares, using
197
- // the lane's fan-out mode. Matrix mode without
198
- // --pattern is rejected because it requires a
199
- // driving workflow (see run-agent.yml).
200
- const allPatterns = executablePatterns(executable);
201
- const promptBody = executable.prompt;
202
- if (allPatterns.length === 0 && !promptBody) {
203
- die(EXIT_USAGE, `routine ${JSON.stringify(args.routine)} declares no patterns or prompt.`);
204
- }
205
-
206
- const effectiveFanout = args.fanout || executableFanout(executable);
207
- let patternsToRun;
208
- let runMode; // ``single`` | ``sequential`` | ``concurrent``
209
- if (args.pattern) {
210
- if (!allPatterns.includes(args.pattern)) {
211
- die(
212
- EXIT_USAGE,
213
- `--pattern=${JSON.stringify(args.pattern)} is not declared on lane/routine ${JSON.stringify(args.routine)}. ` +
214
- `Known patterns: ${allPatterns.join(", ")}.`,
215
- );
216
- }
217
- patternsToRun = [args.pattern];
218
- runMode = "single";
219
- } else if (allPatterns.length === 0 && promptBody) {
220
- patternsToRun = [];
221
- runMode = "single";
222
- } else if (allPatterns.length === 1) {
223
- patternsToRun = allPatterns;
224
- runMode = "single";
225
- } else if (effectiveFanout === "matrix") {
226
- die(
227
- EXIT_USAGE,
228
- `routine ${JSON.stringify(args.routine)} has fanout=matrix and ${allPatterns.length} patterns ` +
229
- `but no --pattern was provided. Matrix mode dispatches one 'shipctl run --pattern <id>' per ` +
230
- `pattern via the workflow (see run-agent.yml). To run them in-process instead, pass ` +
231
- `--fanout sequential or --fanout concurrent.`,
232
- );
233
- } else {
234
- patternsToRun = allPatterns;
235
- runMode = effectiveFanout;
236
- }
237
-
238
- // Idempotency markers are lane-scoped (not per-pattern) so we read
239
- // once up front; per-pattern decisions are derived from the
240
- // concatenated pattern SHA set below so a change to any member of
241
- // the list re-triggers the run (expected behaviour for audit lanes).
242
- const idem = executable.kind === "once" ? executable.idempotency : null;
243
- let marker = null;
244
- if (idem) {
245
- try {
246
- marker = readMarker(cwd, idem.key);
247
- } catch (err) {
248
- await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
249
- die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
89
+ die(EXIT_USAGE, `unknown routine '${args.routine}' in .ship/config.yml`);
90
+ }
91
+
92
+ const env = readEnv();
93
+ const { apiBase, apiToken, workspaceId, githubRepo } = env;
94
+
95
+ // 1) Resolve pattern
96
+ const patternId = resolved.executable.pattern;
97
+ if (!patternId) {
98
+ die(EXIT_USAGE, `routine '${args.routine}' has no pattern set`);
99
+ }
100
+
101
+ const fetchBase = methodologyBase(env, config);
102
+ const rawPatternBody = await loadPattern({ id: patternId, fetchBase });
103
+ const { frontmatter, frontmatterRaw, body: patternBody } = splitFrontmatter(rawPatternBody);
104
+ const fsmStage = pickFsmStage(frontmatter, frontmatterRaw);
105
+
106
+ // Patterns reference ``{{BASE}}`` to splice in common-base. Fetch it
107
+ // up-front so renderPrompt can do the substitution. ``{{SKILLS_CONTEXT}}``
108
+ // inside common-base is left as "(no skills directory)" for the MVP —
109
+ // skills bundling lands in a follow-up.
110
+ const baseRaw = await loadPattern({ id: "common-base", fetchBase, optional: true });
111
+ const baseBody = baseRaw ? splitFrontmatter(baseRaw).body : "";
112
+
113
+ // 2) Resolve task. ``--dry-run`` skips the server call and uses a
114
+ // synthetic task so the operator can see the prompt shape without
115
+ // needing the new endpoints deployed.
116
+ let task = null;
117
+ if (fsmStage) {
118
+ if (args.dryRun || ctx.dryRun) {
119
+ task = {
120
+ ticket_ref: "dry-run/sample#1",
121
+ kind: "dry-run",
122
+ title: "Sample ticket for dry-run prompt rendering",
123
+ body: "This is a synthetic ticket body. The real one comes from `GET /tracker/next` when not in dry-run.",
124
+ url: null,
125
+ labels: ["sample"],
126
+ state: "open",
127
+ fsm_stage: fsmStage,
128
+ };
129
+ } else {
130
+ task = await getNextTask({
131
+ apiBase,
132
+ apiToken,
133
+ workspaceId,
134
+ state: fsmStage,
135
+ });
136
+ if (!task) {
137
+ emit(args, { status: "noop", routine: args.routine, pattern: patternId, fsm_stage: fsmStage, reason: "no_eligible_ticket" });
138
+ process.exit(EXIT_NO_TASK);
139
+ }
250
140
  }
251
141
  }
252
142
 
253
- // Fetch every pattern body first so we can reject the whole run
254
- // atomically if any one is unavailable partial success is worse
255
- // than a hard failure here (the caller can retry once the fetch
256
- // error is cleared).
257
- const fetchJobs = patternsToRun.map((patternId) =>
258
- fetchPatternBody({
259
- patternId,
260
- patternVersion: executable.pattern_version || null,
261
- offline: args.offline,
262
- root,
263
- ctx,
264
- config,
265
- }).then((result) => ({ patternId, result })),
266
- );
267
- // `sequential` vs `concurrent` only differ for future in-process agent
268
- // invocation; today's CLI just emits the pattern bodies to stdout, so
269
- // both modes fetch in parallel and print in declared order. We still
270
- // record the requested mode on the summary so downstream consumers
271
- // (and future work) can see the intent.
272
- const fetched = await Promise.all(fetchJobs);
273
- for (const { patternId, result } of fetched) {
274
- if (!result.ok) {
275
- die(EXIT_USAGE, `pattern ${patternId}: ${result.error}`);
276
- }
277
- }
278
- const runs = promptBody && patternsToRun.length === 0
279
- ? [{
280
- patternId: `${args.routine}:prompt`,
281
- body: promptBody,
282
- source: "routine",
283
- sha256: sha256(promptBody),
284
- }]
285
- : fetched.map(({ patternId, result }) => ({
286
- patternId,
287
- body: result.body,
288
- source: result.source,
289
- sha256: sha256(result.body),
290
- }));
291
-
292
- // Composite SHA over all pattern bodies. ``reset_on=version-change``
293
- // fires when any member's body drifts — which is the correct
294
- // semantics for a multi-pattern audit lane: if one role's playbook
295
- // updates, we want the whole lane to re-run.
296
- const compositeBody = runs.map((r) => `#${r.patternId}\n${r.body}`).join("\n---\n");
297
- const decision = idem
298
- ? decideRun(marker, compositeBody, idem.reset_on || "version-change")
299
- : { run: true, reason: "trigger-router-due", marker: null };
300
- if (!decision.run) {
301
- const summary = {
302
- routine: args.routine,
303
- lane: resolved.kind === "lane" ? args.routine : undefined,
304
- kind: executable.kind,
305
- trigger: effectiveTrigger.trigger,
306
- status: "noop",
307
- reason: "already-done",
308
- marker: decision.marker,
309
- patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
310
- };
311
- await tryCallback(
312
- args,
313
- "ok",
314
- `routine ${args.routine}: already completed, no-op.`,
315
- runMode === "single"
316
- ? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256, noop: true }
317
- : { patterns: runs.map((r) => r.patternId), noop: true },
318
- );
319
- emitSummary(ctx, args, summary);
320
- process.exit(EXIT_OK);
321
- }
322
-
323
- /* Dry-run stops here — no marker write, no callback, just print the
324
- * plan. We still emit the pattern bodies to stdout so operators can
325
- * eyeball what the agent would receive. Multi-pattern runs print
326
- * each body preceded by a ``# ship: pattern=<id>`` banner so the
327
- * agent-side (or a human) can split them back apart. */
143
+ // 3) Mint a run_id + render prompt with finish-protocol values
144
+ // already substituted so the agent can call /agent-runs/finish from
145
+ // inside Cursor without holding any extra config.
146
+ const runId = `run_${crypto.randomBytes(8).toString("hex")}`;
147
+ const prompt = renderPrompt({
148
+ patternBody,
149
+ baseBody,
150
+ role: patternId,
151
+ routineSpec: resolved.executable,
152
+ task,
153
+ fsmStage,
154
+ finishCtx: {
155
+ apiBase,
156
+ apiToken,
157
+ workspaceId,
158
+ runId,
159
+ role: patternId,
160
+ ticketRef: task?.ticket_ref || null,
161
+ fsmStage: fsmStage || null,
162
+ },
163
+ });
164
+
165
+ // ``--dry-run`` exits here so the operator can eyeball the rendered
166
+ // prompt + resolved task without launching an agent or touching any
167
+ // tracker. Useful when iterating on pattern bodies.
328
168
  if (args.dryRun || ctx.dryRun) {
329
- const summary = {
330
- routine: args.routine,
331
- lane: resolved.kind === "lane" ? args.routine : undefined,
332
- kind: executable.kind,
333
- trigger: effectiveTrigger.trigger,
334
- status: "dry-run",
335
- reason: decision.reason,
336
- mode: runMode,
337
- patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
338
- };
339
- if (runs.length === 1) {
340
- summary.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
341
- }
342
- if (ctx.json || args.json) {
343
- console.log(
344
- JSON.stringify(
345
- { ...summary, pattern_bodies: Object.fromEntries(runs.map((r) => [r.patternId, r.body])) },
346
- null,
347
- 2,
348
- ),
349
- );
169
+ if (args.json) {
170
+ console.log(JSON.stringify({
171
+ status: "dry-run",
172
+ routine: args.routine,
173
+ pattern: patternId,
174
+ fsm_stage: fsmStage,
175
+ task,
176
+ prompt,
177
+ }, null, 2));
350
178
  } else {
351
- console.error(
352
- `# ship: routine=${args.routine} kind=${executable.kind} trigger=${effectiveTrigger.trigger} mode=${runMode} (dry-run)`,
353
- );
354
- emitPatternBodies(runs, { json: false });
179
+ console.error(`# ship: dry-run routine=${args.routine} pattern=${patternId} fsm_stage=${fsmStage || "(context-free)"}`);
180
+ if (task) {
181
+ console.error(`# ship: task ticket_ref=${task.ticket_ref} title=${JSON.stringify(task.title || "")}`);
182
+ } else {
183
+ console.error("# ship: task=(none)");
184
+ }
185
+ console.error("# ---- prompt ----");
186
+ console.log(prompt);
355
187
  }
356
188
  process.exit(EXIT_OK);
357
189
  }
358
190
 
359
- /* Emit the prompt(s) for the agent to consume (same contract as
360
- * `shipctl kickoff`). The reusable workflow pipes stdout into the
361
- * configured agent runtime; multi-pattern output is delimited by a
362
- * banner line per pattern so consumers can split on it.
363
- *
364
- * Workspace policy injection (RFC-Workspace-policy): before any
365
- * pattern body, fetch the workspace's prose-rule policies from the
366
- * backend and prepend them as a markdown block. This makes the
367
- * agent treat the policies as hard preamble — the same shape the
368
- * Navigator chat injects into ``TopicService.assemble_messages``.
369
- * Best-effort: a missing token, missing callback URL, or a network
370
- * failure quietly skips the prepend so local / offline runs still
371
- * work. */
372
- if (!(ctx.json || args.json)) {
373
- const provider = resolveAgentProvider(config, args.routine);
374
- if (provider) console.error(`# ship: routine=${args.routine} agent.provider=${provider} mode=${runMode}`);
375
- const preamble = await fetchPoliciesPreamble(args);
376
- if (preamble) emitPoliciesPreamble(preamble);
377
- emitPatternBodies(runs, { json: false });
378
- }
379
-
380
- if (idem) {
381
- try {
382
- writeMarker(cwd, idem.key, {
383
- routine: args.routine,
384
- lane: resolved.kind === "lane" ? args.routine : undefined,
385
- pattern_id: runs[0].patternId,
386
- pattern_sha256: sha256(compositeBody),
387
- pattern_version: executable.pattern_version || null,
388
- patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
389
- });
390
- } catch (err) {
391
- await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
392
- die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
393
- }
394
- }
191
+ // 4) Launch agent runtime
192
+ const provider = resolveProvider(config, args.routine);
193
+ const branchName = makeBranchName(args.routine, task?.ticket_ref);
194
+ const repoUrl = githubRepo ? `https://github.com/${githubRepo}` : null;
195
+ if (!repoUrl) die(EXIT_USAGE, "GITHUB_REPOSITORY env var is required to launch agent");
395
196
 
396
- const callbackMetrics = runMode === "single"
397
- ? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256 }
398
- : {
399
- pattern_id: runs[0].patternId,
400
- pattern_sha256: runs[0].sha256,
401
- patterns: runs.map((r) => r.patternId).join(","),
402
- };
403
- const callbackSummary = runMode === "single"
404
- ? `routine ${args.routine} completed (pattern ${runs[0].patternId}@${runs[0].sha256.slice(0, 8)}).`
405
- : `routine ${args.routine} completed (${runs.length} patterns, mode=${runMode}).`;
406
- const callbackResult = await tryCallback(args, "ok", callbackSummary, callbackMetrics);
407
-
408
- if (ctx.json || args.json) {
409
- // For single-pattern runs we keep the legacy ``pattern: {…}`` key
410
- // alongside the new ``patterns: […]`` list so existing consumers
411
- // (and tests) don't break when they upgrade shipctl before
412
- // starting to declare multi-pattern lanes.
413
- const summaryPayload = {
197
+ let runtime;
198
+ try {
199
+ runtime = await runAgent(provider, {
200
+ repoUrl,
201
+ ref: env.githubRef || "main",
202
+ branchName,
203
+ prompt,
204
+ autoCreatePr: false,
205
+ });
206
+ } catch (err) {
207
+ emit(args, {
208
+ status: "error",
414
209
  routine: args.routine,
415
- lane: resolved.kind === "lane" ? args.routine : undefined,
416
- kind: executable.kind,
417
- trigger: effectiveTrigger.trigger,
418
- status: "completed",
419
- mode: runMode,
420
- patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
421
- callback: callbackResult,
422
- };
423
- if (runs.length === 1) {
424
- summaryPayload.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
425
- }
426
- console.log(JSON.stringify(summaryPayload, null, 2));
427
- }
428
-
429
- process.exit(callbackResult.ok === false ? EXIT_CALLBACK : EXIT_OK);
210
+ pattern: patternId,
211
+ run_id: runId,
212
+ stage: "launch_agent",
213
+ error: err instanceof Error ? err.message : String(err),
214
+ });
215
+ process.exit(EXIT_AGENT_FAIL);
216
+ }
217
+
218
+ // The agent calls POST /agent-runs/finish from inside Cursor with
219
+ // its outcome. The CLI's job ends here Cursor's terminal status
220
+ // tells us the runtime didn't crash; whether the agent actually
221
+ // called /finish (and what outcome it reported) is observable in
222
+ // the audit log + the resulting tracker state.
223
+ emit(args, {
224
+ status: "completed",
225
+ routine: args.routine,
226
+ pattern: patternId,
227
+ fsm_stage: fsmStage,
228
+ ticket_ref: task?.ticket_ref || null,
229
+ agent_id: runtime.agentId,
230
+ branch: runtime.branchName,
231
+ cursor_status: runtime.status,
232
+ run_id: runId,
233
+ });
234
+ process.exit(EXIT_OK);
430
235
  }
431
236
 
432
- /**
433
- * Stream pattern bodies to stdout. For single-pattern runs we write
434
- * the body as-is (identical byte output to the pre-multi-pattern
435
- * behaviour, keeping the test harness stable). For multi-pattern
436
- * runs we precede each body with a ``# ship: pattern=<id>`` banner so
437
- * downstream consumers can re-split the stream.
438
- */
439
- function emitPatternBodies(runs, _opts) {
440
- if (runs.length === 1) {
441
- const body = runs[0].body;
442
- process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
443
- return;
444
- }
445
- for (const r of runs) {
446
- process.stdout.write(`# ship: pattern=${r.patternId} sha256=${r.sha256}\n`);
447
- const body = r.body;
448
- process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
449
- }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+
243
+ function readEnv() {
244
+ return {
245
+ apiBase: stripSlash(process.env.SHIP_API_BASE || ""),
246
+ apiToken: process.env.SHIP_API_TOKEN || "",
247
+ workspaceId: process.env.SHIP_WORKSPACE_ID || "",
248
+ githubRepo: process.env.GITHUB_REPOSITORY || "",
249
+ githubRef: (process.env.GITHUB_REF_NAME || "main").trim(),
250
+ };
450
251
  }
451
252
 
452
- /**
453
- * Print the workspace-policies preamble once at the top of stdout,
454
- * before the first pattern body. Trailing ``---`` separator visually
455
- * distinguishes the preamble from the pattern markdown the agent is
456
- * about to consume; an extra blank line above the separator keeps
457
- * the markdown well-formed if the preamble already ends with one.
458
- */
459
- function emitPoliciesPreamble(preamble) {
460
- const trimmed = preamble.endsWith("\n") ? preamble : `${preamble}\n`;
461
- process.stdout.write(trimmed);
462
- process.stdout.write("\n---\n");
253
+
254
+ function stripSlash(s) {
255
+ return s.replace(/\/+$/, "");
463
256
  }
464
257
 
465
- /**
466
- * Fetch the workspace prose-rule policies for the current run from
467
- * the Ship backend. The endpoint URL is derived from the callback
468
- * URL by swapping the trailing ``/result`` segment for
469
- * ``/policies-preamble`` — both share the same auth dependency
470
- * (per-run JWT or long-lived ``SHIP_RUN_TOKEN``), so we can reuse
471
- * the same bearer.
472
- *
473
- * Returns the preamble markdown or ``null`` when:
474
- * - there's no callback URL / token (local invocation),
475
- * - the URL doesn't end in ``/result`` (someone overrode the
476
- * callback endpoint to a non-canonical path — too risky to
477
- * guess),
478
- * - the backend has no enabled policies (``preamble: null``),
479
- * - or the request fails for any reason.
480
- *
481
- * Failures are surfaced as ``warn:`` lines on stderr so an operator
482
- * can debug them without breaking the lane execution.
483
- */
484
- async function fetchPoliciesPreamble(args) {
485
- const callbackUrl = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
486
- if (!callbackUrl) return null;
487
- const token = args.runToken || process.env.SHIP_RUN_TOKEN;
488
- if (!token) return null;
489
- if (!callbackUrl.endsWith("/result")) {
490
- console.error(
491
- `warn: SHIP_CALLBACK_URL does not end in /result; skipping policies-preamble fetch (got ${callbackUrl}).`,
492
- );
493
- return null;
258
+
259
+ function methodologyBase(env, config) {
260
+ // Server's POST /fetch lives next to /v1, not under /api/methodology.
261
+ if (env.apiBase) return env.apiBase;
262
+ const fromConfig = config?.api?.base_url;
263
+ if (typeof fromConfig === "string" && fromConfig.trim()) {
264
+ return stripSlash(fromConfig);
494
265
  }
495
- const url = `${callbackUrl.slice(0, -"/result".length)}/policies-preamble`;
266
+ throw new Error("SHIP_API_BASE not set and no api.base_url in .ship/config.yml");
267
+ }
268
+
269
+
270
+ function splitFrontmatter(raw) {
271
+ if (!raw.startsWith("---")) return { frontmatter: {}, frontmatterRaw: "", body: raw };
272
+ const end = raw.indexOf("\n---\n", 4);
273
+ if (end < 0) return { frontmatter: {}, frontmatterRaw: "", body: raw };
274
+ const headRaw = raw.slice(3, end + 1);
275
+ const body = raw.slice(end + 5);
276
+ let parsed = {};
496
277
  try {
497
- const res = await fetch(url, {
498
- method: "GET",
499
- headers: {
500
- Accept: "application/json",
501
- Authorization: `Bearer ${token}`,
502
- },
503
- });
504
- if (!res.ok) {
505
- console.error(
506
- `warn: policies-preamble fetch returned HTTP ${res.status} ${res.statusText}; continuing without policies.`,
507
- );
508
- return null;
509
- }
510
- const body = await res.json().catch(() => null);
511
- if (!body || typeof body !== "object") return null;
512
- const preamble = body.preamble;
513
- if (typeof preamble !== "string" || !preamble.trim()) return null;
514
- return preamble;
515
- } catch (err) {
516
- console.error(
517
- `warn: policies-preamble fetch failed: ${err instanceof Error ? err.message : err}`,
518
- );
519
- return null;
278
+ parsed = yaml.parse(headRaw) || {};
279
+ } catch {
280
+ // Some Ship patterns have unquoted ``@elmundi/ship-core`` in
281
+ // ``authors`` which strict YAML rejects. The CLI doesn't need the
282
+ // full document — ``pickFsmStage`` falls back to a regex on the
283
+ // raw frontmatter text in that case.
284
+ parsed = {};
285
+ }
286
+ return { frontmatter: parsed, frontmatterRaw: headRaw, body };
287
+ }
288
+
289
+
290
+ function pickFsmStage(frontmatter, frontmatterRaw) {
291
+ const spec = frontmatter?.spec || {};
292
+ const v = spec.fsm_stage ?? spec.fsmStage;
293
+ if (typeof v === "string" && v.trim()) return v.trim();
294
+ if (frontmatterRaw) {
295
+ // Strict-YAML-fallback: regex on the raw frontmatter. Matches
296
+ // ``fsm_stage: triage`` (with optional surrounding whitespace, with
297
+ // or without quotes) anywhere in the spec block.
298
+ const m = frontmatterRaw.match(/^\s*fsm_stage:\s*['"]?([\w.-]+)['"]?/m);
299
+ if (m && m[1]) return m[1].trim();
520
300
  }
301
+ return null;
521
302
  }
522
303
 
523
304
 
524
- /* ------------------------------------------------------------------ */
525
- /* Helpers */
526
- /* ------------------------------------------------------------------ */
305
+ async function getNextTask({ apiBase, apiToken, workspaceId, state }) {
306
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/tracker/next?state=${encodeURIComponent(state)}`;
307
+ const res = await fetch(url, {
308
+ headers: {
309
+ Accept: "application/json",
310
+ Authorization: `Bearer ${apiToken}`,
311
+ },
312
+ });
313
+ if (!res.ok) {
314
+ throw new Error(`tracker/next ${res.status}: ${(await res.text()).slice(0, 300)}`);
315
+ }
316
+ const body = await res.json();
317
+ return body.ticket || null;
318
+ }
527
319
 
528
- function parseArgs(rest) {
529
- const out = {
530
- routine: null,
531
- pattern: null,
532
- fanout: null,
533
- trigger: null,
534
- dryRun: false,
535
- offline: false,
536
- runId: null,
537
- callbackUrl: null,
538
- runToken: null,
539
- cwd: null,
540
- json: false,
541
- help: false,
542
- };
543
- const copy = [...rest];
544
- const str = (flag, key) => {
545
- if (copy[0] === flag && copy[1] !== undefined) {
546
- copy.shift();
547
- out[key] = String(copy.shift());
548
- return true;
549
- }
550
- const p = `${flag}=`;
551
- if (copy[0] && copy[0].startsWith(p)) {
552
- out[key] = copy[0].slice(p.length);
553
- copy.shift();
554
- return true;
555
- }
556
- return false;
557
- };
558
- while (copy.length) {
559
- const a = copy[0];
560
- if (a === "--help" || a === "-h") {
561
- out.help = true;
562
- copy.shift();
563
- continue;
564
- }
565
- if (a === "--dry-run") {
566
- out.dryRun = true;
567
- copy.shift();
568
- continue;
569
- }
570
- if (a === "--offline") {
571
- out.offline = true;
572
- copy.shift();
573
- continue;
574
- }
575
- if (a === "--json") {
576
- out.json = true;
577
- copy.shift();
578
- continue;
320
+
321
+ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage, finishCtx }) {
322
+ const issueRef = task?.ticket_ref ? task.ticket_ref : "(no ticket)";
323
+ const title = task?.title || "";
324
+ const description = task?.body || "";
325
+
326
+ // Pattern-template substitution. Order matters: expand {{BASE}} first
327
+ // so any further {{ROLE}} / {{SKILLS_CONTEXT}} placeholders inside
328
+ // common-base also get resolved.
329
+ const baseExpanded = (baseBody || "")
330
+ .replace(/\{\{ROLE\}\}/g, role)
331
+ .replace(/\{\{ISSUE\}\}/g, issueRef)
332
+ .replace(/\{\{SKILLS_CONTEXT\}\}/g, "(no skills directory bundled in this run)");
333
+
334
+ const expanded = patternBody
335
+ .replace(/\{\{BASE\}\}/g, baseExpanded)
336
+ .replace(/\{\{ROLE\}\}/g, role)
337
+ .replace(/\{\{ISSUE\}\}/g, issueRef)
338
+ .replace(/\{\{TITLE\}\}/g, title.slice(0, 500))
339
+ .replace(/\{\{DESCRIPTION\}\}/g, description.slice(0, 8000));
340
+
341
+ const out = [];
342
+ if (routineSpec.prompt) {
343
+ out.push("## Routine instructions");
344
+ out.push(routineSpec.prompt.trim());
345
+ out.push("");
346
+ }
347
+ out.push(expanded.trim());
348
+ if (task) {
349
+ out.push("");
350
+ out.push("## Task");
351
+ out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
352
+ if (task.url) out.push(`- **URL:** ${task.url}`);
353
+ if (task.title) out.push(`- **Title:** ${task.title}`);
354
+ if (task.fsm_stage || fsmStage) out.push(`- **FSM stage:** \`${task.fsm_stage || fsmStage}\``);
355
+ if (Array.isArray(task.labels) && task.labels.length) {
356
+ out.push(`- **Labels:** ${task.labels.join(", ")}`);
579
357
  }
580
- if (str("--routine", "routine")) continue;
581
- if (str("--lane", "routine")) continue;
582
- if (str("--pattern", "pattern")) continue;
583
- if (str("--fanout", "fanout")) continue;
584
- if (str("--trigger", "trigger")) continue;
585
- if (str("--ship-run-id", "runId")) continue;
586
- if (str("--ship-callback-url", "callbackUrl")) continue;
587
- if (str("--ship-run-token", "runToken")) continue;
588
- if (str("--cwd", "cwd")) {
589
- out.cwd = path.resolve(out.cwd);
590
- continue;
358
+ if (task.body) {
359
+ out.push("");
360
+ out.push("### Description");
361
+ out.push(task.body);
591
362
  }
592
- die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl run --help`);
593
363
  }
594
- if (out.trigger && !VALID_TRIGGERS.has(out.trigger)) {
595
- die(
596
- EXIT_USAGE,
597
- `--trigger must be one of ${[...VALID_TRIGGERS].join("|")}; got ${out.trigger}`,
598
- );
599
- }
600
- if (out.fanout && !LANE_FANOUT_MODES.includes(out.fanout)) {
601
- die(
602
- EXIT_USAGE,
603
- `--fanout must be one of ${LANE_FANOUT_MODES.join("|")}; got ${out.fanout}`,
604
- );
605
- }
606
- if (out.pattern !== null && (typeof out.pattern !== "string" || !out.pattern.trim())) {
607
- die(EXIT_USAGE, "--pattern: must be a non-empty pattern id");
608
- }
609
- return out;
364
+ out.push("");
365
+ out.push(renderExitProtocol(finishCtx));
366
+ return out.join("\n");
610
367
  }
611
368
 
612
- function resolveTrigger(explicit, laneKind) {
613
- const raw =
614
- explicit ||
615
- (process.env.SHIP_RUN_TRIGGER && process.env.SHIP_RUN_TRIGGER.trim()) ||
616
- inferFromEnv();
617
- const trigger = raw || "manual";
618
-
619
- /* `once` lanes only run under `manual` or `once` triggers. Scheduler
620
- * or event triggers must not accidentally repeat seeding because the
621
- * cron happens to tick. */
622
- if (laneKind === "once") {
623
- return { fits: trigger === "manual" || trigger === "once", trigger };
624
- }
625
- if (laneKind === "schedule") {
626
- return { fits: trigger === "schedule" || trigger === "manual", trigger };
627
- }
628
- if (laneKind === "event") {
629
- return { fits: trigger === "event" || trigger === "manual", trigger };
630
- }
631
- return { fits: false, trigger };
632
- }
633
369
 
634
- function inferFromEnv() {
635
- if (process.env.GITHUB_EVENT_NAME === "schedule") return "schedule";
636
- if (process.env.GITHUB_EVENT_NAME === "workflow_dispatch") return "manual";
637
- if (process.env.GITHUB_EVENT_NAME) return "event";
638
- return null;
370
+ function renderExitProtocol(ctx) {
371
+ // Substitute the run-time values directly into the example so the
372
+ // agent doesn't have to figure out env var hookup. The token is
373
+ // workspace-scoped and meant for this run; the prompt warns the
374
+ // agent not to echo it.
375
+ const apiBase = ctx?.apiBase || "$SHIP_API_BASE";
376
+ const apiToken = ctx?.apiToken || "$SHIP_API_TOKEN";
377
+ const workspaceId = ctx?.workspaceId || "$SHIP_WORKSPACE_ID";
378
+ const runId = ctx?.runId || "<run_id>";
379
+ const ticketRef = ctx?.ticketRef ?? null;
380
+ const fsm = ctx?.fsmStage ?? null;
381
+ const ticketLine = ticketRef === null
382
+ ? '"ticket_ref": null,'
383
+ : `"ticket_ref": ${JSON.stringify(ticketRef)},`;
384
+ const fsmLine = fsm === null
385
+ ? '"fsm_stage": null,'
386
+ : `"fsm_stage": ${JSON.stringify(fsm)},`;
387
+
388
+ return `## Required exit protocol
389
+
390
+ When you finish (or determine you cannot proceed), call Ship's finish
391
+ endpoint **once** with your outcome and stop. This is the only
392
+ sanctioned write surface — Ship's server applies tracker side-effects
393
+ through the workspace's existing OAuth.
394
+
395
+ **Do not** create empty branches or commit placeholder files. If your
396
+ role doesn't change code, no branch is required. If your role does
397
+ change code, push the code on the branch Ship CLI named for you, then
398
+ call finish.
399
+
400
+ **Do not** call any Linear / Jira / GitHub MCP that writes. Reading
401
+ via MCP is fine; writing is not. The finish endpoint is the only
402
+ write surface.
403
+
404
+ \`\`\`bash
405
+ curl -fsS -X POST '${apiBase}/v1/workspaces/${workspaceId}/agent-runs/finish' \\
406
+ -H 'Authorization: Bearer ${apiToken}' \\
407
+ -H 'Content-Type: application/json' \\
408
+ --data @- <<'JSON'
409
+ {
410
+ "run_id": ${JSON.stringify(runId)},
411
+ "outcome": "ready_next_step",
412
+ ${ticketLine}
413
+ ${fsmLine}
414
+ "stage_next": "<next FSM stage, e.g. ba_requirements>",
415
+ "comment": "Markdown summary of what you did. End with [Ship SDLC:${ctx?.role || "{{ROLE}}"}].",
416
+ "summary": null,
417
+ "payload": {}
639
418
  }
640
-
641
- function resolveAgentProvider(config, laneId) {
642
- const override = config.agent?.overrides?.[laneId]?.provider;
643
- if (override) return override;
644
- return config.agent?.default?.provider || null;
419
+ JSON
420
+ \`\`\`
421
+
422
+ ### Outcomes
423
+
424
+ - **\`ready_next_step\`** — your role finished cleanly. Set
425
+ \`stage_next\` to the next FSM stage. Server moves the ticket and
426
+ posts \`comment\` if provided.
427
+ - **\`needs_clarification\`** — you're waiting on a human. Set
428
+ \`comment\` with the question (server posts it) or omit it if you
429
+ already left the question via a separate read-only path. Server
430
+ tags the ticket \`needs:clarification\` so intake stops re-picking.
431
+ Status stays Todo. \`stage_next\` is ignored.
432
+ - **\`blocked\`** — you cannot proceed (missing secret, broken env,
433
+ conflicting branch). Server drops a blocker into the workspace
434
+ inbox; ticket unchanged. \`stage_next\` is ignored.
435
+ - **\`out_of_scope\`** — the ticket is invalid or shouldn't be
436
+ processed. Server moves it to Done with optional \`comment\`.
437
+ \`stage_next\` is ignored.
438
+
439
+ ### Security
440
+
441
+ \`SHIP_API_TOKEN\` is rendered into this prompt so you can call the
442
+ finish endpoint. **Do not echo it back into commit messages, PR
443
+ descriptions, comments, logs, or any output you produce.** Treat it
444
+ as a one-shot credential for this run.
445
+ `;
645
446
  }
646
447
 
647
- async function fetchPatternBody({ patternId, patternVersion, offline, root, ctx, config }) {
648
- /* --offline takes precedence when requested: we MUST NOT hit the
649
- * network or fall through to another source. The lockfile is the
650
- * single source of truth. This makes CI runs reproducible and keeps
651
- * air-gapped installs honest. */
652
- if (offline) return fetchFromLockfile({ patternId, root, strict: true });
653
448
 
654
- /* 1) Running inside the Ship monorepo — read from disk. */
449
+ /**
450
+ * Load a pattern body. Resolution order:
451
+ * 1) when running inside the Ship monorepo, read from
452
+ * ``artifacts/patterns/<id>/ARTIFACT.md`` on disk — fast and
453
+ * always reflects the working tree (good for dry-runs / local
454
+ * smoke tests before the server is rebuilt).
455
+ * 2) otherwise hit the server's ``POST /fetch``.
456
+ */
457
+ async function loadPattern({ id, fetchBase, optional = false }) {
655
458
  const shipRepo = resolveShipRepoRootForCatalog();
656
459
  if (shipRepo) {
657
- const file = readArtifactFile(shipRepo, "pattern", patternId);
658
- if (file) {
659
- const verification = verifyAgainstLockfile({
660
- root,
661
- patternId,
662
- body: file.content,
663
- });
664
- if (verification.warning) console.error(`warn: ${verification.warning}`);
665
- return { ok: true, body: file.content, source: "monorepo", lock: verification };
666
- }
460
+ const file = readArtifactFile(shipRepo, "pattern", id);
461
+ if (file && typeof file.content === "string") return file.content;
667
462
  }
668
-
669
- /* 2) Network: same resolver `shipctl kickoff` uses. */
670
- const base = resolveMethodologyBase(ctx, config);
671
463
  try {
672
- const { content } = await fetchArtifact(base, "pattern", patternId, patternVersion || undefined);
673
- const verification = verifyAgainstLockfile({ root, patternId, body: content });
674
- if (verification.warning) console.error(`warn: ${verification.warning}`);
675
- return { ok: true, body: content, source: "http", lock: verification };
464
+ const { content } = await fetchArtifact(fetchBase, "pattern", id);
465
+ return content;
676
466
  } catch (err) {
677
- /* If the network call failed but we have a locked copy on disk, let
678
- * the operator fall back with a clear warning. This mirrors the
679
- * `npm install --offline` escape hatch when the registry is down. */
680
- const fallback = fetchFromLockfile({ patternId, root, strict: false });
681
- if (fallback.ok) {
682
- console.error(
683
- `warn: network fetch failed for pattern/${patternId}; using locked copy (${fallback.source}).`,
684
- );
685
- return fallback;
467
+ if (optional) {
468
+ console.error(`warn: failed to fetch pattern '${id}': ${err.message}`);
469
+ return "";
686
470
  }
687
- return {
688
- ok: false,
689
- error: `failed to fetch pattern ${patternId}: ${err instanceof Error ? err.message : err}`,
690
- };
471
+ throw err;
691
472
  }
692
473
  }
693
474
 
694
- function verifyAgainstLockfile({ root, patternId, body }) {
695
- let lock;
696
- try {
697
- lock = readLockfile(root);
698
- } catch (err) {
699
- return { present: false, ok: null, warning: `lockfile unreadable: ${err.message}` };
700
- }
701
- if (!lock) return { present: false, ok: null };
702
- const entry = lookupLock(lock, "pattern", patternId);
703
- if (!entry) {
704
- return {
705
- present: true,
706
- ok: null,
707
- warning: `lockfile present but has no entry for pattern/${patternId}; run 'shipctl sync --lock'.`,
708
- };
709
- }
710
- const result = verifyBody(entry, body);
711
- if (!result.ok) {
712
- return {
713
- present: true,
714
- ok: false,
715
- reason: result.reason,
716
- expected: result.expected,
717
- actual: result.actual,
718
- warning: `pattern/${patternId} sha256 drift vs lockfile (${result.reason}; expected ${result.expected?.slice(0, 8)} got ${result.actual?.slice(0, 8)})`,
719
- };
720
- }
721
- return { present: true, ok: true, version: entry.version };
722
- }
723
475
 
724
- function fetchFromLockfile({ patternId, root, strict }) {
725
- let lock;
726
- try {
727
- lock = readLockfile(root);
728
- } catch (err) {
729
- return {
730
- ok: false,
731
- error: `lockfile unreadable: ${err.message}. Run 'shipctl sync --lock' to rebuild.`,
732
- };
733
- }
734
- if (!lock) {
735
- if (!strict) return { ok: false, error: "lockfile missing" };
736
- return {
737
- ok: false,
738
- error:
739
- "--offline requires .ship/shipctl.lock.json. Run 'shipctl sync --lock' in an online environment first.",
740
- };
741
- }
742
- const entry = lookupLock(lock, "pattern", patternId);
743
- if (!entry) {
744
- return {
745
- ok: false,
746
- error:
747
- strict
748
- ? `--offline: pattern/${patternId} missing from .ship/shipctl.lock.json. Run 'shipctl sync --lock' to re-resolve.`
749
- : `pattern/${patternId} not in lockfile`,
750
- };
476
+ function makeBranchName(routine, ticketRef) {
477
+ const stamp = Date.now().toString(36);
478
+ if (ticketRef) {
479
+ const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
480
+ return `cursor/ship-${routine}-${safe}-${stamp}`;
751
481
  }
752
- const abs = path.join(root, entry.cached_path);
753
- let body;
754
- try {
755
- body = fs.readFileSync(abs, "utf8");
756
- } catch (err) {
757
- return {
758
- ok: false,
759
- error: `--offline: cached pattern body unreadable at ${entry.cached_path} (${err instanceof Error ? err.message : err}). Run 'shipctl sync --lock'.`,
760
- };
761
- }
762
- const verification = verifyBody(entry, body);
763
- if (!verification.ok) {
764
- return {
765
- ok: false,
766
- error: `--offline: sha256 mismatch for pattern/${patternId} (expected ${verification.expected?.slice(0, 8)}, got ${verification.actual?.slice(0, 8)}). Re-run 'shipctl sync --lock'.`,
767
- };
768
- }
769
- return {
770
- ok: true,
771
- body,
772
- source: "lockfile",
773
- lock: { present: true, ok: true, version: entry.version },
774
- };
482
+ return `cursor/ship-${routine}-${stamp}`;
775
483
  }
776
484
 
777
- function resolveMethodologyBase(ctx, config) {
778
- const fromFlag = ctx.baseUrl;
779
- const fromEnv =
780
- typeof process.env.SHIP_API_BASE === "string" && process.env.SHIP_API_BASE.trim()
781
- ? process.env.SHIP_API_BASE.trim().replace(/\/$/, "")
782
- : null;
783
- /* Wizard-seeded Actions secret: exact Ship API origin (``POST /fetch`` lives
784
- * at the root next to ``/v1``). Do not append ``/api/methodology`` here. */
785
- if (fromEnv) {
786
- return fromEnv;
787
- }
788
- const raw = config?.api?.base_url;
789
- if (typeof raw === "string" && raw.trim()) {
790
- const u = raw.replace(/\/$/, "");
791
- return u.includes("/api/methodology") ? u : `${u}/api/methodology`;
792
- }
793
- return fromFlag;
794
- }
795
485
 
796
- /*
797
- * Assemble the callback `metrics` bag so Ship's backend can tie each
798
- * run back to its lane + GitHub Actions run without re-parsing logs.
799
- *
800
- * Always-on breadcrumbs (iff we have the data):
801
- * - lane_id — id from `.ship/config.yml`; also recoverable
802
- * from the ship-<lane_id>.yml workflow path,
803
- * but duplicating here costs us nothing and
804
- * makes non-GitHub adapters (RFC-0007 Phase 8)
805
- * cheaper because they won't have that URL.
806
- * - gh_workflow_run_id — GITHUB_RUN_ID env (empty outside Actions).
807
- * - gh_html_url — constructed from GITHUB_SERVER_URL / _REPOSITORY
808
- * / _RUN_ID so the Console can deep-link the
809
- * GH UI from a Lane detail view.
810
- * - gh_event — GITHUB_EVENT_NAME (push / schedule / PR /…).
811
- *
812
- * Caller-supplied extras (pattern id / sha) stack on top. Nothing here
813
- * is required; the backend treats unknown keys as opaque forward-compat
814
- * payload.
815
- */
816
- function collectCallbackMetrics(args, extra = {}) {
817
- const env = process.env;
818
- const out = { ...(extra || {}) };
819
- if (args && args.routine && !out.routine_id) out.routine_id = args.routine;
820
- if (args && args.routine && !out.lane_id) out.lane_id = args.routine;
821
- if (env.GITHUB_RUN_ID && !out.gh_workflow_run_id) {
822
- out.gh_workflow_run_id = env.GITHUB_RUN_ID;
823
- }
824
- if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID && !out.gh_html_url) {
825
- out.gh_html_url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;
486
+ // ---------------------------------------------------------------------------
487
+ // Argument plumbing
488
+ // ---------------------------------------------------------------------------
489
+
490
+
491
+ function parseArgs(rest) {
492
+ const out = { routine: null, cwd: null, json: false, help: false, dryRun: false };
493
+ const copy = [...rest];
494
+ while (copy.length) {
495
+ const a = copy[0];
496
+ if (a === "--help" || a === "-h") { out.help = true; copy.shift(); continue; }
497
+ if (a === "--json") { out.json = true; copy.shift(); continue; }
498
+ if (a === "--dry-run") { out.dryRun = true; copy.shift(); continue; }
499
+ if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
500
+ if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
501
+ // Soft-ignore legacy flags that older trigger workflows still pass —
502
+ // the new pipeline doesn't need them and refusing would break repos
503
+ // that haven't re-seeded yet. ``--lane`` is the back-compat spelling
504
+ // of ``--routine`` from before the rename.
505
+ if (a === "--trigger" && copy[1] !== undefined) { copy.splice(0, 2); continue; }
506
+ if (a === "--lane" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
507
+ die(EXIT_USAGE, `unknown argument: ${a}`);
826
508
  }
827
- if (env.GITHUB_EVENT_NAME && !out.gh_event) out.gh_event = env.GITHUB_EVENT_NAME;
828
509
  return out;
829
510
  }
830
511
 
831
- async function tryCallback(args, status, summary, extraMetrics = {}) {
832
- const url = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
833
- if (!url) return { ok: null, skipped: "no-callback-url" };
834
- const token = args.runToken || process.env.SHIP_RUN_TOKEN;
835
- if (!token) {
836
- console.error(
837
- "warn: SHIP_RUN_TOKEN missing; skipping callback. (Set via --ship-run-token or env.)",
838
- );
839
- return { ok: false, skipped: "no-token" };
840
- }
841
- const body = { status: status === "ok" ? "succeeded" : status === "fail" ? "failed" : status };
842
- if (summary) body.summary = String(summary).slice(0, 1024);
843
- const metrics = collectCallbackMetrics(args, extraMetrics);
844
- if (Object.keys(metrics).length > 0) body.metrics = metrics;
845
512
 
846
- try {
847
- const res = await fetch(url, {
848
- method: "POST",
849
- headers: {
850
- "Content-Type": "application/json",
851
- Accept: "application/json",
852
- Authorization: `Bearer ${token}`,
853
- },
854
- body: JSON.stringify(body),
855
- });
856
- if (!res.ok) {
857
- const text = await res.text().catch(() => "");
858
- console.error(`warn: callback returned HTTP ${res.status} ${res.statusText}\n${text}`);
859
- return { ok: false, status: res.status };
860
- }
861
- return { ok: true, status: res.status };
862
- } catch (err) {
863
- console.error(`warn: callback POST failed: ${err instanceof Error ? err.message : err}`);
864
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
865
- }
513
+ function printHelp() {
514
+ console.log(`shipctl run execute one E14 routine end-to-end.
515
+
516
+ Run
517
+ shipctl run --routine <id> [--json] [--cwd <dir>] [--dry-run]
518
+
519
+ ENV
520
+ SHIP_API_BASE Ship server base URL (e.g. https://api.ship.elmundi.com)
521
+ SHIP_API_TOKEN workspace API token; rendered into the agent prompt
522
+ so the agent can call /agent-runs/finish itself
523
+ SHIP_WORKSPACE_ID UUID of the workspace (a workspace is one project)
524
+ GITHUB_REPOSITORY owner/repo (which checkout the agent gets)
525
+ CURSOR_API_KEY Cursor Cloud API key (when agent.default.provider=cursor)
526
+
527
+ EXIT
528
+ 0 agent runtime reached a terminal state (FINISHED/CANCELLED/ERRORED).
529
+ Whether the agent actually called /agent-runs/finish is observable
530
+ in the audit log this CLI no longer waits on that signal.
531
+ 1 usage / config error
532
+ 7 agent runtime failed to launch
533
+ `);
866
534
  }
867
535
 
868
- function emitSummary(ctx, args, summary) {
869
- if (ctx.json || args.json) {
870
- console.log(JSON.stringify(summary, null, 2));
536
+
537
+ function emit(args, payload) {
538
+ if (args.json) {
539
+ console.log(JSON.stringify(payload, null, 2));
871
540
  } else {
872
- console.error(
873
- `# ship: routine=${summary.routine || summary.lane} status=${summary.status}${summary.reason ? ` reason="${summary.reason}"` : ""}`,
874
- );
541
+ console.error(`# ship: ${payload.status}${payload.reason ? ` reason=${payload.reason}` : ""}${payload.routine ? ` routine=${payload.routine}` : ""}`);
542
+ if (payload.error) console.error(`# error: ${payload.error}`);
875
543
  }
876
544
  }
877
545
 
546
+
878
547
  function die(code, msg) {
879
548
  console.error(msg);
880
549
  process.exit(code);