@elmundi/ship-cli 0.14.1 → 0.15.3

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 +277 -119
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +68 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +34 -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,80 @@ 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 : "";
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
+ const fsmStage = roleResolved.fsm_stage || null;
162
+ const roleBody = roleResolved.prompt || "";
163
+ const systemBody = systemResolved?.prompt || "";
112
164
 
113
- // 2) Resolve task. ``--dry-run`` skips the server call and uses a
165
+ // 3) Resolve task. ``--dry-run`` skips the server call and uses a
114
166
  // synthetic task so the operator can see the prompt shape without
115
167
  // needing the new endpoints deployed.
116
168
  let task = null;
@@ -134,20 +186,40 @@ export async function runCommand(ctx, rest) {
134
186
  state: fsmStage,
135
187
  });
136
188
  if (!task) {
137
- emit(args, { status: "noop", routine: args.routine, pattern: patternId, fsm_stage: fsmStage, reason: "no_eligible_ticket" });
189
+ emit(args, {
190
+ status: "noop",
191
+ routine: args.routine,
192
+ specialist: specialistSlug,
193
+ fsm_stage: fsmStage,
194
+ reason: "no_eligible_ticket",
195
+ run_handle: runHandle,
196
+ });
138
197
  process.exit(EXIT_NO_TASK);
139
198
  }
140
199
  }
141
200
  }
142
201
 
143
- // 3) Mint a run_id + render prompt with finish-protocol values
202
+ // 4) Fetch the workspace policy preamble so the agent prompt
203
+ // carries the same standing rules the Navigator chat does. Best-
204
+ // effort: a missing token, missing API base, or a network failure
205
+ // quietly skips the prepend — local / offline runs still work.
206
+ // ``role`` is now the specialist slug directly (no more
207
+ // ``spec.role`` indirection).
208
+ const policiesPreamble = await fetchPoliciesPreamble({
209
+ apiBase,
210
+ apiToken,
211
+ workspaceId,
212
+ role: specialistSlug,
213
+ });
214
+
215
+ // 5) Mint a run_id + render prompt with finish-protocol values
144
216
  // already substituted so the agent can call /agent-runs/finish from
145
217
  // inside Cursor without holding any extra config.
146
218
  const runId = `run_${crypto.randomBytes(8).toString("hex")}`;
147
- const prompt = renderPrompt({
148
- patternBody,
149
- baseBody,
150
- role: patternId,
219
+ const renderedPrompt = renderPrompt({
220
+ patternBody: roleBody,
221
+ baseBody: systemBody,
222
+ role: specialistSlug,
151
223
  routineSpec: resolved.executable,
152
224
  task,
153
225
  fsmStage,
@@ -156,27 +228,31 @@ export async function runCommand(ctx, rest) {
156
228
  apiToken,
157
229
  workspaceId,
158
230
  runId,
159
- role: patternId,
231
+ role: specialistSlug,
160
232
  ticketRef: task?.ticket_ref || null,
161
233
  fsmStage: fsmStage || null,
162
234
  },
163
235
  });
236
+ const prompt = policiesPreamble
237
+ ? `${policiesPreamble.trim()}\n\n---\n\n${renderedPrompt}`
238
+ : renderedPrompt;
164
239
 
165
240
  // ``--dry-run`` exits here so the operator can eyeball the rendered
166
241
  // prompt + resolved task without launching an agent or touching any
167
- // tracker. Useful when iterating on pattern bodies.
242
+ // tracker. Useful when iterating on prompts.
168
243
  if (args.dryRun || ctx.dryRun) {
169
244
  if (args.json) {
170
245
  console.log(JSON.stringify({
171
246
  status: "dry-run",
172
247
  routine: args.routine,
173
- pattern: patternId,
248
+ specialist: specialistSlug,
249
+ specialist_source: roleResolved.source,
174
250
  fsm_stage: fsmStage,
175
251
  task,
176
252
  prompt,
177
253
  }, null, 2));
178
254
  } else {
179
- console.error(`# ship: dry-run routine=${args.routine} pattern=${patternId} fsm_stage=${fsmStage || "(context-free)"}`);
255
+ console.error(`# ship: dry-run handle=${runHandle} specialist=${specialistSlug} (${roleResolved.source}) fsm_stage=${fsmStage || "(context-free)"}`);
180
256
  if (task) {
181
257
  console.error(`# ship: task ticket_ref=${task.ticket_ref} title=${JSON.stringify(task.title || "")}`);
182
258
  } else {
@@ -189,8 +265,8 @@ export async function runCommand(ctx, rest) {
189
265
  }
190
266
 
191
267
  // 4) Launch agent runtime
192
- const provider = resolveProvider(config, args.routine);
193
- const branchName = makeBranchName(args.routine, task?.ticket_ref);
268
+ const provider = resolveProvider(config, args.routine || specialistSlug);
269
+ const branchName = makeBranchName(runHandle, task?.ticket_ref);
194
270
  const repoUrl = githubRepo ? `https://github.com/${githubRepo}` : null;
195
271
  if (!repoUrl) die(EXIT_USAGE, "GITHUB_REPOSITORY env var is required to launch agent");
196
272
 
@@ -207,8 +283,9 @@ export async function runCommand(ctx, rest) {
207
283
  emit(args, {
208
284
  status: "error",
209
285
  routine: args.routine,
210
- pattern: patternId,
286
+ specialist: specialistSlug,
211
287
  run_id: runId,
288
+ run_handle: runHandle,
212
289
  stage: "launch_agent",
213
290
  error: err instanceof Error ? err.message : String(err),
214
291
  });
@@ -223,13 +300,14 @@ export async function runCommand(ctx, rest) {
223
300
  emit(args, {
224
301
  status: "completed",
225
302
  routine: args.routine,
226
- pattern: patternId,
303
+ specialist: specialistSlug,
227
304
  fsm_stage: fsmStage,
228
305
  ticket_ref: task?.ticket_ref || null,
229
306
  agent_id: runtime.agentId,
230
307
  branch: runtime.branchName,
231
308
  cursor_status: runtime.status,
232
309
  run_id: runId,
310
+ run_handle: runHandle,
233
311
  });
234
312
  process.exit(EXIT_OK);
235
313
  }
@@ -256,49 +334,107 @@ function stripSlash(s) {
256
334
  }
257
335
 
258
336
 
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);
337
+ /**
338
+ * Resolve an agent role through the workspace endpoint.
339
+ *
340
+ * Returns ``{slug, name, prompt, fsm_stage, source}`` (workspace row
341
+ * or Ship default), ``null`` for 404, throws on auth/network errors
342
+ * unless ``optional`` is set (then 404 *and* errors return ``null``
343
+ * with a one-line warning).
344
+ */
345
+ async function resolveAgentRole({
346
+ apiBase,
347
+ apiToken,
348
+ workspaceId,
349
+ slug,
350
+ optional = false,
351
+ }) {
352
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent-roles/${encodeURIComponent(slug)}/resolve`;
353
+ let res;
354
+ try {
355
+ res = await fetch(url, {
356
+ headers: {
357
+ Accept: "application/json",
358
+ Authorization: `Bearer ${apiToken}`,
359
+ },
360
+ });
361
+ } catch (err) {
362
+ if (optional) {
363
+ console.error(
364
+ `warn: agent-role '${slug}' resolve failed (network): ${err instanceof Error ? err.message : err}`,
365
+ );
366
+ return null;
367
+ }
368
+ throw err;
265
369
  }
266
- throw new Error("SHIP_API_BASE not set and no api.base_url in .ship/config.yml");
370
+ if (res.status === 404) return null;
371
+ if (!res.ok) {
372
+ if (optional) {
373
+ console.error(
374
+ `warn: agent-role '${slug}' resolve returned ${res.status}; running without it`,
375
+ );
376
+ return null;
377
+ }
378
+ throw new Error(
379
+ `agent-roles resolve ${res.status}: ${(await res.text()).slice(0, 300)}`,
380
+ );
381
+ }
382
+ return res.json();
267
383
  }
268
384
 
269
385
 
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 = {};
386
+ /**
387
+ * Best-effort fetch of the workspace's policy preamble. Returns the
388
+ * markdown block to prepend, or ``null`` when there's nothing to
389
+ * inject (no policies, missing token / API base, network error,
390
+ * non-200 response). Never throws — a broken policies path mustn't
391
+ * break a routine run.
392
+ *
393
+ * Auth: workspace-membership token (``SHIP_API_TOKEN`` — same one
394
+ * the rest of ``run.mjs`` uses). The companion run-token endpoint
395
+ * at ``/v1/pipelines/runs/{run_id}/policies-preamble`` doesn't fit
396
+ * this flow because the CLI mints ``run_id`` locally; the
397
+ * workspace-scoped variant takes membership instead.
398
+ */
399
+ async function fetchPoliciesPreamble({ apiBase, apiToken, workspaceId, role }) {
400
+ if (!apiBase || !apiToken || !workspaceId) return null;
401
+ const qs = role ? `?role=${encodeURIComponent(role)}` : "";
402
+ const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/policies/preamble${qs}`;
403
+ let res;
277
404
  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 = {};
405
+ res = await fetch(url, {
406
+ headers: {
407
+ Accept: "application/json",
408
+ Authorization: `Bearer ${apiToken}`,
409
+ },
410
+ });
411
+ } catch (err) {
412
+ console.error(
413
+ `warn: policies preamble fetch failed (network): ${err instanceof Error ? err.message : err}`,
414
+ );
415
+ return null;
285
416
  }
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();
417
+ if (!res.ok) {
418
+ // 404 happens against older backends that don't have the
419
+ // workspace-scoped endpoint yet — silent skip there. Other
420
+ // statuses (401, 403, 500) get a one-line warning so operators
421
+ // notice misconfigurations without aborting the run.
422
+ if (res.status !== 404) {
423
+ console.error(
424
+ `warn: policies preamble fetch returned ${res.status}; running without preamble`,
425
+ );
426
+ }
427
+ return null;
300
428
  }
301
- return null;
429
+ let body;
430
+ try {
431
+ body = await res.json();
432
+ } catch {
433
+ return null;
434
+ }
435
+ return typeof body?.preamble === "string" && body.preamble.trim()
436
+ ? body.preamble
437
+ : null;
302
438
  }
303
439
 
304
440
 
@@ -345,6 +481,8 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
345
481
  out.push("");
346
482
  }
347
483
  out.push(expanded.trim());
484
+ out.push("");
485
+ out.push(renderLifecycleHooks());
348
486
  if (task) {
349
487
  out.push("");
350
488
  out.push("## Task");
@@ -367,6 +505,33 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
367
505
  }
368
506
 
369
507
 
508
+ function renderLifecycleHooks() {
509
+ // Phase 4: every run is bracketed by two auditable lifecycle hooks
510
+ // — knowledge-fetch first, knowledge-feedback last. We render them
511
+ // as explicit prompt instructions so they show up in the agent's
512
+ // tool-use log (the runner audits the log to flag runs that
513
+ // skipped them). Soft for now (no run-blocking enforcement) but
514
+ // the prompt is the canonical contract until the runner grows
515
+ // tool-stream interception.
516
+ return [
517
+ "## Lifecycle hooks (Phase 4)",
518
+ "",
519
+ "**First call — knowledge fetch.** Before any other tool call,",
520
+ `run \`shipctl knowledge fetch <bucket>\` for at least one bucket`,
521
+ "relevant to your role. The audit log flags runs whose first",
522
+ "tool call wasn't a knowledge fetch; do not skip this to save",
523
+ "tokens.",
524
+ "",
525
+ "**Last call — knowledge feedback.** Before calling the finish",
526
+ "endpoint, leave one-line learnings via the Ship knowledge",
527
+ "feedback channel (`shipctl feedback draft` → `feedback submit`)",
528
+ "if you discovered something a future run on this codebase would",
529
+ "want to know. Empty findings are a valid outcome — better no",
530
+ "feedback than fabricated polish.",
531
+ ].join("\n");
532
+ }
533
+
534
+
370
535
  function renderExitProtocol(ctx) {
371
536
  // Substitute the run-time values directly into the example so the
372
537
  // agent doesn't have to figure out env var hookup. The token is
@@ -412,7 +577,8 @@ curl -fsS -X POST '${apiBase}/v1/workspaces/${workspaceId}/agent-runs/finish' \\
412
577
  ${ticketLine}
413
578
  ${fsmLine}
414
579
  "stage_next": "<next FSM stage, e.g. ba_requirements>",
415
- "comment": "Markdown summary of what you did. End with [Ship SDLC:${ctx?.role || "{{ROLE}}"}].",
580
+ "description": "<Full rewritten ticket body in Markdown Problem / Goal / Acceptance criteria / Scope / Non-goals / Risks / etc. when your role's job is to shape the ticket itself (intake, BA, planner). Omit (null) when your role is not supposed to rewrite the body.>",
581
+ "comment": "<One-paragraph audit narration of what you changed and why, ending with [Ship SDLC:${ctx?.role || "{{ROLE}}"}]. Do NOT paste the new description here — that's what the description field is for.>",
416
582
  "summary": null,
417
583
  "payload": {}
418
584
  }
@@ -424,8 +590,15 @@ JSON
424
590
  - **\`ready_next_step\`** — your role finished cleanly. Two shapes:
425
591
 
426
592
  1. **You worked on a ticket.** Set \`ticket_ref\` and \`stage_next\`
427
- to the next FSM stage; server moves the ticket and posts
428
- \`comment\` if provided.
593
+ to the next FSM stage; server moves the ticket. If your role's
594
+ job is to shape the ticket (intake / BA / planner), set
595
+ \`description\` to the **full rewritten body** — the server
596
+ replaces the tracker description (Linear keeps the prior body
597
+ in the activity feed, so nothing is lost). Use \`comment\` for
598
+ a short audit narration of what changed and why; **do not put
599
+ the new spec text in a comment**, otherwise the ticket
600
+ description rots while comments accumulate. Pure-narration
601
+ roles (security-officer, retro) skip \`description\` entirely.
429
602
  2. **There was nothing to do.** Pass \`ticket_ref: null\` and omit
430
603
  \`stage_next\`. The server records the run in the audit log and
431
604
  does **nothing** else — no inbox row, no tracker mutation. This
@@ -462,33 +635,6 @@ as a one-shot credential for this run.
462
635
  }
463
636
 
464
637
 
465
- /**
466
- * Load a pattern body. Resolution order:
467
- * 1) when running inside the Ship monorepo, read from
468
- * ``artifacts/patterns/<id>/ARTIFACT.md`` on disk — fast and
469
- * always reflects the working tree (good for dry-runs / local
470
- * smoke tests before the server is rebuilt).
471
- * 2) otherwise hit the server's ``POST /fetch``.
472
- */
473
- async function loadPattern({ id, fetchBase, optional = false }) {
474
- const shipRepo = resolveShipRepoRootForCatalog();
475
- if (shipRepo) {
476
- const file = readArtifactFile(shipRepo, "pattern", id);
477
- if (file && typeof file.content === "string") return file.content;
478
- }
479
- try {
480
- const { content } = await fetchArtifact(fetchBase, "pattern", id);
481
- return content;
482
- } catch (err) {
483
- if (optional) {
484
- console.error(`warn: failed to fetch pattern '${id}': ${err.message}`);
485
- return "";
486
- }
487
- throw err;
488
- }
489
- }
490
-
491
-
492
638
  function makeBranchName(routine, ticketRef) {
493
639
  const stamp = Date.now().toString(36);
494
640
  if (ticketRef) {
@@ -505,7 +651,14 @@ function makeBranchName(routine, ticketRef) {
505
651
 
506
652
 
507
653
  function parseArgs(rest) {
508
- const out = { routine: null, cwd: null, json: false, help: false, dryRun: false };
654
+ const out = {
655
+ routine: null,
656
+ specialist: null,
657
+ cwd: null,
658
+ json: false,
659
+ help: false,
660
+ dryRun: false,
661
+ };
509
662
  const copy = [...rest];
510
663
  while (copy.length) {
511
664
  const a = copy[0];
@@ -513,6 +666,7 @@ function parseArgs(rest) {
513
666
  if (a === "--json") { out.json = true; copy.shift(); continue; }
514
667
  if (a === "--dry-run") { out.dryRun = true; copy.shift(); continue; }
515
668
  if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
669
+ if (a === "--specialist" && copy[1] !== undefined) { out.specialist = copy[1]; copy.splice(0, 2); continue; }
516
670
  if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
517
671
  // Soft-ignore legacy flags that older trigger workflows still pass —
518
672
  // the new pipeline doesn't need them and refusing would break repos
@@ -527,10 +681,14 @@ function parseArgs(rest) {
527
681
 
528
682
 
529
683
  function printHelp() {
530
- console.log(`shipctl run — execute one E14 routine end-to-end.
684
+ console.log(`shipctl run — execute one E14 routine or pipeline-pick specialist end-to-end.
531
685
 
532
686
  Run
533
687
  shipctl run --routine <id> [--json] [--cwd <dir>] [--dry-run]
688
+ shipctl run --specialist <slug> [--json] [--cwd <dir>] [--dry-run]
689
+
690
+ --routine id from process.routines in .ship/config.yml (cron-driven)
691
+ --specialist agent-role slug from the Ship registry (pipeline-pick fallback)
534
692
 
535
693
  ENV
536
694
  SHIP_API_BASE Ship server base URL (e.g. https://api.ship.elmundi.com)