@elmundi/ship-cli 0.12.1 → 0.12.2

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
@@ -140,6 +140,12 @@ try {
140
140
  process.exit(0);
141
141
  }
142
142
 
143
+ if (cmd === "process") {
144
+ const { processCommand } = await import("../lib/commands/process.mjs");
145
+ await processCommand(ctx, rest);
146
+ process.exit(0);
147
+ }
148
+
143
149
  if (cmd === "migrate") {
144
150
  const { migrateCommand } = await import("../lib/commands/migrate.mjs");
145
151
  await migrateCommand(ctx, rest);
@@ -1,20 +1,20 @@
1
1
  export function printHelp() {
2
- console.log(`shipctl — adopt Ship in a repo, sync the catalog, run lanes, report Runs.
2
+ console.log(`shipctl — adopt Ship in a repo, sync the catalog, run routines, report outcomes.
3
3
 
4
4
  Bootstrap a new or existing repo (init / new / doctor), pull the
5
- methodology catalog into .ship/cache (sync), execute one-shot lanes or
6
- emit prompts for the workspace runner (run / lanes / kickoff /
5
+ methodology catalog into .ship/cache (sync), execute one-shot routines or
6
+ emit prompts for the workspace runner (trigger / run / kickoff /
7
7
  callback). Talks to the methodology + orchestration APIs over HTTPS.
8
8
 
9
9
  VOCABULARY
10
- lanes: (.ship/config.yml) operator console: Automations
11
- pattern: (artifact kind) operator console: Plays
12
- pipeline_runs (DB / API) operator console: Runs
13
- attention surface → operator console: Inbox
10
+ process.routines: (.ship/config.yml) repo-local scheduled/manual work
11
+ pattern: (artifact kind) cached role/playbook prompt body
12
+ routine claim: (Ship API) idempotent schedule-window claim
13
+ attention surface → operator console: Inbox
14
14
 
15
- The protocol-stable terms (lanes:, pattern:, pipeline_runs) stay
16
- literal in YAML, CLI flags, and HTTP. Operator-facing prose uses the
17
- console nouns.
15
+ Legacy 'lanes:' configs and '--lane' flags are still accepted as aliases
16
+ so already-seeded repositories keep working while new repos use
17
+ 'process.routines'.
18
18
 
19
19
  GLOBAL FLAGS
20
20
  --base-url URL Methodology API (default: SHIP_API_BASE or
@@ -66,22 +66,18 @@ COMMANDS
66
66
  [--force-unpin] [--dry-run] [--lock] [--json] [--cwd <dir>]
67
67
  — fetch artifacts into .ship/cache. With --lock,
68
68
  also writes .ship/shipctl.lock.json covering
69
- every pattern the declared lanes depend on.
69
+ every pattern the declared routines depend on.
70
70
 
71
71
  Run
72
72
  shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
73
- ask Ship which configured lanes are
74
- due for the current GitHub trigger.
75
- shipctl run --lane <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
73
+ compute due routines locally, then claim
74
+ the schedule window in Ship.
75
+ shipctl run --routine <id> [--pattern <id>] [--fanout matrix|sequential|concurrent]
76
76
  [--trigger event|schedule|manual|once]
77
77
  [--dry-run] [--offline] [--json] [--cwd <dir>]
78
78
  [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
79
- — one-shot dispatch entry point. 'kind: once'
80
- lanes execute fully here; 'kind: lane / event /
81
- schedule' lanes are queued for the workspace
82
- runner via .github/workflows/run-agent.yml.
83
- Reports its terminal status via the callback URL
84
- Ship injected into the workflow.
79
+ — one-shot routine dispatch entry point.
80
+ Use --lane as a legacy alias.
85
81
  shipctl lanes install [--only <csv>] [--ref <git-ref>] [--owner <gh>] [--repo <name>]
86
82
  [--shipctl-version <v>] [--dry-run] [--force] [--json] [--cwd <dir>]
87
83
  shipctl lanes list [--json] [--cwd <dir>]
@@ -99,6 +95,17 @@ COMMANDS
99
95
  RunSummary outcome) back to Ship so it can
100
96
  render the outcome row and route any
101
97
  escalations into the Inbox.
98
+ shipctl process prompt --state <id> [--ticket-json <json>] [--policies-file <path>]
99
+ [--cwd <dir>] [--json]
100
+ — assemble a Process/FSM specialist prompt
101
+ bundle with ticket context, allowed
102
+ transitions, policies, and mandatory
103
+ knowledge-first guardrails.
104
+ shipctl process tickets --workspace <id> [--query <text>] [--tracker <kind>] [--json]
105
+ — read-only tracker picker for selecting
106
+ ticket context before building a process
107
+ prompt. Does not create, comment, or
108
+ transition tickets.
102
109
 
103
110
  Knowledge
104
111
  shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>] [--only <csv>] [--json]
@@ -44,10 +44,12 @@
44
44
 
45
45
  const VERSION = "v1";
46
46
 
47
- /** Ship ships two starter buckets today — keep in lockstep with
48
- * ``backend.app.services.catalog.KNOWLEDGE_STARTERS`` and
49
- * ``console/src/lib/api/client.ts#KNOWLEDGE_STARTERS``. */
50
- const KNOWN_SLUGS = ["code-style", "ui-runbook"];
47
+ /** Static starters with source markdown under ``artifacts/knowledge-starters``.
48
+ * Procedural catalog recipes are exposed by the backend under the
49
+ * ``ship-recipes/<pattern-id>`` prefix because that list is generated from
50
+ * on-disk pattern artifacts at runtime. */
51
+ export const STATIC_KNOWLEDGE_SLUGS = ["code-style", "ui-runbook"];
52
+ export const RECIPE_KNOWLEDGE_PREFIX = "ship-recipes/";
51
53
 
52
54
  /**
53
55
  * @param {{baseUrl?: string, json?: boolean}} ctx
@@ -100,7 +102,9 @@ INIT FLAGS
100
102
  Defaults to the most-recently activated repo in
101
103
  the resolved workspace.
102
104
  --only <csv> Comma-separated starter slugs. Defaults to the
103
- full catalog (${KNOWN_SLUGS.join(", ")}).
105
+ full backend catalog, including static starters
106
+ (${STATIC_KNOWLEDGE_SLUGS.join(", ")}) and generated
107
+ recipe starters under ${RECIPE_KNOWLEDGE_PREFIX}<pattern-id>.
104
108
  --base-url URL Workspace control-plane API. See env fallbacks.
105
109
  --json Emit a machine-readable JSON summary.
106
110
 
@@ -134,10 +138,10 @@ async function knowledgeInitCommand(ctx, args) {
134
138
 
135
139
  const selection = opts.only;
136
140
  if (selection !== null) {
137
- const unknown = selection.filter((s) => !KNOWN_SLUGS.includes(s));
141
+ const unknown = selection.filter((s) => !isKnownKnowledgeStarterSlug(s));
138
142
  if (unknown.length) {
139
143
  console.error(
140
- `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown: ${KNOWN_SLUGS.join(", ")}`,
144
+ `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown static slugs: ${STATIC_KNOWLEDGE_SLUGS.join(", ")}; recipe slugs must start with ${RECIPE_KNOWLEDGE_PREFIX}`,
141
145
  );
142
146
  process.exit(1);
143
147
  }
@@ -173,6 +177,13 @@ async function knowledgeInitCommand(ctx, args) {
173
177
  );
174
178
  }
175
179
 
180
+ export function isKnownKnowledgeStarterSlug(slug) {
181
+ return (
182
+ STATIC_KNOWLEDGE_SLUGS.includes(slug) ||
183
+ slug.startsWith(RECIPE_KNOWLEDGE_PREFIX)
184
+ );
185
+ }
186
+
176
187
  async function knowledgeFetchCommand(ctx, args) {
177
188
  const opts = parseFetchArgs(args);
178
189
  const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
@@ -0,0 +1,388 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { readConfig } from "../config/io.mjs";
5
+ import { CONFIG_SCHEMA_VERSION, validateConfig } from "../config/schema.mjs";
6
+ import { apiGet } from "../http.mjs";
7
+ import {
8
+ buildSpecialistPromptBundle,
9
+ renderSpecialistPromptBundleMarkdown,
10
+ } from "../process/specialist-prompt-contract.mjs";
11
+
12
+ export async function processCommand(ctx, rest) {
13
+ const [sub, ...args] = rest;
14
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
15
+ printProcessHelp();
16
+ return;
17
+ }
18
+ if (sub === "prompt" || sub === "bundle") {
19
+ await processPromptCommand(ctx, args);
20
+ return;
21
+ }
22
+ if (sub === "tickets") {
23
+ await processTicketsCommand(ctx, args);
24
+ return;
25
+ }
26
+ console.error(
27
+ `Unknown 'shipctl process' subcommand: ${sub}\nRun: shipctl process --help`,
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ function printProcessHelp() {
33
+ console.log(`shipctl process — assemble Process/FSM specialist context
34
+
35
+ USAGE
36
+ shipctl process prompt --state <state-id> [--ticket-json <json>] [--ticket-file <path>]
37
+ [--ticket-id <id>] [--ticket-title <title>] [--ticket-url <url>]
38
+ [--ticket-description <text>] [--policies-file <path>]
39
+ [--cwd <dir>] [--json]
40
+ shipctl process prompt --specialist <specialist-id> [...]
41
+ shipctl process tickets --workspace <id> [--process <id>] [--tracker <kind>]
42
+ [--query <text>] [--state open|closed|all] [--limit <n>]
43
+ [--project-hint <hint>] [--assignee-me] [--assignee <user>]
44
+ [--json]
45
+
46
+ WHAT IT EMITS
47
+ A specialist prompt bundle assembled from .ship/config.yml:
48
+ - process and state identity
49
+ - specialist template fields from the selected state
50
+ - ticket context supplied by the caller / tracker picker
51
+ - allowed outgoing FSM transitions from process.transitions
52
+ - workspace policies supplied by --policies-file
53
+ - mandatory knowledge-first and Ship-boundary guardrails
54
+
55
+ The command does not mutate tracker tickets, execute transitions, or write to
56
+ the repository. It prepares context for an agent runtime; Ship remains
57
+ responsible for side effects, FSM validation, audit logging, and PR-only writes.`);
58
+ }
59
+
60
+ async function processTicketsCommand(ctx, args) {
61
+ const opts = parseTicketsArgs(args);
62
+ const baseUrl = resolveWorkspaceBaseUrl(opts.baseUrl || ctx.baseUrl);
63
+ const token = process.env.SHIP_API_TOKEN || "";
64
+ if (!token) {
65
+ console.error("SHIP_API_TOKEN is required for shipctl process tickets.");
66
+ process.exit(1);
67
+ }
68
+ const processId = opts.processId || "development";
69
+ const params = new URLSearchParams();
70
+ for (const [key, value] of Object.entries({
71
+ tracker: opts.tracker,
72
+ project_hint: opts.projectHint,
73
+ state: opts.state,
74
+ query: opts.query,
75
+ assignee: opts.assignee,
76
+ limit: opts.limit,
77
+ })) {
78
+ if (value !== null && value !== undefined && value !== "") {
79
+ params.set(key, String(value));
80
+ }
81
+ }
82
+ if (opts.assigneeMe) params.set("assignee_me", "true");
83
+ const suffix = params.toString() ? `?${params}` : "";
84
+ const data = await apiGet(
85
+ baseUrl,
86
+ `/v1/workspaces/${encodeURIComponent(opts.workspace)}/processes/${encodeURIComponent(processId)}/tickets${suffix}`,
87
+ );
88
+ if (ctx.json || opts.json) {
89
+ console.log(JSON.stringify(data, null, 2));
90
+ return;
91
+ }
92
+ const tickets = Array.isArray(data?.tickets) ? data.tickets : [];
93
+ console.log(`Tracker: ${data?.tracker || opts.tracker || "(auto)"}`);
94
+ if (!tickets.length) {
95
+ console.log("No tickets matched.");
96
+ return;
97
+ }
98
+ for (const ticket of tickets) {
99
+ const id = ticket.key || ticket.display_id || ticket.id || "(no id)";
100
+ const title = ticket.title || "(untitled)";
101
+ const status = ticket.status ? ` [${ticket.status}]` : "";
102
+ const url = ticket.url ? `\n ${ticket.url}` : "";
103
+ console.log(`- ${id}${status}: ${title}${url}`);
104
+ }
105
+ }
106
+
107
+ async function processPromptCommand(ctx, args) {
108
+ const opts = parsePromptArgs(args);
109
+ const cwd = opts.cwd || process.cwd();
110
+ let config;
111
+ try {
112
+ config = readConfig(cwd).config;
113
+ } catch (err) {
114
+ die(err instanceof Error ? err.message : String(err));
115
+ }
116
+ if (config.version !== CONFIG_SCHEMA_VERSION) {
117
+ die(
118
+ `.ship/config.yml is at v${config.version}; shipctl process prompt requires v${CONFIG_SCHEMA_VERSION}.\nRun 'shipctl migrate' to upgrade.`,
119
+ );
120
+ }
121
+ const validation = validateConfig(config);
122
+ if (!validation.ok) {
123
+ die(["config is invalid:", ...validation.errors.map((e) => ` - ${e}`)].join("\n"));
124
+ }
125
+ const processConfig = config.process;
126
+ if (!processConfig || typeof processConfig !== "object") {
127
+ die("process: missing from .ship/config.yml");
128
+ }
129
+
130
+ const state = selectState(processConfig, opts);
131
+ const allowedTransitions = Array.isArray(processConfig.transitions)
132
+ ? processConfig.transitions.filter((transition) => transition.from === state.id)
133
+ : [];
134
+ const bundle = buildSpecialistPromptBundle({
135
+ process: processConfig,
136
+ state,
137
+ allowedTransitions,
138
+ ticket: resolveTicket(opts),
139
+ policies: readOptionalFile(opts.policiesFile, "policies"),
140
+ });
141
+
142
+ if (ctx.json || opts.json) {
143
+ console.log(JSON.stringify(bundle, null, 2));
144
+ return;
145
+ }
146
+ process.stdout.write(renderSpecialistPromptBundleMarkdown(bundle));
147
+ }
148
+
149
+ function parsePromptArgs(args) {
150
+ const out = {
151
+ state: null,
152
+ specialist: null,
153
+ cwd: null,
154
+ json: false,
155
+ ticketJson: null,
156
+ ticketFile: null,
157
+ ticket: {},
158
+ policiesFile: null,
159
+ };
160
+ const copy = [...args];
161
+ const readValue = (flag) => {
162
+ const current = copy.shift();
163
+ if (current === flag) {
164
+ if (copy.length === 0) die(`${flag} requires a value`);
165
+ return String(copy.shift());
166
+ }
167
+ const prefix = `${flag}=`;
168
+ if (current && current.startsWith(prefix)) {
169
+ return current.slice(prefix.length);
170
+ }
171
+ copy.unshift(current);
172
+ return null;
173
+ };
174
+
175
+ while (copy.length) {
176
+ const arg = copy[0];
177
+ if (arg === "--help" || arg === "-h") {
178
+ printProcessHelp();
179
+ process.exit(0);
180
+ }
181
+ if (arg === "--json") {
182
+ copy.shift();
183
+ out.json = true;
184
+ continue;
185
+ }
186
+ const state = readValue("--state");
187
+ if (state !== null) {
188
+ out.state = state;
189
+ continue;
190
+ }
191
+ const specialist = readValue("--specialist");
192
+ if (specialist !== null) {
193
+ out.specialist = specialist;
194
+ continue;
195
+ }
196
+ const cwd = readValue("--cwd");
197
+ if (cwd !== null) {
198
+ out.cwd = path.resolve(cwd);
199
+ continue;
200
+ }
201
+ const ticketJson = readValue("--ticket-json");
202
+ if (ticketJson !== null) {
203
+ out.ticketJson = ticketJson;
204
+ continue;
205
+ }
206
+ const ticketFile = readValue("--ticket-file");
207
+ if (ticketFile !== null) {
208
+ out.ticketFile = path.resolve(ticketFile);
209
+ continue;
210
+ }
211
+ const policiesFile = readValue("--policies-file");
212
+ if (policiesFile !== null) {
213
+ out.policiesFile = path.resolve(policiesFile);
214
+ continue;
215
+ }
216
+ for (const [flag, key] of [
217
+ ["--ticket-id", "id"],
218
+ ["--ticket-key", "key"],
219
+ ["--ticket-title", "title"],
220
+ ["--ticket-url", "url"],
221
+ ["--ticket-status", "status"],
222
+ ["--ticket-description", "description"],
223
+ ]) {
224
+ const value = readValue(flag);
225
+ if (value !== null) {
226
+ out.ticket[key] = value;
227
+ break;
228
+ }
229
+ }
230
+ if (copy[0] === arg) {
231
+ die(`unknown argument: ${arg}\nRun: shipctl process prompt --help`);
232
+ }
233
+ }
234
+ if (!out.state && !out.specialist) {
235
+ die("either --state <state-id> or --specialist <specialist-id> is required");
236
+ }
237
+ if (out.state && out.specialist) {
238
+ die("use either --state or --specialist, not both");
239
+ }
240
+ return out;
241
+ }
242
+
243
+ function parseTicketsArgs(args) {
244
+ const out = {
245
+ workspace: null,
246
+ processId: "development",
247
+ tracker: null,
248
+ projectHint: null,
249
+ query: null,
250
+ state: "open",
251
+ limit: 10,
252
+ assigneeMe: false,
253
+ assignee: null,
254
+ baseUrl: null,
255
+ json: false,
256
+ };
257
+ const copy = [...args];
258
+ const readValue = (flag) => {
259
+ const current = copy.shift();
260
+ if (current === flag) {
261
+ if (copy.length === 0) die(`${flag} requires a value`);
262
+ return String(copy.shift());
263
+ }
264
+ const prefix = `${flag}=`;
265
+ if (current && current.startsWith(prefix)) {
266
+ return current.slice(prefix.length);
267
+ }
268
+ copy.unshift(current);
269
+ return null;
270
+ };
271
+ while (copy.length) {
272
+ const arg = copy[0];
273
+ if (arg === "--help" || arg === "-h") {
274
+ printProcessHelp();
275
+ process.exit(0);
276
+ }
277
+ if (arg === "--json") {
278
+ copy.shift();
279
+ out.json = true;
280
+ continue;
281
+ }
282
+ if (arg === "--assignee-me") {
283
+ copy.shift();
284
+ out.assigneeMe = true;
285
+ continue;
286
+ }
287
+ for (const [flag, key] of [
288
+ ["--workspace", "workspace"],
289
+ ["--process", "processId"],
290
+ ["--tracker", "tracker"],
291
+ ["--project-hint", "projectHint"],
292
+ ["--query", "query"],
293
+ ["--state", "state"],
294
+ ["--limit", "limit"],
295
+ ["--assignee", "assignee"],
296
+ ["--base-url", "baseUrl"],
297
+ ]) {
298
+ const value = readValue(flag);
299
+ if (value !== null) {
300
+ out[key] = key === "limit" ? Number(value) : value;
301
+ break;
302
+ }
303
+ }
304
+ if (copy[0] === arg) {
305
+ die(`unknown argument: ${arg}\nRun: shipctl process tickets --help`);
306
+ }
307
+ }
308
+ if (!out.workspace) die("--workspace <id> is required");
309
+ if (!["open", "closed", "all"].includes(out.state)) {
310
+ die("--state must be one of open|closed|all");
311
+ }
312
+ if (!Number.isInteger(out.limit) || out.limit < 1 || out.limit > 25) {
313
+ die("--limit must be an integer between 1 and 25");
314
+ }
315
+ return out;
316
+ }
317
+
318
+ function selectState(processConfig, opts) {
319
+ const states = Array.isArray(processConfig.states) ? processConfig.states : [];
320
+ if (opts.state) {
321
+ const state = states.find((item) => item.id === opts.state);
322
+ if (!state) die(`unknown process state ${JSON.stringify(opts.state)}`);
323
+ return state;
324
+ }
325
+ const state = states.find((item) => {
326
+ const specialist = item.specialist;
327
+ if (typeof specialist === "string") return specialist === opts.specialist;
328
+ if (specialist && typeof specialist === "object") {
329
+ return specialist.id === opts.specialist || specialist.name === opts.specialist;
330
+ }
331
+ return false;
332
+ });
333
+ if (!state) die(`unknown process specialist ${JSON.stringify(opts.specialist)}`);
334
+ return state;
335
+ }
336
+
337
+ function resolveTicket(opts) {
338
+ const parts = [];
339
+ if (opts.ticketFile) {
340
+ const body = readOptionalFile(opts.ticketFile, "ticket");
341
+ if (body) {
342
+ parts.push(parseTicketJson(body, `ticket file ${opts.ticketFile}`));
343
+ }
344
+ }
345
+ if (opts.ticketJson) {
346
+ parts.push(parseTicketJson(opts.ticketJson, "--ticket-json"));
347
+ }
348
+ if (Object.keys(opts.ticket).length) {
349
+ parts.push(opts.ticket);
350
+ }
351
+ if (!parts.length) return null;
352
+ return Object.assign({}, ...parts);
353
+ }
354
+
355
+ function parseTicketJson(value, label) {
356
+ try {
357
+ const parsed = JSON.parse(value);
358
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
359
+ die(`${label}: must be a JSON object`);
360
+ }
361
+ return parsed;
362
+ } catch (err) {
363
+ die(`${label}: failed to parse JSON (${err instanceof Error ? err.message : err})`);
364
+ }
365
+ }
366
+
367
+ function readOptionalFile(filePath, label) {
368
+ if (!filePath) return null;
369
+ try {
370
+ return fs.readFileSync(filePath, "utf8");
371
+ } catch (err) {
372
+ die(`failed to read ${label} file ${filePath}: ${err instanceof Error ? err.message : err}`);
373
+ }
374
+ }
375
+
376
+ function resolveWorkspaceBaseUrl(explicit) {
377
+ return (
378
+ explicit ||
379
+ process.env.SHIP_WORKSPACE_API_BASE ||
380
+ process.env.SHIP_API_BASE ||
381
+ "https://api.ship.elmundi.com"
382
+ );
383
+ }
384
+
385
+ function die(message) {
386
+ console.error(message);
387
+ process.exit(1);
388
+ }