@elmundi/ship-cli 0.14.2 → 0.15.4

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.
Files changed (39) hide show
  1. package/README.md +17 -16
  2. package/bin/shipctl.mjs +4 -80
  3. package/lib/commands/feedback.mjs +1 -1
  4. package/lib/commands/help.mjs +47 -131
  5. package/lib/commands/init.mjs +17 -250
  6. package/lib/commands/knowledge.mjs +25 -328
  7. package/lib/commands/preflight.mjs +213 -0
  8. package/lib/commands/run.mjs +298 -119
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +73 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +39 -0
  13. package/lib/templates.mjs +2 -2
  14. package/lib/verify/checks/agents-on-disk.mjs +5 -28
  15. package/lib/verify/registry.mjs +7 -8
  16. package/package.json +1 -1
  17. package/lib/artifacts/fs-index.mjs +0 -230
  18. package/lib/cache/store.mjs +0 -422
  19. package/lib/commands/bootstrap.mjs +0 -4
  20. package/lib/commands/callback.mjs +0 -742
  21. package/lib/commands/docs.mjs +0 -90
  22. package/lib/commands/kickoff.mjs +0 -192
  23. package/lib/commands/lanes.mjs +0 -566
  24. package/lib/commands/manifest-catalog.mjs +0 -251
  25. package/lib/commands/migrate.mjs +0 -204
  26. package/lib/commands/new.mjs +0 -452
  27. package/lib/commands/patterns.mjs +0 -160
  28. package/lib/commands/process.mjs +0 -388
  29. package/lib/commands/search.mjs +0 -43
  30. package/lib/commands/sync.mjs +0 -824
  31. package/lib/config/migrate.mjs +0 -223
  32. package/lib/find-ship-root.mjs +0 -75
  33. package/lib/process/specialist-prompt-contract.mjs +0 -171
  34. package/lib/state/lockfile.mjs +0 -180
  35. package/lib/vendor/run-agent.workflow.yml +0 -254
  36. package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
  37. package/lib/verify/checks/cache-integrity.mjs +0 -51
  38. package/lib/verify/checks/gitignore-cache.mjs +0 -51
  39. package/lib/verify/checks/rules-markers.mjs +0 -135
@@ -4,19 +4,21 @@
4
4
  * Customer's GH Actions cron fires this once per routine slot. The
5
5
  * pipeline:
6
6
  *
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
7
+ * 1. Read the routine from `.ship/config.yml` (specialist slug +
8
+ * optional inline prompt).
9
+ * 2. Resolve the agent role body via the workspace endpoint
10
+ * `GET /v1/workspaces/{ws}/agent-roles/{slug}/resolve`
11
+ * workspace overrides win, otherwise the Ship default. Pull
12
+ * the `system` (shared base) body in parallel.
13
+ * 3. If the resolved role declares `fsm_stage`, ask Ship server
14
+ * for the next ticket in that stage
13
15
  * (`GET /v1/.../tracker/next?state=<stage>`). Server picks the
14
16
  * 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.
17
+ * 4. Mint a `run_id` and render the prompt: system body + role
18
+ * body + routine prompt + ticket details + a finish-protocol
19
+ * block with `SHIP_API_BASE`, `SHIP_API_TOKEN`, `SHIP_WORKSPACE_ID`,
20
+ * `RUN_ID`, `TICKET_REF`, `FSM_STAGE` already substituted so the
21
+ * agent can call the finish endpoint directly.
20
22
  * 5. Launch the configured agent runtime (`cli/lib/agents/`) — Cursor
21
23
  * Cloud today. Block until the runtime terminates.
22
24
  * 6. The agent itself calls
@@ -46,15 +48,12 @@
46
48
  import crypto from "node:crypto";
47
49
  import path from "node:path";
48
50
 
49
- import yaml from "yaml";
50
-
51
51
  import { readConfig, findShipRoot } from "../config/io.mjs";
52
52
  import { resolveExecutable } from "../runtime/routines.mjs";
53
- import { fetchArtifact } from "../http.mjs";
54
- import { readArtifactFile } from "../artifacts/fs-index.mjs";
55
- import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
56
53
  import { resolveProvider, runAgent } from "../agents/index.mjs";
57
54
 
55
+ const SYSTEM_ROLE_SLUG = "system";
56
+
58
57
 
59
58
  const EXIT_OK = 0;
60
59
  const EXIT_USAGE = 1;
@@ -73,8 +72,14 @@ export async function runCommand(ctx, rest) {
73
72
  printHelp();
74
73
  process.exit(EXIT_OK);
75
74
  }
76
- if (!args.routine) {
77
- die(EXIT_USAGE, "`--routine <id>` is required.\nRun: shipctl run --help");
75
+ if (!args.routine && !args.specialist) {
76
+ die(
77
+ EXIT_USAGE,
78
+ "either `--routine <id>` or `--specialist <slug>` is required.\nRun: shipctl run --help",
79
+ );
80
+ }
81
+ if (args.routine && args.specialist) {
82
+ die(EXIT_USAGE, "`--routine` and `--specialist` are mutually exclusive.");
78
83
  }
79
84
 
80
85
  const cwd = args.cwd || process.cwd();
@@ -84,33 +89,83 @@ export async function runCommand(ctx, rest) {
84
89
  }
85
90
 
86
91
  const { config } = readConfig(cwd);
87
- const resolved = resolveExecutable(config, args.routine);
88
- if (!resolved) {
89
- die(EXIT_USAGE, `unknown routine '${args.routine}' in .ship/config.yml`);
92
+ // Routine mode: resolve from ``.ship/config.yml``. Specialist mode
93
+ // (used by the pipeline-pick fallback in the trigger workflow):
94
+ // synthesize a minimal executable so the rest of the pipeline can
95
+ // stay routine-shaped without inventing a ``pipeline:<slug>``
96
+ // routine in the YAML.
97
+ let resolved;
98
+ if (args.specialist) {
99
+ resolved = {
100
+ kind: "specialist",
101
+ id: args.specialist,
102
+ source: { specialist: args.specialist },
103
+ executable: {
104
+ id: args.specialist,
105
+ type: "specialist",
106
+ kind: "pipeline_pick",
107
+ specialist: args.specialist,
108
+ prompt: null,
109
+ },
110
+ };
111
+ } else {
112
+ resolved = resolveExecutable(config, args.routine);
113
+ if (!resolved) {
114
+ die(EXIT_USAGE, `unknown routine '${args.routine}' in .ship/config.yml`);
115
+ }
90
116
  }
91
117
 
118
+ // ``runId`` for emit/branch naming carries either the routine id or
119
+ // the specialist slug. Logging downstream uses ``runHandle``.
120
+ const runHandle = args.routine || `pipeline:${args.specialist}`;
121
+
92
122
  const env = readEnv();
93
123
  const { apiBase, apiToken, workspaceId, githubRepo } = env;
94
124
 
95
- // 1) Resolve pattern
96
- const patternId = resolved.executable.pattern;
97
- if (!patternId) {
98
- die(EXIT_USAGE, `routine '${args.routine}' has no pattern set`);
125
+ // 1) Resolve specialist slug.
126
+ // ``routine.specialist`` is the canonical Phase-2.4 form; the legacy
127
+ // ``routine.pattern`` is mapped to a slug in
128
+ // ``cli/lib/runtime/routines.mjs`` (drops the ``role-`` prefix).
129
+ const specialistSlug = resolved.executable.specialist || args.specialist;
130
+ if (!specialistSlug) {
131
+ die(
132
+ EXIT_USAGE,
133
+ `routine '${args.routine}' has no 'specialist:' (or legacy 'pattern:') set`,
134
+ );
135
+ }
136
+ if (!apiBase || !apiToken || !workspaceId) {
137
+ die(
138
+ EXIT_USAGE,
139
+ "agent-role resolve requires SHIP_API_BASE + SHIP_API_TOKEN + SHIP_WORKSPACE_ID",
140
+ );
99
141
  }
100
142
 
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
143
+ // 2) Pull the resolved role + the shared system prompt in parallel.
144
+ // ``resolveAgentRole`` returns the workspace override when present,
145
+ // otherwise falls back to the Ship default. ``system`` is fetched
146
+ // optional older deployments may not have it; we render without
147
+ // a system header in that case.
148
+ const [roleResolved, systemResolved] = await Promise.all([
149
+ resolveAgentRole({ apiBase, apiToken, workspaceId, slug: specialistSlug }),
150
+ resolveAgentRole({
151
+ apiBase,
152
+ apiToken,
153
+ workspaceId,
154
+ slug: SYSTEM_ROLE_SLUG,
155
+ optional: true,
156
+ }),
157
+ ]);
158
+ if (!roleResolved) {
159
+ die(EXIT_USAGE, `unknown agent role '${specialistSlug}' for this workspace`);
160
+ }
161
+ // Per-routine FSM stage override takes precedence over the role's
162
+ // default. Lets one role (``ba``) drive both ``ba_requirements`` for
163
+ // SDLC and ``wbs`` for decomposition without per-process role clones.
164
+ const fsmStage = resolved.executable?.fsm_stage || roleResolved.fsm_stage || null;
165
+ const roleBody = roleResolved.prompt || "";
166
+ const systemBody = systemResolved?.prompt || "";
167
+
168
+ // 3) Resolve task. ``--dry-run`` skips the server call and uses a
114
169
  // synthetic task so the operator can see the prompt shape without
115
170
  // needing the new endpoints deployed.
116
171
  let task = null;
@@ -134,20 +189,40 @@ export async function runCommand(ctx, rest) {
134
189
  state: fsmStage,
135
190
  });
136
191
  if (!task) {
137
- emit(args, { status: "noop", routine: args.routine, pattern: patternId, fsm_stage: fsmStage, reason: "no_eligible_ticket" });
192
+ emit(args, {
193
+ status: "noop",
194
+ routine: args.routine,
195
+ specialist: specialistSlug,
196
+ fsm_stage: fsmStage,
197
+ reason: "no_eligible_ticket",
198
+ run_handle: runHandle,
199
+ });
138
200
  process.exit(EXIT_NO_TASK);
139
201
  }
140
202
  }
141
203
  }
142
204
 
143
- // 3) Mint a run_id + render prompt with finish-protocol values
205
+ // 4) Fetch the workspace policy preamble so the agent prompt
206
+ // carries the same standing rules the Navigator chat does. Best-
207
+ // effort: a missing token, missing API base, or a network failure
208
+ // quietly skips the prepend — local / offline runs still work.
209
+ // ``role`` is now the specialist slug directly (no more
210
+ // ``spec.role`` indirection).
211
+ const policiesPreamble = await fetchPoliciesPreamble({
212
+ apiBase,
213
+ apiToken,
214
+ workspaceId,
215
+ role: specialistSlug,
216
+ });
217
+
218
+ // 5) Mint a run_id + render prompt with finish-protocol values
144
219
  // already substituted so the agent can call /agent-runs/finish from
145
220
  // inside Cursor without holding any extra config.
146
221
  const runId = `run_${crypto.randomBytes(8).toString("hex")}`;
147
- const prompt = renderPrompt({
148
- patternBody,
149
- baseBody,
150
- role: patternId,
222
+ const renderedPrompt = renderPrompt({
223
+ patternBody: roleBody,
224
+ baseBody: systemBody,
225
+ role: specialistSlug,
151
226
  routineSpec: resolved.executable,
152
227
  task,
153
228
  fsmStage,
@@ -156,27 +231,31 @@ export async function runCommand(ctx, rest) {
156
231
  apiToken,
157
232
  workspaceId,
158
233
  runId,
159
- role: patternId,
234
+ role: specialistSlug,
160
235
  ticketRef: task?.ticket_ref || null,
161
236
  fsmStage: fsmStage || null,
162
237
  },
163
238
  });
239
+ const prompt = policiesPreamble
240
+ ? `${policiesPreamble.trim()}\n\n---\n\n${renderedPrompt}`
241
+ : renderedPrompt;
164
242
 
165
243
  // ``--dry-run`` exits here so the operator can eyeball the rendered
166
244
  // prompt + resolved task without launching an agent or touching any
167
- // tracker. Useful when iterating on pattern bodies.
245
+ // tracker. Useful when iterating on prompts.
168
246
  if (args.dryRun || ctx.dryRun) {
169
247
  if (args.json) {
170
248
  console.log(JSON.stringify({
171
249
  status: "dry-run",
172
250
  routine: args.routine,
173
- pattern: patternId,
251
+ specialist: specialistSlug,
252
+ specialist_source: roleResolved.source,
174
253
  fsm_stage: fsmStage,
175
254
  task,
176
255
  prompt,
177
256
  }, null, 2));
178
257
  } else {
179
- console.error(`# ship: dry-run routine=${args.routine} pattern=${patternId} fsm_stage=${fsmStage || "(context-free)"}`);
258
+ console.error(`# ship: dry-run handle=${runHandle} specialist=${specialistSlug} (${roleResolved.source}) fsm_stage=${fsmStage || "(context-free)"}`);
180
259
  if (task) {
181
260
  console.error(`# ship: task ticket_ref=${task.ticket_ref} title=${JSON.stringify(task.title || "")}`);
182
261
  } else {
@@ -189,8 +268,8 @@ export async function runCommand(ctx, rest) {
189
268
  }
190
269
 
191
270
  // 4) Launch agent runtime
192
- const provider = resolveProvider(config, args.routine);
193
- const branchName = makeBranchName(args.routine, task?.ticket_ref);
271
+ const provider = resolveProvider(config, args.routine || specialistSlug);
272
+ const branchName = makeBranchName(runHandle, task?.ticket_ref);
194
273
  const repoUrl = githubRepo ? `https://github.com/${githubRepo}` : null;
195
274
  if (!repoUrl) die(EXIT_USAGE, "GITHUB_REPOSITORY env var is required to launch agent");
196
275
 
@@ -207,8 +286,9 @@ export async function runCommand(ctx, rest) {
207
286
  emit(args, {
208
287
  status: "error",
209
288
  routine: args.routine,
210
- pattern: patternId,
289
+ specialist: specialistSlug,
211
290
  run_id: runId,
291
+ run_handle: runHandle,
212
292
  stage: "launch_agent",
213
293
  error: err instanceof Error ? err.message : String(err),
214
294
  });
@@ -223,13 +303,14 @@ export async function runCommand(ctx, rest) {
223
303
  emit(args, {
224
304
  status: "completed",
225
305
  routine: args.routine,
226
- pattern: patternId,
306
+ specialist: specialistSlug,
227
307
  fsm_stage: fsmStage,
228
308
  ticket_ref: task?.ticket_ref || null,
229
309
  agent_id: runtime.agentId,
230
310
  branch: runtime.branchName,
231
311
  cursor_status: runtime.status,
232
312
  run_id: runId,
313
+ run_handle: runHandle,
233
314
  });
234
315
  process.exit(EXIT_OK);
235
316
  }
@@ -256,49 +337,107 @@ function stripSlash(s) {
256
337
  }
257
338
 
258
339
 
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);
340
+ /**
341
+ * Resolve an agent role through the workspace endpoint.
342
+ *
343
+ * Returns ``{slug, name, prompt, fsm_stage, source}`` (workspace row
344
+ * or Ship default), ``null`` for 404, throws on auth/network errors
345
+ * unless ``optional`` is set (then 404 *and* errors return ``null``
346
+ * with a one-line warning).
347
+ */
348
+ async function resolveAgentRole({
349
+ apiBase,
350
+ apiToken,
351
+ workspaceId,
352
+ slug,
353
+ optional = false,
354
+ }) {
355
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent-roles/${encodeURIComponent(slug)}/resolve`;
356
+ let res;
357
+ try {
358
+ res = await fetch(url, {
359
+ headers: {
360
+ Accept: "application/json",
361
+ Authorization: `Bearer ${apiToken}`,
362
+ },
363
+ });
364
+ } catch (err) {
365
+ if (optional) {
366
+ console.error(
367
+ `warn: agent-role '${slug}' resolve failed (network): ${err instanceof Error ? err.message : err}`,
368
+ );
369
+ return null;
370
+ }
371
+ throw err;
265
372
  }
266
- throw new Error("SHIP_API_BASE not set and no api.base_url in .ship/config.yml");
373
+ if (res.status === 404) return null;
374
+ if (!res.ok) {
375
+ if (optional) {
376
+ console.error(
377
+ `warn: agent-role '${slug}' resolve returned ${res.status}; running without it`,
378
+ );
379
+ return null;
380
+ }
381
+ throw new Error(
382
+ `agent-roles resolve ${res.status}: ${(await res.text()).slice(0, 300)}`,
383
+ );
384
+ }
385
+ return res.json();
267
386
  }
268
387
 
269
388
 
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 = {};
389
+ /**
390
+ * Best-effort fetch of the workspace's policy preamble. Returns the
391
+ * markdown block to prepend, or ``null`` when there's nothing to
392
+ * inject (no policies, missing token / API base, network error,
393
+ * non-200 response). Never throws — a broken policies path mustn't
394
+ * break a routine run.
395
+ *
396
+ * Auth: workspace-membership token (``SHIP_API_TOKEN`` — same one
397
+ * the rest of ``run.mjs`` uses). The companion run-token endpoint
398
+ * at ``/v1/pipelines/runs/{run_id}/policies-preamble`` doesn't fit
399
+ * this flow because the CLI mints ``run_id`` locally; the
400
+ * workspace-scoped variant takes membership instead.
401
+ */
402
+ async function fetchPoliciesPreamble({ apiBase, apiToken, workspaceId, role }) {
403
+ if (!apiBase || !apiToken || !workspaceId) return null;
404
+ const qs = role ? `?role=${encodeURIComponent(role)}` : "";
405
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/policies/preamble${qs}`;
406
+ let res;
277
407
  try {
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 = {};
408
+ res = await fetch(url, {
409
+ headers: {
410
+ Accept: "application/json",
411
+ Authorization: `Bearer ${apiToken}`,
412
+ },
413
+ });
414
+ } catch (err) {
415
+ console.error(
416
+ `warn: policies preamble fetch failed (network): ${err instanceof Error ? err.message : err}`,
417
+ );
418
+ return null;
285
419
  }
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();
420
+ if (!res.ok) {
421
+ // 404 happens against older backends that don't have the
422
+ // workspace-scoped endpoint yet — silent skip there. Other
423
+ // statuses (401, 403, 500) get a one-line warning so operators
424
+ // notice misconfigurations without aborting the run.
425
+ if (res.status !== 404) {
426
+ console.error(
427
+ `warn: policies preamble fetch returned ${res.status}; running without preamble`,
428
+ );
429
+ }
430
+ return null;
431
+ }
432
+ let body;
433
+ try {
434
+ body = await res.json();
435
+ } catch {
436
+ return null;
300
437
  }
301
- return null;
438
+ return typeof body?.preamble === "string" && body.preamble.trim()
439
+ ? body.preamble
440
+ : null;
302
441
  }
303
442
 
304
443
 
@@ -345,7 +484,27 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
345
484
  out.push("");
346
485
  }
347
486
  out.push(expanded.trim());
487
+ out.push("");
488
+ out.push(renderLifecycleHooks());
348
489
  if (task) {
490
+ // ELS-86: parent project context (Brief / WBS / Architecture /
491
+ // Test architecture / Tasks). The server lifts and caps it; we
492
+ // render it BEFORE the per-ticket block so the agent sees the
493
+ // surrounding plan first, then narrows to its own scope. Only
494
+ // present when the ticket is part of a decomposed project — the
495
+ // server returns ``project_context: null`` otherwise and we
496
+ // skip the block silently.
497
+ if (typeof task.project_context === "string" && task.project_context.trim()) {
498
+ out.push("");
499
+ out.push("## Project context");
500
+ out.push("");
501
+ out.push(
502
+ "_Excerpt of the parent project body. Read for surrounding plan;",
503
+ "your scope is the per-task block below, not the whole project._",
504
+ );
505
+ out.push("");
506
+ out.push(task.project_context.trim());
507
+ }
349
508
  out.push("");
350
509
  out.push("## Task");
351
510
  out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
@@ -367,6 +526,33 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
367
526
  }
368
527
 
369
528
 
529
+ function renderLifecycleHooks() {
530
+ // Phase 4: every run is bracketed by two auditable lifecycle hooks
531
+ // — knowledge-fetch first, knowledge-feedback last. We render them
532
+ // as explicit prompt instructions so they show up in the agent's
533
+ // tool-use log (the runner audits the log to flag runs that
534
+ // skipped them). Soft for now (no run-blocking enforcement) but
535
+ // the prompt is the canonical contract until the runner grows
536
+ // tool-stream interception.
537
+ return [
538
+ "## Lifecycle hooks (Phase 4)",
539
+ "",
540
+ "**First call — knowledge fetch.** Before any other tool call,",
541
+ `run \`shipctl knowledge fetch <bucket>\` for at least one bucket`,
542
+ "relevant to your role. The audit log flags runs whose first",
543
+ "tool call wasn't a knowledge fetch; do not skip this to save",
544
+ "tokens.",
545
+ "",
546
+ "**Last call — knowledge feedback.** Before calling the finish",
547
+ "endpoint, leave one-line learnings via the Ship knowledge",
548
+ "feedback channel (`shipctl feedback draft` → `feedback submit`)",
549
+ "if you discovered something a future run on this codebase would",
550
+ "want to know. Empty findings are a valid outcome — better no",
551
+ "feedback than fabricated polish.",
552
+ ].join("\n");
553
+ }
554
+
555
+
370
556
  function renderExitProtocol(ctx) {
371
557
  // Substitute the run-time values directly into the example so the
372
558
  // agent doesn't have to figure out env var hookup. The token is
@@ -470,40 +656,21 @@ as a one-shot credential for this run.
470
656
  }
471
657
 
472
658
 
473
- /**
474
- * Load a pattern body. Resolution order:
475
- * 1) when running inside the Ship monorepo, read from
476
- * ``artifacts/patterns/<id>/ARTIFACT.md`` on disk — fast and
477
- * always reflects the working tree (good for dry-runs / local
478
- * smoke tests before the server is rebuilt).
479
- * 2) otherwise hit the server's ``POST /fetch``.
480
- */
481
- async function loadPattern({ id, fetchBase, optional = false }) {
482
- const shipRepo = resolveShipRepoRootForCatalog();
483
- if (shipRepo) {
484
- const file = readArtifactFile(shipRepo, "pattern", id);
485
- if (file && typeof file.content === "string") return file.content;
486
- }
487
- try {
488
- const { content } = await fetchArtifact(fetchBase, "pattern", id);
489
- return content;
490
- } catch (err) {
491
- if (optional) {
492
- console.error(`warn: failed to fetch pattern '${id}': ${err.message}`);
493
- return "";
494
- }
495
- throw err;
496
- }
497
- }
498
-
499
-
500
659
  function makeBranchName(routine, ticketRef) {
501
660
  const stamp = Date.now().toString(36);
661
+ // Sanitize ``routine`` too — for pipeline-pick runs ``runHandle`` is
662
+ // ``pipeline:<specialist>`` and the bare ``:`` is in git's reserved
663
+ // character set, which Cursor's ``/v0/agents`` validator rejects
664
+ // with HTTP 400 ("Invalid branch name. Branch names cannot start
665
+ // with '-', contain invalid characters (spaces, ~, ^, :, ?, *, [,
666
+ // ], \\, .., @{, //), end with '/', '.lock', or '.', or be named
667
+ // 'HEAD'."). Same regex as the ticketRef path.
668
+ const safeRoutine = String(routine).replace(/[^a-zA-Z0-9_-]/g, "-");
502
669
  if (ticketRef) {
503
670
  const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
504
- return `cursor/ship-${routine}-${safe}-${stamp}`;
671
+ return `cursor/ship-${safeRoutine}-${safe}-${stamp}`;
505
672
  }
506
- return `cursor/ship-${routine}-${stamp}`;
673
+ return `cursor/ship-${safeRoutine}-${stamp}`;
507
674
  }
508
675
 
509
676
 
@@ -513,7 +680,14 @@ function makeBranchName(routine, ticketRef) {
513
680
 
514
681
 
515
682
  function parseArgs(rest) {
516
- const out = { routine: null, cwd: null, json: false, help: false, dryRun: false };
683
+ const out = {
684
+ routine: null,
685
+ specialist: null,
686
+ cwd: null,
687
+ json: false,
688
+ help: false,
689
+ dryRun: false,
690
+ };
517
691
  const copy = [...rest];
518
692
  while (copy.length) {
519
693
  const a = copy[0];
@@ -521,6 +695,7 @@ function parseArgs(rest) {
521
695
  if (a === "--json") { out.json = true; copy.shift(); continue; }
522
696
  if (a === "--dry-run") { out.dryRun = true; copy.shift(); continue; }
523
697
  if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
698
+ if (a === "--specialist" && copy[1] !== undefined) { out.specialist = copy[1]; copy.splice(0, 2); continue; }
524
699
  if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
525
700
  // Soft-ignore legacy flags that older trigger workflows still pass —
526
701
  // the new pipeline doesn't need them and refusing would break repos
@@ -535,10 +710,14 @@ function parseArgs(rest) {
535
710
 
536
711
 
537
712
  function printHelp() {
538
- console.log(`shipctl run — execute one E14 routine end-to-end.
713
+ console.log(`shipctl run — execute one E14 routine or pipeline-pick specialist end-to-end.
539
714
 
540
715
  Run
541
716
  shipctl run --routine <id> [--json] [--cwd <dir>] [--dry-run]
717
+ shipctl run --specialist <slug> [--json] [--cwd <dir>] [--dry-run]
718
+
719
+ --routine id from process.routines in .ship/config.yml (cron-driven)
720
+ --specialist agent-role slug from the Ship registry (pipeline-pick fallback)
542
721
 
543
722
  ENV
544
723
  SHIP_API_BASE Ship server base URL (e.g. https://api.ship.elmundi.com)