@elmundi/ship-cli 0.11.2 → 0.12.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.
@@ -33,6 +33,22 @@ 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.
37
+ *
38
+ * matrix — GitHub Actions matrix: one runner per pattern, parallel.
39
+ * sequential — Single runner, `shipctl run` iterates patterns in order.
40
+ * concurrent — Single runner, `shipctl run` spawns subprocesses in parallel.
41
+ *
42
+ * Meaningful only when ``patterns.length > 1``; single-pattern lanes ignore
43
+ * it (the linter warns if it's set on a single-pattern lane). Default:
44
+ * ``matrix``. */
45
+ export const LANE_FANOUT_MODES = Object.freeze([
46
+ "matrix",
47
+ "sequential",
48
+ "concurrent",
49
+ ]);
50
+ export const LANE_FANOUT_DEFAULT = "matrix";
51
+
36
52
  /* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
37
53
  * file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
38
54
  * restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
@@ -156,6 +172,7 @@ export function DEFAULT_CONFIG_V2() {
156
172
  default: { provider: null },
157
173
  overrides: {},
158
174
  },
175
+ process: DEFAULT_PROCESS_CONFIG(),
159
176
  lanes: {},
160
177
  artifacts: v1.artifacts,
161
178
  cache: v1.cache,
@@ -163,6 +180,28 @@ export function DEFAULT_CONFIG_V2() {
163
180
  };
164
181
  }
165
182
 
183
+ export function DEFAULT_PROCESS_CONFIG() {
184
+ return {
185
+ id: "development",
186
+ name: "Development Process",
187
+ primary: true,
188
+ states: [
189
+ { id: "task_intake", name: "Intake", specialist: { id: "intake", name: "Intake specialist" }, layout: { x: 72, y: 170 } },
190
+ { id: "ba_requirements", name: "Requirements", specialist: { id: "business_analyst", name: "Business analyst" }, layout: { x: 338, y: 170 } },
191
+ { id: "dev_implementation", name: "Implementation", specialist: { id: "developer", name: "Developer" }, layout: { x: 604, y: 170 } },
192
+ { id: "qa_manual", name: "Quality Review", specialist: { id: "qa_engineer", name: "QA engineer" }, layout: { x: 870, y: 170 } },
193
+ { id: "pr_review", name: "Final Review", specialist: { id: "review_owner", name: "Review owner" }, layout: { x: 1136, y: 170 } },
194
+ ],
195
+ transitions: [
196
+ { from: "task_intake", to: "ba_requirements" },
197
+ { from: "ba_requirements", to: "dev_implementation" },
198
+ { from: "dev_implementation", to: "qa_manual" },
199
+ { from: "qa_manual", to: "pr_review" },
200
+ ],
201
+ routines: [],
202
+ };
203
+ }
204
+
166
205
  /**
167
206
  * Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
168
207
  * return the current schema's shape; default to v2 so new installs
@@ -190,6 +229,7 @@ const KNOWN_TOP_LEVEL_V2 = new Set([
190
229
  "api",
191
230
  "stack",
192
231
  "agent",
232
+ "process",
193
233
  "lanes",
194
234
  "artifacts",
195
235
  "cache",
@@ -269,6 +309,105 @@ function validateSharedSections(obj, errors, warnings) {
269
309
  * @param {string[]} errors
270
310
  * @param {string[]} warnings
271
311
  */
312
+ function validateV2Process(obj, errors, warnings) {
313
+ const process = obj.process;
314
+ if (process === undefined) return;
315
+ if (!isPlainObject(process)) {
316
+ errors.push("process: must be an object");
317
+ return;
318
+ }
319
+ pushUnknownKeyWarnings(
320
+ process,
321
+ new Set(["id", "name", "primary", "states", "transitions", "routines"]),
322
+ "process",
323
+ warnings,
324
+ );
325
+ if (typeof process.id !== "string" || !process.id.trim()) {
326
+ errors.push("process.id: must be a non-empty string");
327
+ }
328
+ if (process.name !== undefined && (typeof process.name !== "string" || !process.name.trim())) {
329
+ errors.push("process.name: must be a non-empty string when set");
330
+ }
331
+ if (process.primary !== undefined && typeof process.primary !== "boolean") {
332
+ errors.push("process.primary: must be boolean when set");
333
+ }
334
+
335
+ if (!Array.isArray(process.states) || process.states.length < 1) {
336
+ errors.push("process.states: must contain at least one state");
337
+ return;
338
+ }
339
+ const stateIds = new Set();
340
+ for (let i = 0; i < process.states.length; i += 1) {
341
+ const state = process.states[i];
342
+ const prefix = `process.states[${i}]`;
343
+ if (!isPlainObject(state)) {
344
+ errors.push(`${prefix}: must be an object`);
345
+ continue;
346
+ }
347
+ pushUnknownKeyWarnings(
348
+ state,
349
+ new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
350
+ prefix,
351
+ warnings,
352
+ );
353
+ if (typeof state.id !== "string" || !state.id.trim()) {
354
+ errors.push(`${prefix}.id: must be a non-empty string`);
355
+ } else if (stateIds.has(state.id)) {
356
+ errors.push(`${prefix}.id: duplicate state id ${JSON.stringify(state.id)}`);
357
+ } else {
358
+ stateIds.add(state.id);
359
+ }
360
+ if (state.name !== undefined && (typeof state.name !== "string" || !state.name.trim())) {
361
+ errors.push(`${prefix}.name: must be a non-empty string when set`);
362
+ }
363
+ if (state.instructions !== undefined && typeof state.instructions !== "string") {
364
+ errors.push(`${prefix}.instructions: must be a string when set`);
365
+ }
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`);
368
+ }
369
+ if (state.layout !== undefined) {
370
+ if (!isPlainObject(state.layout)) {
371
+ errors.push(`${prefix}.layout: must be an object when set`);
372
+ } else {
373
+ pushUnknownKeyWarnings(state.layout, new Set(["x", "y"]), `${prefix}.layout`, warnings);
374
+ if (typeof state.layout.x !== "number" || !Number.isFinite(state.layout.x)) {
375
+ errors.push(`${prefix}.layout.x: must be a finite number`);
376
+ }
377
+ if (typeof state.layout.y !== "number" || !Number.isFinite(state.layout.y)) {
378
+ errors.push(`${prefix}.layout.y: must be a finite number`);
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ if (process.transitions !== undefined) {
385
+ if (!Array.isArray(process.transitions)) {
386
+ errors.push("process.transitions: must be a list when set");
387
+ } else {
388
+ for (let i = 0; i < process.transitions.length; i += 1) {
389
+ const transition = process.transitions[i];
390
+ const prefix = `process.transitions[${i}]`;
391
+ if (!isPlainObject(transition)) {
392
+ errors.push(`${prefix}: must be an object`);
393
+ continue;
394
+ }
395
+ pushUnknownKeyWarnings(transition, new Set(["from", "to", "condition"]), prefix, warnings);
396
+ if (!stateIds.has(transition.from)) {
397
+ errors.push(`${prefix}.from: must reference an existing state id`);
398
+ }
399
+ if (!stateIds.has(transition.to)) {
400
+ errors.push(`${prefix}.to: must reference an existing state id`);
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ if (process.routines !== undefined && !Array.isArray(process.routines)) {
407
+ errors.push("process.routines: must be a list when set");
408
+ }
409
+ }
410
+
272
411
  function validateV2Lanes(obj, errors, warnings) {
273
412
  const agent = obj.agent;
274
413
  if (agent !== undefined) {
@@ -331,7 +470,9 @@ function validateV2Lanes(obj, errors, warnings) {
331
470
  const KNOWN_LANE_COMMON = new Set([
332
471
  "kind",
333
472
  "pattern",
473
+ "patterns",
334
474
  "pattern_version",
475
+ "fanout",
335
476
  "permissions",
336
477
  "runner",
337
478
  "timeout_minutes",
@@ -366,8 +507,36 @@ function validateLane(laneId, lane, errors, warnings) {
366
507
  `${prefix}.kind: must be one of ${LANE_KINDS.join("|")}; got ${JSON.stringify(lane.kind)}`,
367
508
  );
368
509
  }
369
- if (typeof lane.pattern !== "string" || !lane.pattern.trim()) {
370
- errors.push(`${prefix}.pattern: must be a non-empty pattern id`);
510
+ // ``patterns: [ids]`` is the canonical multi-pattern form (RFC-0008 C3);
511
+ // ``pattern: <id>`` is kept as the single-pattern alias so existing
512
+ // configs keep working. Exactly one of the two must be present.
513
+ const hasPatterns = Array.isArray(lane.patterns);
514
+ const hasPattern = typeof lane.pattern === "string";
515
+ if (hasPatterns && hasPattern) {
516
+ errors.push(
517
+ `${prefix}: use either 'pattern' (single) or 'patterns' (list), not both`,
518
+ );
519
+ } else if (hasPatterns) {
520
+ if (lane.patterns.length < 1) {
521
+ errors.push(`${prefix}.patterns: must contain at least one pattern id`);
522
+ } else {
523
+ for (let i = 0; i < lane.patterns.length; i += 1) {
524
+ const p = lane.patterns[i];
525
+ if (typeof p !== "string" || !p.trim()) {
526
+ errors.push(
527
+ `${prefix}.patterns[${i}]: must be a non-empty pattern id string`,
528
+ );
529
+ }
530
+ }
531
+ }
532
+ } else if (hasPattern) {
533
+ if (!lane.pattern.trim()) {
534
+ errors.push(`${prefix}.pattern: must be a non-empty pattern id`);
535
+ }
536
+ } else {
537
+ errors.push(
538
+ `${prefix}: must declare 'pattern' (single) or 'patterns' (list)`,
539
+ );
371
540
  }
372
541
  if (
373
542
  lane.pattern_version !== undefined &&
@@ -375,6 +544,21 @@ function validateLane(laneId, lane, errors, warnings) {
375
544
  ) {
376
545
  errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
377
546
  }
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
549
+ // warning rather than an error so schedule templates that set it
550
+ // blindly remain portable across single/multi-pattern use.
551
+ if (lane.fanout !== undefined) {
552
+ if (typeof lane.fanout !== "string" || !LANE_FANOUT_MODES.includes(lane.fanout)) {
553
+ errors.push(
554
+ `${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}; got ${JSON.stringify(lane.fanout)}`,
555
+ );
556
+ } else if (hasPattern || (hasPatterns && lane.patterns.length < 2)) {
557
+ warnings.push(
558
+ `${prefix}.fanout: ignored for single-pattern lanes (has no effect unless 'patterns' has ≥2 entries)`,
559
+ );
560
+ }
561
+ }
378
562
  if (lane.permissions !== undefined && !isPlainObject(lane.permissions)) {
379
563
  errors.push(`${prefix}.permissions: must be an object when set`);
380
564
  }
@@ -499,6 +683,7 @@ export function validateConfig(obj) {
499
683
 
500
684
  validateSharedSections(obj, errors, warnings);
501
685
  if (isV2) {
686
+ validateV2Process(obj, errors, warnings);
502
687
  validateV2Lanes(obj, errors, warnings);
503
688
  }
504
689
 
@@ -648,3 +833,69 @@ export function validateConfig(obj) {
648
833
  if (errors.length) return { ok: false, errors, warnings };
649
834
  return { ok: true, config: obj, warnings };
650
835
  }
836
+
837
+ /**
838
+ * Return the canonical list of pattern ids for a lane.
839
+ *
840
+ * Accepts both the canonical ``patterns: [ids]`` (RFC-0008) and the
841
+ * legacy ``pattern: <id>`` single-string alias. Use this helper
842
+ * everywhere that reads ``lane.pattern`` / ``lane.patterns`` so the
843
+ * call-site never has to branch on the two shapes.
844
+ *
845
+ * For lanes that declared neither key (e.g. malformed config that
846
+ * slipped past validateConfig), returns an empty list rather than
847
+ * throwing — the caller already surfaced the validation error.
848
+ *
849
+ * @param {any} lane
850
+ * @returns {string[]}
851
+ */
852
+ export function lanePatterns(lane) {
853
+ if (!lane || typeof lane !== "object") return [];
854
+ if (Array.isArray(lane.patterns)) {
855
+ return lane.patterns
856
+ .map((p) => (typeof p === "string" ? p.trim() : ""))
857
+ .filter(Boolean);
858
+ }
859
+ if (typeof lane.pattern === "string" && lane.pattern.trim()) {
860
+ return [lane.pattern.trim()];
861
+ }
862
+ return [];
863
+ }
864
+
865
+ /**
866
+ * The "primary" pattern id for a lane. Shorthand for ``lanePatterns(lane)[0]``
867
+ * with an explicit null for lanes that have no pattern.
868
+ *
869
+ * Intended for call-sites that currently only consume a single pattern
870
+ * (shipctl run, the renderer, the dashboard). They read this and will
871
+ * continue to work until multi-pattern execution lands (C3.2); until
872
+ * then, a lane with ``patterns.length > 1`` is rejected upstream by
873
+ * the executor with a clear error.
874
+ *
875
+ * @param {any} lane
876
+ * @returns {string | null}
877
+ */
878
+ export function lanePrimaryPattern(lane) {
879
+ const list = lanePatterns(lane);
880
+ return list.length ? list[0] : null;
881
+ }
882
+
883
+ /**
884
+ * Resolve the effective fan-out mode for a lane.
885
+ *
886
+ * Returns ``LANE_FANOUT_DEFAULT`` (``matrix``) when the lane doesn't
887
+ * declare one or has at most one pattern (in which case the concept
888
+ * doesn't apply but we still want a deterministic value for downstream
889
+ * consumers). Unknown / invalid values fall back to the default; the
890
+ * validator already flags them as errors at config-load time.
891
+ *
892
+ * @param {any} lane
893
+ * @returns {"matrix" | "sequential" | "concurrent"}
894
+ */
895
+ export function laneFanout(lane) {
896
+ if (!lane || typeof lane !== "object") return LANE_FANOUT_DEFAULT;
897
+ if (typeof lane.fanout === "string" && LANE_FANOUT_MODES.includes(lane.fanout)) {
898
+ return lane.fanout;
899
+ }
900
+ return LANE_FANOUT_DEFAULT;
901
+ }
@@ -12,7 +12,7 @@
12
12
  * {
13
13
  * "version": 1,
14
14
  * "lane": "seed_knowledge_starters",
15
- * "pattern_id": "seed-knowledge-starters",
15
+ * "pattern_id": "onboard-seed-knowledge",
16
16
  * "pattern_sha256": "…",
17
17
  * "pattern_version": "1.0.0",
18
18
  * "completed_at": "2026-04-21T14:20:00Z",
package/lib/templates.mjs CHANGED
@@ -30,8 +30,8 @@ Agent protocol (must follow before applying an artifact):
30
30
 
31
31
  \`\`\`bash
32
32
  shipctl pattern list
33
- shipctl pattern show cloud-developer # resolves latest or pin
34
- shipctl pattern fetch cloud-developer --version 1.4.2
33
+ shipctl pattern show role-developer # resolves latest or pin
34
+ shipctl pattern fetch role-developer --version 1.4.2
35
35
  shipctl search "release gates and qa split" --top-k 8
36
36
  shipctl docs fetch documentation/adoption/delivery-quality-and-release-process.md
37
37
  shipctl sync # reconcile .ship/cache/
@@ -41,7 +41,7 @@ shipctl sync # reconcile .ship/cache/
41
41
 
42
42
  \`\`\`bash
43
43
  curl -sS -X POST "${baseUrl}/fetch" -H "Content-Type: application/json" \\
44
- -d '{"kind":"pattern","id":"cloud-developer"}'
44
+ -d '{"kind":"pattern","id":"role-developer"}'
45
45
  curl -sS "${baseUrl}/patterns"
46
46
  \`\`\`
47
47
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@elmundi/ship-cli",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
- "description": "Ship CLI artifacts protocol (patterns/tools/workflows/collections), docs search/fetch/feedback, and agent init",
5
+ "description": "Ship CLI: bootstrap a repo, sync the Plays catalog, run Automations, report Runs.",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Denys Kuzin",
8
8
  "repository": {