@elmundi/ship-cli 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,7 +51,7 @@ FLAGS
51
51
  version. Use when intentionally bumping a pin.
52
52
  --dry-run Print the resolution plan; do not write or fetch.
53
53
  --lock After sync, materialise every pattern referenced
54
- by the declared lanes and write
54
+ by the declared routines and write
55
55
  .ship/shipctl.lock.json (used by
56
56
  'shipctl run --offline').
57
57
  --json Emit a structured JSON summary on stdout.
@@ -1,43 +1,51 @@
1
1
  import { readConfig } from "../config/io.mjs";
2
+ import { dueLanesFromRoutines, dueRoutines } from "../runtime/routines.mjs";
2
3
 
3
- const VERSION = "v1";
4
+ const VERSION = "v2";
4
5
 
5
6
  export async function triggerCommand(ctx, rest) {
6
7
  const opts = parseArgs(rest);
7
- const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
8
- const token = requireToken();
9
- let workspaceId = opts.workspace;
10
- if (!workspaceId) workspaceId = await resolveSoleWorkspace(baseUrl, token);
11
- const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
12
8
  const { config } = readConfig(opts.cwd || process.cwd());
9
+ const local = dueRoutines(config, { event: opts.event, now: opts.now ? new Date(opts.now) : new Date() });
10
+ let due = local.due;
13
11
 
14
- const result = await apiPostJson(
15
- baseUrl,
16
- `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/trigger`,
17
- {
18
- event: opts.event,
19
- config,
20
- github: {
21
- event_name: process.env.SHIP_EVENT_NAME || process.env.GITHUB_EVENT_NAME || "",
22
- ref: process.env.SHIP_REF || process.env.GITHUB_REF || "",
23
- sha: process.env.SHIP_SHA || process.env.GITHUB_SHA || "",
24
- run_id: process.env.GITHUB_RUN_ID || "",
25
- },
26
- },
27
- token,
28
- );
12
+ const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
13
+ const token = process.env.SHIP_API_TOKEN || "";
14
+ let claimStatus = "skipped:no-token";
15
+ if (token && due.length > 0 && !opts.noClaim) {
16
+ let workspaceId = opts.workspace;
17
+ if (!workspaceId) workspaceId = await resolveSoleWorkspace(baseUrl, token);
18
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
19
+ const claimed = [];
20
+ for (const routine of due) {
21
+ const claim = await claimRoutine(baseUrl, token, workspaceId, repoId, opts.event, routine);
22
+ if (claim.status === "claimed" || claim.status === "unavailable") {
23
+ claimed.push({ ...routine, claim_status: claim.status });
24
+ }
25
+ }
26
+ due = claimed;
27
+ claimStatus = "attempted";
28
+ }
29
+
30
+ const result = {
31
+ event: opts.event,
32
+ status: due.length ? "due" : "noop",
33
+ due_routines: due,
34
+ due_lanes: dueLanesFromRoutines(due),
35
+ skipped_routines: local.skipped,
36
+ claim_status: claimStatus,
37
+ };
29
38
 
30
39
  if (ctx.json || opts.json) {
31
40
  console.log(JSON.stringify(result, null, 2));
32
41
  return;
33
42
  }
34
- const due = Array.isArray(result.due_lanes) ? result.due_lanes : [];
35
43
  if (!due.length) {
36
- console.log(`Ship trigger ${opts.event}: no lanes due.`);
44
+ console.log(`Ship trigger ${opts.event}: no routines due.`);
37
45
  return;
38
46
  }
39
- console.log(`Ship trigger ${opts.event}: ${due.length} lane(s) due`);
40
- for (const lane of due) console.log(` - ${lane.lane_id}`);
47
+ console.log(`Ship trigger ${opts.event}: ${due.length} routine(s) due`);
48
+ for (const routine of due) console.log(` - ${routine.routine_id}`);
41
49
  }
42
50
 
43
51
  function explicitGlobalBaseUrl(ctx) {
@@ -45,13 +53,13 @@ function explicitGlobalBaseUrl(ctx) {
45
53
  }
46
54
 
47
55
  function printHelp() {
48
- console.log(`shipctl trigger — ask Ship which lanes are due (${VERSION})
56
+ console.log(`shipctl trigger — compute which routines are due (${VERSION})
49
57
 
50
58
  USAGE
51
- shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
59
+ shipctl trigger --event schedule [--repo <id|owner/name>] [--workspace <id>] [--json]
52
60
 
53
61
  ENV
54
- SHIP_API_TOKEN Required.
62
+ SHIP_API_TOKEN Optional. When set, due routines are claimed in Ship.
55
63
  SHIP_WORKSPACE_API_BASE Optional API base override.
56
64
  SHIP_API_BASE Fallback API base override.
57
65
  `);
@@ -64,6 +72,8 @@ function parseArgs(args) {
64
72
  repo: null,
65
73
  baseUrl: null,
66
74
  cwd: null,
75
+ now: null,
76
+ noClaim: false,
67
77
  json: false,
68
78
  };
69
79
  const copy = [...args];
@@ -87,10 +97,16 @@ function parseArgs(args) {
87
97
  consume("--workspace", "workspace") ||
88
98
  consume("--repo", "repo") ||
89
99
  consume("--base-url", "baseUrl") ||
90
- consume("--cwd", "cwd")
100
+ consume("--cwd", "cwd") ||
101
+ consume("--now", "now")
91
102
  ) {
92
103
  continue;
93
104
  }
105
+ if (copy[0] === "--no-claim") {
106
+ out.noClaim = true;
107
+ copy.shift();
108
+ continue;
109
+ }
94
110
  if (copy[0] === "--json") {
95
111
  out.json = true;
96
112
  copy.shift();
@@ -114,15 +130,6 @@ function parseArgs(args) {
114
130
  return out;
115
131
  }
116
132
 
117
- function requireToken() {
118
- const token = process.env.SHIP_API_TOKEN || "";
119
- if (!token) {
120
- console.error("SHIP_API_TOKEN is required.");
121
- process.exit(1);
122
- }
123
- return token;
124
- }
125
-
126
133
  function resolveBaseUrl(explicit) {
127
134
  if (explicit) return explicit.replace(/\/+$/, "");
128
135
  if (process.env.SHIP_WORKSPACE_API_BASE) return process.env.SHIP_WORKSPACE_API_BASE.replace(/\/+$/, "");
@@ -169,6 +176,33 @@ async function apiPostJson(baseUrl, path, body, token) {
169
176
  return apiRequest(baseUrl, path, "POST", token, body);
170
177
  }
171
178
 
179
+ async function claimRoutine(baseUrl, token, workspaceId, repoId, event, routine) {
180
+ try {
181
+ return await apiPostJson(
182
+ baseUrl,
183
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/routine-runs/claim`,
184
+ {
185
+ event,
186
+ routine_id: routine.routine_id,
187
+ window_key: routine.window_key,
188
+ scheduled_for: routine.scheduled_for,
189
+ window_start: routine.window_start,
190
+ window_end: routine.window_end,
191
+ github: {
192
+ event_name: process.env.SHIP_EVENT_NAME || process.env.GITHUB_EVENT_NAME || "",
193
+ ref: process.env.SHIP_REF || process.env.GITHUB_REF || "",
194
+ sha: process.env.SHIP_SHA || process.env.GITHUB_SHA || "",
195
+ run_id: process.env.GITHUB_RUN_ID || "",
196
+ },
197
+ },
198
+ token,
199
+ );
200
+ } catch (err) {
201
+ console.error(`warn: routine claim failed, running locally: ${err instanceof Error ? err.message : err}`);
202
+ return { status: "unavailable" };
203
+ }
204
+ }
205
+
172
206
  async function apiRequest(baseUrl, path, method, token, body) {
173
207
  const url = `${baseUrl}${path}`;
174
208
  let res;
@@ -195,6 +229,5 @@ async function apiRequest(baseUrl, path, method, token, body) {
195
229
  }
196
230
  if (res.ok) return data;
197
231
  const msg = typeof data === "string" ? data : JSON.stringify(data);
198
- console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
199
- process.exit(res.status >= 500 ? 3 : 1);
232
+ throw new Error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
200
233
  }
@@ -33,7 +33,7 @@ export const LANE_EVENT_TYPES = Object.freeze([
33
33
  export const LANE_IDEMPOTENCY_STORES = Object.freeze(["file", "backend"]);
34
34
  export const LANE_IDEMPOTENCY_RESET_ON = Object.freeze(["version-change", "manual"]);
35
35
 
36
- /* RFC-0008 C3.2 — fan-out strategy for multi-pattern lanes.
36
+ /* RFC-0008 C3.2 — fan-out strategy for multi-pattern routines.
37
37
  *
38
38
  * matrix — GitHub Actions matrix: one runner per pattern, parallel.
39
39
  * sequential — Single runner, `shipctl run` iterates patterns in order.
@@ -49,6 +49,18 @@ export const LANE_FANOUT_MODES = Object.freeze([
49
49
  ]);
50
50
  export const LANE_FANOUT_DEFAULT = "matrix";
51
51
 
52
+ export const PROCESS_AGENT_PROFILES = Object.freeze([
53
+ "auto",
54
+ "main",
55
+ "cheaper",
56
+ "cursor_agent",
57
+ "codex_cli",
58
+ "ship_cloud_agent",
59
+ "local_cli",
60
+ ]);
61
+
62
+ export const PROCESS_TRIGGER_TYPES = Object.freeze(["manual", "event", "schedule"]);
63
+
52
64
  /* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
53
65
  * file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
54
66
  * restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
@@ -198,14 +210,14 @@ export function DEFAULT_PROCESS_CONFIG() {
198
210
  { from: "dev_implementation", to: "qa_manual" },
199
211
  { from: "qa_manual", to: "pr_review" },
200
212
  ],
201
- routines: [],
213
+ routines: {},
202
214
  };
203
215
  }
204
216
 
205
217
  /**
206
218
  * Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
207
219
  * return the current schema's shape; default to v2 so new installs
208
- * benefit from lanes.
220
+ * benefit from process routines.
209
221
  */
210
222
  export function DEFAULT_CONFIG() {
211
223
  return DEFAULT_CONFIG_V2();
@@ -346,7 +358,7 @@ function validateV2Process(obj, errors, warnings) {
346
358
  }
347
359
  pushUnknownKeyWarnings(
348
360
  state,
349
- new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
361
+ new Set(["id", "name", "specialist", "agent_profile", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
350
362
  prefix,
351
363
  warnings,
352
364
  );
@@ -363,9 +375,33 @@ function validateV2Process(obj, errors, warnings) {
363
375
  if (state.instructions !== undefined && typeof state.instructions !== "string") {
364
376
  errors.push(`${prefix}.instructions: must be a string when set`);
365
377
  }
366
- if (state.specialist !== undefined && !isPlainObject(state.specialist) && typeof state.specialist !== "string") {
367
- errors.push(`${prefix}.specialist: must be an object or string when set`);
378
+ if (state.specialist !== undefined) {
379
+ if (typeof state.specialist === "string") {
380
+ if (!state.specialist.trim()) {
381
+ errors.push(`${prefix}.specialist: must be a non-empty string when set`);
382
+ }
383
+ } else if (!isPlainObject(state.specialist)) {
384
+ errors.push(`${prefix}.specialist: must be an object or string when set`);
385
+ } else {
386
+ pushUnknownKeyWarnings(
387
+ state.specialist,
388
+ new Set(["id", "name", "agent_profile"]),
389
+ `${prefix}.specialist`,
390
+ warnings,
391
+ );
392
+ requireOptionalString(state.specialist.id, `${prefix}.specialist.id`, errors, { required: false });
393
+ requireOptionalString(state.specialist.name, `${prefix}.specialist.name`, errors, { required: false });
394
+ validateProcessAgentProfile(
395
+ state.specialist.agent_profile,
396
+ `${prefix}.specialist.agent_profile`,
397
+ errors,
398
+ );
399
+ }
368
400
  }
401
+ validateProcessAgentProfile(state.agent_profile, `${prefix}.agent_profile`, errors);
402
+ validateProcessTriggers(state.triggers, `${prefix}.triggers`, errors);
403
+ validateProcessConditions(state.exit_conditions, `${prefix}.exit_conditions`, errors);
404
+ validateProcessConditions(state.block_conditions, `${prefix}.block_conditions`, errors);
369
405
  if (state.layout !== undefined) {
370
406
  if (!isPlainObject(state.layout)) {
371
407
  errors.push(`${prefix}.layout: must be an object when set`);
@@ -399,12 +435,162 @@ function validateV2Process(obj, errors, warnings) {
399
435
  if (!stateIds.has(transition.to)) {
400
436
  errors.push(`${prefix}.to: must reference an existing state id`);
401
437
  }
438
+ requireOptionalString(transition.condition, `${prefix}.condition`, errors, { required: false });
402
439
  }
403
440
  }
404
441
  }
405
442
 
406
- if (process.routines !== undefined && !Array.isArray(process.routines)) {
407
- errors.push("process.routines: must be a list when set");
443
+ validateProcessRoutines(process.routines, errors, warnings);
444
+ }
445
+
446
+ function validateProcessAgentProfile(value, prefix, errors) {
447
+ if (value === undefined || value === null) return;
448
+ if (typeof value !== "string" || !PROCESS_AGENT_PROFILES.includes(value)) {
449
+ errors.push(`${prefix}: must be one of ${PROCESS_AGENT_PROFILES.join("|")}`);
450
+ }
451
+ }
452
+
453
+ function validateProcessTriggers(value, prefix, errors) {
454
+ if (value === undefined) return;
455
+ if (!Array.isArray(value)) {
456
+ errors.push(`${prefix}: must be a list when set`);
457
+ return;
458
+ }
459
+ for (let i = 0; i < value.length; i += 1) {
460
+ const trigger = value[i];
461
+ const itemPrefix = `${prefix}[${i}]`;
462
+ if (!isPlainObject(trigger)) {
463
+ errors.push(`${itemPrefix}: must be an object`);
464
+ continue;
465
+ }
466
+ if (!PROCESS_TRIGGER_TYPES.includes(trigger.type)) {
467
+ errors.push(`${itemPrefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
468
+ }
469
+ if (trigger.type === "schedule") {
470
+ requireOptionalString(trigger.interval, `${itemPrefix}.interval`, errors, { required: true });
471
+ } else if (trigger.type === "event") {
472
+ requireOptionalString(trigger.event, `${itemPrefix}.event`, errors, { required: true });
473
+ }
474
+ }
475
+ }
476
+
477
+ function validateProcessConditions(value, prefix, errors) {
478
+ if (value === undefined) return;
479
+ if (!Array.isArray(value)) {
480
+ errors.push(`${prefix}: must be a list when set`);
481
+ return;
482
+ }
483
+ for (let i = 0; i < value.length; i += 1) {
484
+ const condition = value[i];
485
+ const itemPrefix = `${prefix}[${i}]`;
486
+ if (!isPlainObject(condition)) {
487
+ errors.push(`${itemPrefix}: must be an object`);
488
+ continue;
489
+ }
490
+ requireOptionalString(condition.expression, `${itemPrefix}.expression`, errors, { required: true });
491
+ }
492
+ }
493
+
494
+ function validateProcessRoutines(value, errors, warnings) {
495
+ if (value === undefined) return;
496
+ const entries = Array.isArray(value)
497
+ ? value.map((routine, i) => [String(i), routine, `process.routines[${i}]`])
498
+ : isPlainObject(value)
499
+ ? Object.entries(value).map(([id, routine]) => [id, routine, `process.routines.${id}`])
500
+ : null;
501
+ if (!entries) {
502
+ errors.push("process.routines: must be a map or list when set");
503
+ return;
504
+ }
505
+ for (const [routineId, routine, prefix] of entries) {
506
+ if (!isPlainObject(routine)) {
507
+ errors.push(`${prefix}: must be an object`);
508
+ continue;
509
+ }
510
+ pushUnknownKeyWarnings(
511
+ routine,
512
+ new Set([
513
+ "id",
514
+ "name",
515
+ "cadence",
516
+ "enabled",
517
+ "trigger",
518
+ "description",
519
+ "prompt",
520
+ "instructions",
521
+ "pattern",
522
+ "patterns",
523
+ "pattern_version",
524
+ "fanout",
525
+ "idempotency",
526
+ "agent_profile",
527
+ "specialist_id",
528
+ "specialist_name",
529
+ "specialist",
530
+ "schedule",
531
+ "window",
532
+ "event",
533
+ ]),
534
+ prefix,
535
+ warnings,
536
+ );
537
+ if (Array.isArray(value)) {
538
+ requireOptionalString(routine.id, `${prefix}.id`, errors, { required: true });
539
+ } else if (!LANE_ID_REGEX.test(routineId)) {
540
+ errors.push(`${prefix}: invalid routine id`);
541
+ }
542
+ requireOptionalString(routine.name, `${prefix}.name`, errors, { required: true });
543
+ requireOptionalString(routine.cadence, `${prefix}.cadence`, errors, { required: false });
544
+ if (routine.enabled !== undefined && routine.enabled !== null && typeof routine.enabled !== "boolean") {
545
+ errors.push(`${prefix}.enabled: must be a boolean when set`);
546
+ }
547
+ requireOptionalString(routine.description, `${prefix}.description`, errors, { required: false });
548
+ requireOptionalString(routine.instructions, `${prefix}.instructions`, errors, { required: false });
549
+ requireOptionalString(routine.prompt, `${prefix}.prompt`, errors, { required: false });
550
+ requireOptionalString(routine.pattern, `${prefix}.pattern`, errors, { required: false });
551
+ if (routine.patterns !== undefined) {
552
+ if (!Array.isArray(routine.patterns) || routine.patterns.some((p) => typeof p !== "string" || !p.trim())) {
553
+ errors.push(`${prefix}.patterns: must be a list of non-empty strings`);
554
+ }
555
+ }
556
+ if (routine.fanout !== undefined && !LANE_FANOUT_MODES.includes(routine.fanout)) {
557
+ errors.push(`${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}`);
558
+ }
559
+ requireOptionalString(routine.specialist_id, `${prefix}.specialist_id`, errors, { required: false });
560
+ requireOptionalString(routine.specialist_name, `${prefix}.specialist_name`, errors, { required: false });
561
+ validateProcessAgentProfile(routine.agent_profile, `${prefix}.agent_profile`, errors);
562
+ validateRoutineTrigger(routine.trigger, `${prefix}.trigger`, errors);
563
+ if (routine.schedule !== undefined && routine.schedule !== null) {
564
+ if (typeof routine.schedule !== "string" && !isPlainObject(routine.schedule)) {
565
+ errors.push(`${prefix}.schedule: must be an object or cron string when set`);
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ function validateRoutineTrigger(value, prefix, errors) {
572
+ if (value === undefined || value === null) return;
573
+ if (!isPlainObject(value)) {
574
+ errors.push(`${prefix}: must be an object when set`);
575
+ return;
576
+ }
577
+ if (!PROCESS_TRIGGER_TYPES.includes(value.type)) {
578
+ errors.push(`${prefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
579
+ }
580
+ if (value.type === "schedule") {
581
+ requireOptionalString(value.cron ?? value.interval, `${prefix}.cron`, errors, { required: true });
582
+ }
583
+ if (value.type === "event") {
584
+ requireOptionalString(value.event, `${prefix}.event`, errors, { required: true });
585
+ }
586
+ }
587
+
588
+ function requireOptionalString(value, prefix, errors, { required }) {
589
+ if ((value === undefined || value === null) && !required) return;
590
+ if (typeof value !== "string" || !value.trim()) {
591
+ errors.push(
592
+ `${prefix}: must be ${required ? "a non-empty string" : "null or a non-empty string"}`,
593
+ );
408
594
  }
409
595
  }
410
596
 
@@ -544,8 +730,8 @@ function validateLane(laneId, lane, errors, warnings) {
544
730
  ) {
545
731
  errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
546
732
  }
547
- // RFC-0008 C3.2 — `fanout` picks how multi-pattern lanes execute.
548
- // Single-pattern lanes ignore it (it's a no-op for them); we emit a
733
+ // RFC-0008 C3.2 — `fanout` picks how multi-pattern routines execute.
734
+ // Single-pattern routines ignore it (it's a no-op for them); we emit a
549
735
  // warning rather than an error so schedule templates that set it
550
736
  // blindly remain portable across single/multi-pattern use.
551
737
  if (lane.fanout !== undefined) {
@@ -0,0 +1,171 @@
1
+ export const SPECIALIST_PROMPT_GUARDRAILS = `# Ship specialist execution guardrails
2
+
3
+ These rules apply to specialist prompts assembled from process configuration,
4
+ ticket context, workspace policies, and knowledge buckets.
5
+
6
+ ## Knowledge First
7
+
8
+ Before inventing a solution or procedure, search Ship knowledge for relevant
9
+ recipes, patterns, policies, and technical context. Use repository-specific
10
+ knowledge first when a repo is known, then workspace knowledge. If no relevant
11
+ article is found, say that explicitly before proposing a new approach.
12
+
13
+ Knowledge articles can also answer clarifying technical questions when the
14
+ ticket lacks implementation detail.
15
+
16
+ ## Allowed Exits
17
+
18
+ A specialist cycle may end only through a Ship-controlled outcome:
19
+
20
+ - Ask for clarification: return a clarification intent for Ship to post as a
21
+ tracker comment and mirror into Inbox.
22
+ - Handoff: request one of the transitions explicitly configured in the process
23
+ FSM. Ship validates the transition before any side effect.
24
+ - Complete with result: produce the final result or PR reference for Ship to
25
+ record. Repository changes must be delivered through pull requests only.
26
+
27
+ ## Boundaries
28
+
29
+ Do not perform direct ticket-system mutations. Do not execute transitions that
30
+ are not declared in the process configuration. Do not push directly to protected
31
+ branches or bypass review. All material actions must be represented in Ship
32
+ audit data. Workspace policies are mandatory and override recipe guidance.`;
33
+
34
+ export function renderSpecialistPromptGuardrails() {
35
+ return `${SPECIALIST_PROMPT_GUARDRAILS.trim()}\n`;
36
+ }
37
+
38
+ export function buildSpecialistPromptBundle({
39
+ process,
40
+ state,
41
+ allowedTransitions = [],
42
+ ticket = null,
43
+ policies = null,
44
+ }) {
45
+ const specialist = normalizeSpecialist(state.specialist);
46
+ const agentProfile =
47
+ state.agent_profile || specialist.agent_profile || "auto";
48
+ return {
49
+ process: {
50
+ id: process.id,
51
+ name: process.name || process.id,
52
+ primary: process.primary === true,
53
+ },
54
+ state: {
55
+ id: state.id,
56
+ name: state.name || state.id,
57
+ instructions: typeof state.instructions === "string" ? state.instructions : "",
58
+ triggers: Array.isArray(state.triggers) ? state.triggers : [],
59
+ exit_conditions: Array.isArray(state.exit_conditions) ? state.exit_conditions : [],
60
+ block_conditions: Array.isArray(state.block_conditions) ? state.block_conditions : [],
61
+ },
62
+ specialist,
63
+ agent_profile: agentProfile,
64
+ ticket,
65
+ policies,
66
+ allowed_transitions: allowedTransitions.map((transition) => ({
67
+ from: transition.from,
68
+ to: transition.to,
69
+ condition: transition.condition || null,
70
+ })),
71
+ guardrails: renderSpecialistPromptGuardrails(),
72
+ };
73
+ }
74
+
75
+ export function renderSpecialistPromptBundleMarkdown(bundle) {
76
+ const lines = [
77
+ "# Ship Specialist Prompt Bundle",
78
+ "",
79
+ "## Process",
80
+ "",
81
+ `- Process: ${bundle.process.name} (\`${bundle.process.id}\`)`,
82
+ `- Primary: ${bundle.process.primary ? "yes" : "no"}`,
83
+ `- State: ${bundle.state.name} (\`${bundle.state.id}\`)`,
84
+ "",
85
+ "## Specialist",
86
+ "",
87
+ `- Specialist: ${bundle.specialist.name} (\`${bundle.specialist.id}\`)`,
88
+ `- Agent profile: \`${bundle.agent_profile}\``,
89
+ "",
90
+ bundle.specialist.role || "No specialist role description configured.",
91
+ "",
92
+ ];
93
+
94
+ if (bundle.state.instructions) {
95
+ lines.push("## State Instructions", "", bundle.state.instructions, "");
96
+ }
97
+
98
+ lines.push("## Ticket Context", "");
99
+ if (bundle.ticket) {
100
+ lines.push(...renderTicketLines(bundle.ticket), "");
101
+ } else {
102
+ lines.push("No ticket context was supplied. Use the Ship tracker picker before starting this specialist cycle.", "");
103
+ }
104
+
105
+ lines.push("## Workspace Policies", "");
106
+ if (bundle.policies && bundle.policies.trim()) {
107
+ lines.push(bundle.policies.trim(), "");
108
+ } else {
109
+ lines.push("Workspace policies were not supplied locally. In managed runs, Ship must inject enabled workspace policies before the agent starts.", "");
110
+ }
111
+
112
+ lines.push("## Allowed Transitions", "");
113
+ if (bundle.allowed_transitions.length) {
114
+ for (const transition of bundle.allowed_transitions) {
115
+ const condition = transition.condition ? ` when ${transition.condition}` : "";
116
+ lines.push(`- \`${transition.from}\` -> \`${transition.to}\`${condition}`);
117
+ }
118
+ } else {
119
+ lines.push("- No outgoing transitions are configured for this state. The agent may only ask for clarification or complete with a result.");
120
+ }
121
+
122
+ lines.push(
123
+ "",
124
+ "## Required Guardrails",
125
+ "",
126
+ bundle.guardrails.trim(),
127
+ "",
128
+ );
129
+ return `${lines.join("\n").trim()}\n`;
130
+ }
131
+
132
+ function normalizeSpecialist(value) {
133
+ if (typeof value === "string") {
134
+ return { id: value, name: value, role: "", agent_profile: null };
135
+ }
136
+ if (value && typeof value === "object" && !Array.isArray(value)) {
137
+ return {
138
+ id: String(value.id || value.name || "specialist"),
139
+ name: String(value.name || value.id || "Specialist"),
140
+ role: typeof value.role === "string" ? value.role : "",
141
+ agent_profile: typeof value.agent_profile === "string" ? value.agent_profile : null,
142
+ };
143
+ }
144
+ return { id: "specialist", name: "Specialist", role: "", agent_profile: null };
145
+ }
146
+
147
+ function renderTicketLines(ticket) {
148
+ const lines = [];
149
+ for (const key of ["id", "key", "title", "url", "status", "description"]) {
150
+ const value = ticket[key];
151
+ if (typeof value === "string" && value.trim()) {
152
+ lines.push(`- ${titleCase(key)}: ${value.trim()}`);
153
+ }
154
+ }
155
+ const extra = Object.entries(ticket).filter(
156
+ ([key, value]) =>
157
+ !["id", "key", "title", "url", "status", "description"].includes(key) &&
158
+ value != null &&
159
+ typeof value !== "object",
160
+ );
161
+ for (const [key, value] of extra) {
162
+ lines.push(`- ${titleCase(key)}: ${String(value)}`);
163
+ }
164
+ return lines.length ? lines : ["- Ticket context object was empty."];
165
+ }
166
+
167
+ function titleCase(value) {
168
+ return value
169
+ .replace(/[_-]+/g, " ")
170
+ .replace(/\b\w/g, (char) => char.toUpperCase());
171
+ }