@elmundi/ship-cli 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/shipctl.mjs CHANGED
@@ -81,6 +81,18 @@ try {
81
81
  process.exit(0);
82
82
  }
83
83
 
84
+ if (cmd === "inbox") {
85
+ const { inboxCommand } = await import("../lib/commands/inbox.mjs");
86
+ await inboxCommand(ctx, rest);
87
+ process.exit(0);
88
+ }
89
+
90
+ if (cmd === "project") {
91
+ const { projectCommand } = await import("../lib/commands/project.mjs");
92
+ await projectCommand(ctx, rest);
93
+ process.exit(0);
94
+ }
95
+
84
96
  if (cmd === "run") {
85
97
  const { runCommand } = await import("../lib/commands/run.mjs");
86
98
  await runCommand(ctx, rest);
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shared client helpers for ``shipctl`` commands that hit the
3
+ * agent-runs API (inbox / project / etc).
4
+ *
5
+ * Reuses the same auth model the existing ``shipctl knowledge`` flow
6
+ * established: bearer ``SHIP_API_TOKEN`` PAT, base URL resolved via
7
+ * the same precedence chain (flag → ``SHIP_WORKSPACE_API_BASE`` →
8
+ * ``SHIP_API_BASE`` → prod default), workspace id resolved via flag /
9
+ * ``SHIP_WORKSPACE_ID`` / sole-workspace lookup.
10
+ *
11
+ * Exit codes are uniform across commands so wrapper scripts can
12
+ * branch on them: 0 ok, 1 arg/config error, 2 auth error (401),
13
+ * 3 network/5xx.
14
+ */
15
+
16
+ const PROD_BASE = "https://api.ship.elmundi.com";
17
+
18
+
19
+ /**
20
+ * @param {string|null|undefined} explicit
21
+ * @returns {string}
22
+ */
23
+ export function resolveBaseUrl(explicit) {
24
+ if (explicit) return explicit.replace(/\/+$/, "");
25
+ const envWorkspace = process.env.SHIP_WORKSPACE_API_BASE;
26
+ if (envWorkspace) return envWorkspace.replace(/\/+$/, "");
27
+ const envGeneric = process.env.SHIP_API_BASE;
28
+ if (envGeneric) return envGeneric.replace(/\/+$/, "");
29
+ return PROD_BASE;
30
+ }
31
+
32
+
33
+ export function requireToken() {
34
+ const token = process.env.SHIP_API_TOKEN || "";
35
+ if (!token) {
36
+ console.error(
37
+ "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
38
+ );
39
+ process.exit(1);
40
+ }
41
+ return token;
42
+ }
43
+
44
+
45
+ /**
46
+ * @param {string} baseUrl
47
+ * @param {string} token
48
+ * @returns {Promise<string>}
49
+ */
50
+ export async function resolveSoleWorkspace(baseUrl, token) {
51
+ const rows = await apiRequest(baseUrl, "/v1/workspaces", "GET", token, null);
52
+ if (!Array.isArray(rows) || rows.length === 0) {
53
+ console.error("No workspaces visible to this token.");
54
+ process.exit(1);
55
+ }
56
+ if (rows.length > 1) {
57
+ const ids = rows.map((r) => `${r.id} (${r.name ?? "?"})`).join("\n ");
58
+ console.error(
59
+ `Token has access to more than one workspace; pass --workspace <id>.\n ${ids}`,
60
+ );
61
+ process.exit(1);
62
+ }
63
+ return String(rows[0].id);
64
+ }
65
+
66
+
67
+ /**
68
+ * @param {{ workspace?: string|null, baseUrl?: string|null }} opts
69
+ * @param {{ baseUrl?: string|null, baseUrlSource?: string|null }} ctx
70
+ */
71
+ export async function resolveContext(opts, ctx) {
72
+ const baseUrl = resolveBaseUrl(
73
+ opts.baseUrl || (ctx?.baseUrlSource === "flag" ? ctx?.baseUrl : null),
74
+ );
75
+ const token = requireToken();
76
+ let workspaceId =
77
+ opts.workspace || (process.env.SHIP_WORKSPACE_ID || "").trim() || "";
78
+ if (!workspaceId) {
79
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
80
+ }
81
+ return { baseUrl, token, workspaceId };
82
+ }
83
+
84
+
85
+ /**
86
+ * @param {string} baseUrl
87
+ * @param {string} path
88
+ * @param {"GET"|"POST"|"DELETE"} method
89
+ * @param {string} token
90
+ * @param {Record<string, unknown>|null} body
91
+ */
92
+ export async function apiRequest(baseUrl, path, method, token, body) {
93
+ const url = `${baseUrl}${path}`;
94
+ let res;
95
+ try {
96
+ res = await fetch(url, {
97
+ method,
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ Accept: "application/json",
101
+ Authorization: `Bearer ${token}`,
102
+ },
103
+ body: body === null ? undefined : JSON.stringify(body),
104
+ });
105
+ } catch (err) {
106
+ console.error(
107
+ `Network error calling ${url}: ${err instanceof Error ? err.message : err}`,
108
+ );
109
+ process.exit(3);
110
+ }
111
+ const text = await res.text();
112
+ let data = null;
113
+ try {
114
+ data = text ? JSON.parse(text) : null;
115
+ } catch {
116
+ data = text;
117
+ }
118
+ if (res.ok) return data;
119
+ if (res.status === 401) {
120
+ console.error(
121
+ `HTTP 401 on ${method} ${url} — SHIP_API_TOKEN is missing, expired, or lacks workspace access.`,
122
+ );
123
+ process.exit(2);
124
+ }
125
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
126
+ console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
127
+ process.exit(res.status >= 500 ? 3 : 1);
128
+ }
129
+
130
+
131
+ /**
132
+ * Read a value from a flag (`--name`/`--name=value`). Returns true if
133
+ * consumed; mutates ``copy`` in place.
134
+ *
135
+ * @param {string[]} copy
136
+ * @param {string} flag
137
+ * @param {Record<string, string|null>} out
138
+ * @param {string} key
139
+ */
140
+ export function consumeStringFlag(copy, flag, out, key) {
141
+ if (copy[0] === flag && copy[1] !== undefined) {
142
+ copy.shift();
143
+ out[key] = String(copy.shift());
144
+ return true;
145
+ }
146
+ const p = `${flag}=`;
147
+ if (copy[0] && copy[0].startsWith(p)) {
148
+ out[key] = copy[0].slice(p.length);
149
+ copy.shift();
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+
156
+ /**
157
+ * Read a body / file argument. ``--body <inline>`` takes the next arg
158
+ * as the literal body; ``--body-file <path>`` reads the file content.
159
+ * Mutually exclusive — the second wins if both are given but we warn.
160
+ *
161
+ * @param {string[]} copy
162
+ * @param {Record<string, string|null>} out
163
+ */
164
+ export function consumeBodyFlags(copy, out) {
165
+ if (consumeStringFlag(copy, "--body", out, "body")) return true;
166
+ if (consumeStringFlag(copy, "--body-file", out, "bodyFile")) return true;
167
+ return false;
168
+ }
169
+
170
+
171
+ /**
172
+ * Resolve ``--body`` vs ``--body-file`` into a single string. Reads
173
+ * stdin when ``--body-file=-`` so callers can pipe markdown.
174
+ *
175
+ * @param {{ body?: string|null, bodyFile?: string|null }} out
176
+ */
177
+ export async function readBodyFromOpts(out) {
178
+ if (out.body && out.bodyFile) {
179
+ console.error("Pass --body OR --body-file, not both.");
180
+ process.exit(1);
181
+ }
182
+ if (out.bodyFile) {
183
+ const fs = await import("node:fs/promises");
184
+ if (out.bodyFile === "-") {
185
+ const chunks = [];
186
+ for await (const chunk of process.stdin) chunks.push(chunk);
187
+ return Buffer.concat(chunks).toString("utf8");
188
+ }
189
+ return await fs.readFile(out.bodyFile, "utf8");
190
+ }
191
+ return out.body || "";
192
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `shipctl inbox` — write into the operator's mailbox from a routine.
3
+ *
4
+ * Routines like daily-retro / learning-capture / process-reviewer file
5
+ * a single ``type=report`` letter per run; the operator opens it in the
6
+ * mailbox preview and hits Acknowledge. SDLC pipelines occasionally
7
+ * file ``approval`` / ``improvement`` items.
8
+ *
9
+ * Usage:
10
+ *
11
+ * shipctl inbox create
12
+ * --type <report|improvement|approval|exception>
13
+ * --title "Daily retro 2026-05-07"
14
+ * --body "<markdown>" | --body-file path/to/digest.md | --body-file -
15
+ * [--summary "<short list-row blurb>"]
16
+ * [--workspace <id>]
17
+ * [--ticket-ref <id>]
18
+ * [--json]
19
+ *
20
+ * Auth: bearer ``SHIP_API_TOKEN`` (mint at /settings).
21
+ *
22
+ * Exit codes:
23
+ * 0 inbox item created
24
+ * 1 arg / config / 4xx error
25
+ * 2 auth error (401)
26
+ * 3 network / 5xx error
27
+ */
28
+
29
+ import {
30
+ apiRequest,
31
+ consumeBodyFlags,
32
+ consumeStringFlag,
33
+ readBodyFromOpts,
34
+ resolveContext,
35
+ } from "../agent_api.mjs";
36
+
37
+
38
+ const VALID_TYPES = new Set([
39
+ "report",
40
+ "improvement",
41
+ "approval",
42
+ "exception",
43
+ ]);
44
+
45
+
46
+ export async function inboxCommand(ctx, rest) {
47
+ const [sub, ...args] = rest;
48
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
49
+ printInboxHelp();
50
+ return;
51
+ }
52
+ if (sub === "create") {
53
+ await inboxCreateCommand(ctx, args);
54
+ return;
55
+ }
56
+ console.error(
57
+ `Unknown 'shipctl inbox' subcommand: ${sub}\nRun: shipctl inbox --help`,
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+
63
+ function printInboxHelp() {
64
+ console.log(`shipctl inbox — file letters in the operator's mailbox
65
+
66
+ SUBCOMMANDS
67
+ shipctl inbox create --type <kind> --title <s> --body <md>
68
+ [--summary <s>] [--ticket-ref <id>]
69
+ [--body-file <path|->] [--workspace <id>] [--json]
70
+
71
+ TYPES
72
+ report Read-only digest (daily retro, learning capture,
73
+ process review). Operator hits Acknowledge.
74
+ improvement Actionable suggestion. Operator accepts / dismisses.
75
+ approval Gated decision. Operator approves / rejects / dismisses.
76
+ exception Policy edge case. Operator acknowledges / dismisses.
77
+
78
+ ENV
79
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
80
+ SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
81
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
82
+
83
+ EXIT
84
+ 0 letter filed
85
+ 1 arg / config / 4xx
86
+ 2 auth (401)
87
+ 3 network / 5xx
88
+ `);
89
+ }
90
+
91
+
92
+ async function inboxCreateCommand(ctx, args) {
93
+ const opts = parseCreateArgs(args);
94
+ const body = (await readBodyFromOpts(opts)).trim();
95
+ if (!body) {
96
+ console.error("Pass --body <markdown> or --body-file <path|->");
97
+ process.exit(1);
98
+ }
99
+ const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
100
+
101
+ const result = await apiRequest(
102
+ baseUrl,
103
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/inbox/items`,
104
+ "POST",
105
+ token,
106
+ {
107
+ type: opts.type,
108
+ title: opts.title,
109
+ body,
110
+ summary: opts.summary || null,
111
+ ticket_ref: opts.ticketRef || null,
112
+ },
113
+ );
114
+
115
+ if (ctx?.json || opts.json) {
116
+ console.log(JSON.stringify(result, null, 2));
117
+ return;
118
+ }
119
+ console.log(`Letter filed (${opts.type}): ${opts.title}`);
120
+ }
121
+
122
+
123
+ function parseCreateArgs(args) {
124
+ const out = {
125
+ type: null,
126
+ title: null,
127
+ body: null,
128
+ bodyFile: null,
129
+ summary: null,
130
+ ticketRef: null,
131
+ workspace: null,
132
+ baseUrl: null,
133
+ json: false,
134
+ };
135
+ const copy = [...args];
136
+ while (copy.length) {
137
+ if (
138
+ consumeStringFlag(copy, "--type", out, "type") ||
139
+ consumeStringFlag(copy, "--title", out, "title") ||
140
+ consumeStringFlag(copy, "--summary", out, "summary") ||
141
+ consumeStringFlag(copy, "--ticket-ref", out, "ticketRef") ||
142
+ consumeStringFlag(copy, "--workspace", out, "workspace") ||
143
+ consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
144
+ consumeBodyFlags(copy, out)
145
+ ) {
146
+ continue;
147
+ }
148
+ if (copy[0] === "--json") {
149
+ out.json = true;
150
+ copy.shift();
151
+ continue;
152
+ }
153
+ if (copy[0] === "--help" || copy[0] === "-h") {
154
+ printInboxHelp();
155
+ process.exit(0);
156
+ }
157
+ console.error(`Unknown flag: ${copy[0]}`);
158
+ process.exit(1);
159
+ }
160
+ if (!out.type || !VALID_TYPES.has(out.type)) {
161
+ console.error(
162
+ `--type must be one of ${[...VALID_TYPES].join(" | ")}`,
163
+ );
164
+ process.exit(1);
165
+ }
166
+ if (!out.title || !out.title.trim()) {
167
+ console.error("--title is required");
168
+ process.exit(1);
169
+ }
170
+ return out;
171
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `shipctl project` — manage tracker projects from a routine.
3
+ *
4
+ * Today the only subcommand is ``find-or-create``: the reviewer
5
+ * routines (tech-reviewer, qa-reviewer, security-officer) call this
6
+ * once per run to resolve their dedicated holding-pen project ("Tech
7
+ * Debt", "QA Debt", "Security") on the workspace's bound tracker.
8
+ * First run creates; every subsequent run short-circuits on the
9
+ * case-insensitive name match — no list-then-create race.
10
+ *
11
+ * Usage:
12
+ *
13
+ * shipctl project find-or-create
14
+ * --name "Tech Debt"
15
+ * --body "<markdown body for the new project — only used on create>"
16
+ * [--description "<one-liner; ≤240 chars; only used on create>"]
17
+ * [--workspace <id>]
18
+ * [--json]
19
+ *
20
+ * Output (default): ``project_id\tname\tcreated`` on a single line so
21
+ * shell scripts can parse it. ``--json`` returns the full server
22
+ * response.
23
+ *
24
+ * Auth: bearer ``SHIP_API_TOKEN``.
25
+ *
26
+ * Exit codes:
27
+ * 0 project resolved (found or created)
28
+ * 1 arg / config / 4xx error
29
+ * 2 auth error (401)
30
+ * 3 network / 5xx error
31
+ */
32
+
33
+ import {
34
+ apiRequest,
35
+ consumeBodyFlags,
36
+ consumeStringFlag,
37
+ readBodyFromOpts,
38
+ resolveContext,
39
+ } from "../agent_api.mjs";
40
+
41
+
42
+ export async function projectCommand(ctx, rest) {
43
+ const [sub, ...args] = rest;
44
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
45
+ printProjectHelp();
46
+ return;
47
+ }
48
+ if (sub === "find-or-create") {
49
+ await projectFindOrCreateCommand(ctx, args);
50
+ return;
51
+ }
52
+ console.error(
53
+ `Unknown 'shipctl project' subcommand: ${sub}\nRun: shipctl project --help`,
54
+ );
55
+ process.exit(1);
56
+ }
57
+
58
+
59
+ function printProjectHelp() {
60
+ console.log(`shipctl project — manage tracker projects
61
+
62
+ SUBCOMMANDS
63
+ shipctl project find-or-create --name <s> --body <md>
64
+ [--description <s>] [--body-file <path|->]
65
+ [--workspace <id>] [--json]
66
+
67
+ ENV
68
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
69
+ SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
70
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
71
+
72
+ EXIT
73
+ 0 project resolved
74
+ 1 arg / config / 4xx
75
+ 2 auth (401)
76
+ 3 network / 5xx
77
+ `);
78
+ }
79
+
80
+
81
+ async function projectFindOrCreateCommand(ctx, args) {
82
+ const opts = parseFindOrCreateArgs(args);
83
+ const body = (await readBodyFromOpts(opts)).trim();
84
+ if (!body) {
85
+ console.error("Pass --body <markdown> or --body-file <path|->");
86
+ process.exit(1);
87
+ }
88
+ const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
89
+
90
+ const result = await apiRequest(
91
+ baseUrl,
92
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/projects/find-or-create`,
93
+ "POST",
94
+ token,
95
+ {
96
+ name: opts.name,
97
+ body,
98
+ description: opts.description || null,
99
+ },
100
+ );
101
+
102
+ if (ctx?.json || opts.json) {
103
+ console.log(JSON.stringify(result, null, 2));
104
+ return;
105
+ }
106
+ const project = result?.project ?? {};
107
+ const created = result?.created ? "created" : "existing";
108
+ console.log(
109
+ `${project.id ?? ""}\t${project.name ?? opts.name}\t${created}`,
110
+ );
111
+ }
112
+
113
+
114
+ function parseFindOrCreateArgs(args) {
115
+ const out = {
116
+ name: null,
117
+ body: null,
118
+ bodyFile: null,
119
+ description: null,
120
+ workspace: null,
121
+ baseUrl: null,
122
+ json: false,
123
+ };
124
+ const copy = [...args];
125
+ while (copy.length) {
126
+ if (
127
+ consumeStringFlag(copy, "--name", out, "name") ||
128
+ consumeStringFlag(copy, "--description", out, "description") ||
129
+ consumeStringFlag(copy, "--workspace", out, "workspace") ||
130
+ consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
131
+ consumeBodyFlags(copy, out)
132
+ ) {
133
+ continue;
134
+ }
135
+ if (copy[0] === "--json") {
136
+ out.json = true;
137
+ copy.shift();
138
+ continue;
139
+ }
140
+ if (copy[0] === "--help" || copy[0] === "-h") {
141
+ printProjectHelp();
142
+ process.exit(0);
143
+ }
144
+ console.error(`Unknown flag: ${copy[0]}`);
145
+ process.exit(1);
146
+ }
147
+ if (!out.name || !out.name.trim()) {
148
+ console.error("--name is required");
149
+ process.exit(1);
150
+ }
151
+ return out;
152
+ }
@@ -158,7 +158,10 @@ export async function runCommand(ctx, rest) {
158
158
  if (!roleResolved) {
159
159
  die(EXIT_USAGE, `unknown agent role '${specialistSlug}' for this workspace`);
160
160
  }
161
- const fsmStage = roleResolved.fsm_stage || null;
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;
162
165
  const roleBody = roleResolved.prompt || "";
163
166
  const systemBody = systemResolved?.prompt || "";
164
167
 
@@ -484,6 +487,24 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
484
487
  out.push("");
485
488
  out.push(renderLifecycleHooks());
486
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
+ }
487
508
  out.push("");
488
509
  out.push("## Task");
489
510
  out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
@@ -637,11 +658,19 @@ as a one-shot credential for this run.
637
658
 
638
659
  function makeBranchName(routine, ticketRef) {
639
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, "-");
640
669
  if (ticketRef) {
641
670
  const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
642
- return `cursor/ship-${routine}-${safe}-${stamp}`;
671
+ return `cursor/ship-${safeRoutine}-${safe}-${stamp}`;
643
672
  }
644
- return `cursor/ship-${routine}-${stamp}`;
673
+ return `cursor/ship-${safeRoutine}-${stamp}`;
645
674
  }
646
675
 
647
676
 
@@ -558,6 +558,10 @@ function validateProcessRoutines(value, errors, warnings) {
558
558
  "schedule",
559
559
  "window",
560
560
  "event",
561
+ // ``fsm_stage`` overrides the role's default stage so one role
562
+ // can drive multiple processes (BA serves both
563
+ // ``ba_requirements`` for SDLC and ``wbs`` for decomposition).
564
+ "fsm_stage",
561
565
  ]),
562
566
  prefix,
563
567
  warnings,
@@ -616,6 +620,7 @@ function validateProcessRoutines(value, errors, warnings) {
616
620
  );
617
621
  }
618
622
  validateProcessAgentProfile(routine.agent_profile, `${prefix}.agent_profile`, errors);
623
+ requireOptionalString(routine.fsm_stage, `${prefix}.fsm_stage`, errors, { required: false });
619
624
  validateRoutineTrigger(routine.trigger, `${prefix}.trigger`, errors);
620
625
  if (routine.schedule !== undefined && routine.schedule !== null) {
621
626
  if (typeof routine.schedule !== "string" && !isPlainObject(routine.schedule)) {
@@ -64,6 +64,11 @@ export function routineToExecutable(id, routine) {
64
64
  idempotency: routine.idempotency || null,
65
65
  prompt: stringOrNull(routine.prompt) || stringOrNull(routine.instructions),
66
66
  agent_profile: stringOrNull(routine.agent_profile) || stringOrNull(routine.specialist?.agent_profile),
67
+ // Per-routine FSM stage override. When set, ``shipctl run`` uses
68
+ // it instead of the role's default ``fsm_stage`` — that's how a
69
+ // single role (e.g. ``ba``) serves both SDLC (``ba_requirements``)
70
+ // and decomposition (``wbs``) without per-process role clones.
71
+ fsm_stage: stringOrNull(routine.fsm_stage),
67
72
  };
68
73
  }
69
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elmundi/ship-cli",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Ship CLI: bootstrap a repo, sync the catalog, run process routines, report outcomes.",
6
6
  "license": "Apache-2.0",