@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.
@@ -25,8 +25,35 @@
25
25
  * (which defaults to the public methodology host); run callbacks hit the
26
26
  * orchestration API (`api.ship.elmundi.com`), a different origin, so we
27
27
  * take the URL directly from the run context Ship injected.
28
+ *
29
+ * RFC-0010 Phase 3 (P3-03) extended this command to emit the new
30
+ * `RunSummary` contract on top of the existing `status`/`summary`/
31
+ * `metrics` body. Two equivalent code paths feed the same outcome:
32
+ *
33
+ * - Per-field flags: `--outcome-text`, `--severity SEV=N`,
34
+ * `--artifact TYPE:TITLE[:REF]`, `--requires-approval`,
35
+ * `--approval-payload @file.json`, `--escalation TYPE:REASON`,
36
+ * `--findings-count N`. Composes well with bash heredoc / per-step
37
+ * authoring inside a workflow.
38
+ * - Bulk env input: `SHIP_RUN_OUTCOME` (inline JSON object) or
39
+ * `SHIP_RUN_OUTCOME_FILE` (path to JSON file). Suits agents that
40
+ * emit a full RunSummary blob on stdout.
41
+ *
42
+ * If both env and flags are present we MERGE — flags win on collision
43
+ * (per-field shallow override at the top, per-key inside
44
+ * `findings_by_severity`). Backwards compat is guaranteed: when none of
45
+ * the new inputs are present, the request body is byte-identical to the
46
+ * pre-P3 contract (no `outcome` key emitted).
47
+ *
48
+ * Outcome shape validation (severity vocabulary, escalation type enum
49
+ * beyond what we accept on the CLI surface, etc.) is the BACKEND's job
50
+ * (see RFC-0010 §RunSummary + the P3-01 Pydantic model). `shipctl` only
51
+ * enforces well-formedness at the wire boundary: object-not-array,
52
+ * field types, file readability, JSON parseability.
28
53
  */
29
54
 
55
+ import { readFileSync } from "node:fs";
56
+
30
57
  /** @typedef {"succeeded"|"failed"|"cancelled"} TerminalStatus */
31
58
 
32
59
  const STATUS_ALIASES = {
@@ -44,6 +71,22 @@ const STATUS_ALIASES = {
44
71
  cancel: "cancelled",
45
72
  };
46
73
 
74
+ /* RFC-0010 §RunSummary — the five inbox/escalation types Ship knows
75
+ * how to route. We reject anything else at the CLI boundary so the
76
+ * misspelled `--escalation aproval:...` fails fast with a usage hint
77
+ * instead of being silently dropped by the backend's enum validator. */
78
+ const VALID_ESCALATION_TYPES = new Set([
79
+ "clarification",
80
+ "improvement",
81
+ "failure",
82
+ "approval",
83
+ "exception",
84
+ ]);
85
+
86
+ const VALID_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
87
+
88
+ const OUTCOME_TEXT_MAX = 500;
89
+
47
90
  const EXIT_USAGE = 2;
48
91
  const EXIT_AUTH = 10;
49
92
  const EXIT_CONFIG = 11;
@@ -55,36 +98,81 @@ function die(code, msg) {
55
98
  }
56
99
 
57
100
  function printCallbackHelp() {
58
- console.log(`shipctl callback — report a pipeline run's terminal status to Ship.
101
+ console.log(`shipctl callback — what a Play's pattern calls when its work is done so Ship can render an outcome-first row in the Run list and route any escalations into the Inbox.
59
102
 
60
103
  USAGE
61
104
  shipctl callback --status <ok|fail|cancelled> [--summary "..."] [--metric k=v]...
105
+ [--outcome-text "..."] [--severity SEV=N]... [--artifact TYPE:TITLE[:REF]]...
106
+ [--requires-approval] [--approval-payload <@file|JSON>]
107
+ [--escalation TYPE:REASON]... [--findings-count N]
62
108
 
63
- FLAGS
109
+ Identity
110
+ --run-id Pipeline run UUID (usually set by SHIP_RUN_ID env).
111
+ --callback-url Full callback URL (usually set by SHIP_CALLBACK_URL env).
112
+ --base-url Orchestration API base (default: SHIP_API_BASE env). Combined
113
+ with --run-id to construct the URL when --callback-url absent.
114
+ Env:
115
+ SHIP_RUN_TOKEN (required) Short-lived bearer Ship issued for this run.
116
+ SHIP_CALLBACK_URL (preferred) Full URL of the result endpoint.
117
+ SHIP_RUN_ID Fallback input for --run-id.
118
+ SHIP_API_BASE Fallback input for --base-url.
119
+
120
+ Status & summary
64
121
  --status Terminal status. Aliases: ok|success|succeeded, fail|failed,
65
122
  cancelled|canceled. Required.
66
123
  --summary One-line human summary (≤1024 chars). Optional.
67
124
  --metric k=v Structured metric to attach. Repeatable. Values coerced:
68
125
  numbers, booleans (true|false), JSON (prefix { or [), else string.
69
126
  Example: --metric tickets_processed=3 --metric dry_run=true
70
- --run-id Pipeline run UUID (usually set by SHIP_RUN_ID env).
71
- --callback-url Full callback URL (usually set by SHIP_CALLBACK_URL env).
72
- --base-url Orchestration API base (default: SHIP_API_BASE env). Combined
73
- with --run-id to construct the URL when --callback-url absent.
74
127
  --json Print the Ship response JSON on success.
75
- --help Show this help.
128
+ --help, -h Show this help.
76
129
 
77
- ENV
78
- SHIP_RUN_TOKEN (required) Short-lived bearer Ship issued for this run.
79
- SHIP_CALLBACK_URL (preferred) Full URL of the result endpoint.
80
- SHIP_RUN_ID Fallback input for --run-id.
81
- SHIP_API_BASE Fallback input for --base-url.
130
+ RunSummary outcome (RFC-0010 §RunSummary — emitted as the request body's "outcome" object)
131
+ --outcome-text "..." Pattern-authored single-line UI sentence (≤${OUTCOME_TEXT_MAX} chars,
132
+ leading/trailing whitespace trimmed). This is what operators
133
+ see in /runs — keep it concrete, no "completed successfully"
134
+ filler. Example:
135
+ "3 issues found · 1 PR opened"
136
+ --findings-count N Non-negative integer total. If omitted but --severity
137
+ flags are present, derived from their sum.
138
+ --severity SEV=N Aggregated into outcome.findings_by_severity. SEV is one of
139
+ low|medium|high|critical. N is non-negative int. Repeatable;
140
+ order doesn't matter; last write wins per severity.
141
+ --artifact TYPE:TITLE[:REF]
142
+ Repeatable. Parsed to {type, title, ref?}. Use \\: to embed
143
+ a literal colon inside TITLE. REF (URL or external id) is
144
+ optional. Example:
145
+ --artifact pr:"Fix null check":https://github.com/o/r/pull/42
146
+ --requires-approval Flag (no value). Sets outcome.requires_approval=true.
147
+ --approval-payload PAYLOAD
148
+ JSON object to attach as outcome.approval_payload. Either
149
+ inline JSON or "@path/to/file.json" to load from disk.
150
+ Must parse to an object (not array/scalar).
151
+ --escalation TYPE:REASON Repeatable. Aggregated into outcome.escalations[]. Each
152
+ escalation lands in the Inbox with that type. TYPE must
153
+ be one of:
154
+ clarification | improvement | failure | approval | exception
155
+ Env (alternative to per-field flags):
156
+ SHIP_RUN_OUTCOME Inline JSON object — used as the base outcome (CLI flags
157
+ merge on top, flag values win on per-field collision).
158
+ SHIP_RUN_OUTCOME_FILE Path to a JSON file with the same semantics. Useful when
159
+ an agent emits a full RunSummary blob to stdout.
82
160
 
83
- EXAMPLE (inside a workflow.yml ‹if: always()› step)
161
+ EXAMPLES
162
+ # Terminal status only (legacy contract):
163
+ shipctl callback --status ok --summary "3 PRs scanned"
164
+
165
+ # Canonical pattern-authored outcome (mirrors flow-pr-self-review's "## Reporting"):
84
166
  shipctl callback --status ok \\
85
- --summary "Intake processed TICKET-42" \\
86
- --metric tickets_processed=1 \\
87
- --metric ticket_ids=LIN-42
167
+ --outcome-text "Reviewed PR · 3 suggestions · 1 fix applied" \\
168
+ --findings-count 3 \\
169
+ --severity high=1 --severity medium=2 \\
170
+ --artifact comment:"PR self-review summary":"https://github.com/o/r/pull/42#issuecomment-1" \\
171
+ --artifact pr:"Auto-fix: null guard":"https://github.com/o/r/pull/42/commits/abc" \\
172
+ --escalation clarification:"Need owner sign-off on test rewrite"
173
+
174
+ # Bulk input from agent stdout:
175
+ SHIP_RUN_OUTCOME_FILE=./summary.json shipctl callback --status ok
88
176
  `);
89
177
  }
90
178
 
@@ -127,6 +215,190 @@ function parseMetricArg(tok) {
127
215
  return { key, value: coerceMetricValue(value) };
128
216
  }
129
217
 
218
+ /* --severity SEV=N. Mirrors --metric's k=v shape but with a fixed
219
+ * vocabulary and integer values. We validate at the CLI surface so the
220
+ * `--severity hi=1` typo dies before we waste an HTTP round-trip. */
221
+ export function parseSeverityArg(tok) {
222
+ const eq = tok.indexOf("=");
223
+ if (eq <= 0) {
224
+ die(EXIT_USAGE, `--severity expects SEV=N; got: ${tok}`);
225
+ }
226
+ const key = tok.slice(0, eq).trim().toLowerCase();
227
+ const raw = tok.slice(eq + 1).trim();
228
+ if (!VALID_SEVERITIES.has(key)) {
229
+ die(
230
+ EXIT_USAGE,
231
+ `--severity SEV must be one of low|medium|high|critical; got: ${key}`,
232
+ );
233
+ }
234
+ if (!/^\d+$/.test(raw)) {
235
+ die(
236
+ EXIT_USAGE,
237
+ `--severity ${key}=N expects a non-negative integer; got: ${raw}`,
238
+ );
239
+ }
240
+ const n = Number(raw);
241
+ if (!Number.isSafeInteger(n) || n < 0) {
242
+ die(
243
+ EXIT_USAGE,
244
+ `--severity ${key}=N expects a non-negative integer; got: ${raw}`,
245
+ );
246
+ }
247
+ return { key, value: n };
248
+ }
249
+
250
+ /* --artifact TYPE:TITLE[:REF]. The annoying parser-y bit: TITLE may
251
+ * embed colons via `\:`, REF is taken verbatim after the second
252
+ * unescaped colon (so URLs like https://… survive the round-trip
253
+ * without further escaping — only the post-TYPE post-TITLE colons
254
+ * matter as separators). */
255
+ export function parseArtifactArg(tok) {
256
+ const findUnescapedColon = (s, start = 0) => {
257
+ for (let i = start; i < s.length; i++) {
258
+ if (s[i] === ":" && s[i - 1] !== "\\") return i;
259
+ }
260
+ return -1;
261
+ };
262
+ const firstColon = findUnescapedColon(tok);
263
+ if (firstColon <= 0) {
264
+ die(EXIT_USAGE, `--artifact expects TYPE:TITLE[:REF]; got: ${tok}`);
265
+ }
266
+ const type = tok.slice(0, firstColon).trim();
267
+ const rest = tok.slice(firstColon + 1);
268
+ const secondColon = findUnescapedColon(rest);
269
+ let titleRaw;
270
+ let ref = null;
271
+ if (secondColon < 0) {
272
+ titleRaw = rest;
273
+ } else {
274
+ titleRaw = rest.slice(0, secondColon);
275
+ ref = rest.slice(secondColon + 1);
276
+ }
277
+ /* Resolve the only escape we recognise. Other backslash sequences
278
+ * pass through untouched — kept deliberately narrow so we don't grow
279
+ * a DSL. */
280
+ const title = titleRaw.replace(/\\:/g, ":").trim();
281
+ if (!type) die(EXIT_USAGE, `--artifact TYPE cannot be empty: ${tok}`);
282
+ if (!title) die(EXIT_USAGE, `--artifact TITLE cannot be empty: ${tok}`);
283
+ /** @type {{type: string, title: string, ref?: string}} */
284
+ const out = { type, title };
285
+ if (ref !== null && ref !== "") out.ref = ref;
286
+ return out;
287
+ }
288
+
289
+ /* --escalation TYPE:REASON. REASON is everything after the first
290
+ * colon (so it can contain colons / URLs / punctuation freely). */
291
+ export function parseEscalationArg(tok) {
292
+ const firstColon = tok.indexOf(":");
293
+ if (firstColon <= 0) {
294
+ die(EXIT_USAGE, `--escalation expects TYPE:REASON; got: ${tok}`);
295
+ }
296
+ const type = tok.slice(0, firstColon).trim().toLowerCase();
297
+ const reason = tok.slice(firstColon + 1).trim();
298
+ if (!VALID_ESCALATION_TYPES.has(type)) {
299
+ die(
300
+ EXIT_USAGE,
301
+ `--escalation TYPE must be one of ${[...VALID_ESCALATION_TYPES].join("|")}; got: ${type}`,
302
+ );
303
+ }
304
+ if (!reason) die(EXIT_USAGE, `--escalation REASON cannot be empty: ${tok}`);
305
+ return { type, reason };
306
+ }
307
+
308
+ /* --approval-payload @path | inline JSON. Distinguishes by the leading
309
+ * `@` because that's what bash/curl users already expect for
310
+ * load-from-file semantics. We always require an object (not a top-level
311
+ * array or scalar) so the backend's `Record<string, unknown>` slot lands
312
+ * something usable. */
313
+ export function parseApprovalPayload(raw) {
314
+ let source = raw;
315
+ let origin = "inline JSON";
316
+ if (typeof raw === "string" && raw.startsWith("@")) {
317
+ const path = raw.slice(1);
318
+ if (!path) die(EXIT_USAGE, "--approval-payload @ requires a file path");
319
+ try {
320
+ source = readFileSync(path, "utf8");
321
+ } catch (err) {
322
+ die(
323
+ EXIT_CONFIG,
324
+ `--approval-payload could not read ${path}: ${err instanceof Error ? err.message : err}`,
325
+ );
326
+ }
327
+ origin = `file ${path}`;
328
+ }
329
+ let parsed;
330
+ try {
331
+ parsed = JSON.parse(source);
332
+ } catch (err) {
333
+ die(
334
+ EXIT_CONFIG,
335
+ `--approval-payload (${origin}) is not valid JSON: ${err instanceof Error ? err.message : err}`,
336
+ );
337
+ return null;
338
+ }
339
+ if (
340
+ parsed === null ||
341
+ typeof parsed !== "object" ||
342
+ Array.isArray(parsed)
343
+ ) {
344
+ die(
345
+ EXIT_CONFIG,
346
+ `--approval-payload must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed}).`,
347
+ );
348
+ }
349
+ return parsed;
350
+ }
351
+
352
+ /* SHIP_RUN_OUTCOME / SHIP_RUN_OUTCOME_FILE → object. Returns null when
353
+ * neither env is set. We do *not* validate the inner shape (severities,
354
+ * escalation types, etc.) here — that's the backend's responsibility
355
+ * per RFC-0010. We only enforce well-formedness so a typo in JSON
356
+ * surfaces as an EXIT_CONFIG with a useful message rather than a 422
357
+ * round-trip with the bearer already burned. */
358
+ export function loadEnvOutcome(env = process.env) {
359
+ const inline = env.SHIP_RUN_OUTCOME;
360
+ const file = env.SHIP_RUN_OUTCOME_FILE;
361
+ if (!inline && !file) return null;
362
+ let source;
363
+ let origin;
364
+ if (file) {
365
+ try {
366
+ source = readFileSync(file, "utf8");
367
+ } catch (err) {
368
+ die(
369
+ EXIT_CONFIG,
370
+ `SHIP_RUN_OUTCOME_FILE could not read ${file}: ${err instanceof Error ? err.message : err}`,
371
+ );
372
+ return null;
373
+ }
374
+ origin = `SHIP_RUN_OUTCOME_FILE=${file}`;
375
+ } else {
376
+ source = inline;
377
+ origin = "SHIP_RUN_OUTCOME";
378
+ }
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(source);
382
+ } catch (err) {
383
+ die(
384
+ EXIT_CONFIG,
385
+ `${origin} is not valid JSON: ${err instanceof Error ? err.message : err}`,
386
+ );
387
+ return null;
388
+ }
389
+ if (
390
+ parsed === null ||
391
+ typeof parsed !== "object" ||
392
+ Array.isArray(parsed)
393
+ ) {
394
+ die(
395
+ EXIT_CONFIG,
396
+ `${origin} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed}).`,
397
+ );
398
+ }
399
+ return parsed;
400
+ }
401
+
130
402
  export function parseCallbackArgs(rest) {
131
403
  const out = {
132
404
  status: null,
@@ -137,8 +409,25 @@ export function parseCallbackArgs(rest) {
137
409
  baseUrl: null,
138
410
  json: false,
139
411
  help: false,
412
+ /* Outcome accumulator. We track presence of *any* outcome flag with
413
+ * `outcomeFlagsSeen` so backwards-compat (no outcome key) is a
414
+ * deterministic check rather than a "did everything end up empty"
415
+ * heuristic. */
416
+ outcome: {
417
+ outcome_text: null,
418
+ findings_count: null,
419
+ findings_by_severity: {},
420
+ artifacts: [],
421
+ requires_approval: false,
422
+ approval_payload: null,
423
+ escalations: [],
424
+ },
425
+ outcomeFlagsSeen: false,
140
426
  };
141
427
  const copy = [...rest];
428
+ const markOutcome = () => {
429
+ out.outcomeFlagsSeen = true;
430
+ };
142
431
  /* Tiny arg-munger kept inline rather than pulling a dependency —
143
432
  * matches the style of feedback.mjs / patterns.mjs and keeps this CLI
144
433
  * zero-prod-deps apart from `yaml`. */
@@ -156,6 +445,24 @@ export function parseCallbackArgs(rest) {
156
445
  }
157
446
  return false;
158
447
  };
448
+ /* Same as strFlag but routes the captured value into a callback so
449
+ * we can validate / mutate (outcome flags don't live as plain keys
450
+ * on `out`). */
451
+ const handleArgFlag = (name, take) => {
452
+ if (copy[0] === name && copy[1] !== undefined) {
453
+ copy.shift();
454
+ take(String(copy.shift()));
455
+ return true;
456
+ }
457
+ const p = `${name}=`;
458
+ if (copy[0] && copy[0].startsWith(p)) {
459
+ const raw = copy[0].slice(p.length);
460
+ copy.shift();
461
+ take(raw);
462
+ return true;
463
+ }
464
+ return false;
465
+ };
159
466
  while (copy.length) {
160
467
  const a = copy[0];
161
468
  if (a === "--help" || a === "-h") {
@@ -186,6 +493,69 @@ export function parseCallbackArgs(rest) {
186
493
  out.metrics[key] = value;
187
494
  continue;
188
495
  }
496
+ if (
497
+ handleArgFlag("--outcome-text", (raw) => {
498
+ markOutcome();
499
+ const trimmed = String(raw).trim();
500
+ if (trimmed.length > OUTCOME_TEXT_MAX) {
501
+ die(
502
+ EXIT_USAGE,
503
+ `--outcome-text exceeds ${OUTCOME_TEXT_MAX} chars (got ${trimmed.length}).`,
504
+ );
505
+ }
506
+ out.outcome.outcome_text = trimmed;
507
+ })
508
+ )
509
+ continue;
510
+ if (
511
+ handleArgFlag("--findings-count", (raw) => {
512
+ markOutcome();
513
+ const r = String(raw).trim();
514
+ if (!/^\d+$/.test(r)) {
515
+ die(
516
+ EXIT_USAGE,
517
+ `--findings-count expects a non-negative integer; got: ${r}`,
518
+ );
519
+ }
520
+ out.outcome.findings_count = Number(r);
521
+ })
522
+ )
523
+ continue;
524
+ if (
525
+ handleArgFlag("--severity", (raw) => {
526
+ markOutcome();
527
+ const { key, value } = parseSeverityArg(String(raw));
528
+ out.outcome.findings_by_severity[key] = value;
529
+ })
530
+ )
531
+ continue;
532
+ if (
533
+ handleArgFlag("--artifact", (raw) => {
534
+ markOutcome();
535
+ out.outcome.artifacts.push(parseArtifactArg(String(raw)));
536
+ })
537
+ )
538
+ continue;
539
+ if (a === "--requires-approval") {
540
+ markOutcome();
541
+ out.outcome.requires_approval = true;
542
+ copy.shift();
543
+ continue;
544
+ }
545
+ if (
546
+ handleArgFlag("--approval-payload", (raw) => {
547
+ markOutcome();
548
+ out.outcome.approval_payload = parseApprovalPayload(String(raw));
549
+ })
550
+ )
551
+ continue;
552
+ if (
553
+ handleArgFlag("--escalation", (raw) => {
554
+ markOutcome();
555
+ out.outcome.escalations.push(parseEscalationArg(String(raw)));
556
+ })
557
+ )
558
+ continue;
189
559
  die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl callback --help`);
190
560
  }
191
561
  return out;
@@ -208,11 +578,81 @@ export function resolveCallbackUrl(args, env = process.env) {
208
578
  return null;
209
579
  }
210
580
 
211
- export function buildCallbackBody(args) {
581
+ /* Compose the final outcome from env (base) + CLI flags (overlay).
582
+ *
583
+ * Merge semantics — codified here so the test names stay terse and the
584
+ * spec deviation (if any) is grep-able:
585
+ *
586
+ * - Top-level keys: shallow override (CLI wins on collision).
587
+ * - `findings_by_severity`: per-key merge (env's `low: 1` survives a
588
+ * CLI `--severity high=2`; CLI overrides env when severities collide).
589
+ * - Arrays (`artifacts`, `escalations`): if CLI contributed any,
590
+ * CLI replaces env. Otherwise env's array passes through. We
591
+ * intentionally do NOT concat — appending env+CLI would be too
592
+ * surprising for an agent that emits a complete blob and then a
593
+ * human refines a single field via flag.
594
+ * - `findings_count`: when neither env nor flag provides one but
595
+ * `findings_by_severity` is present, we derive a sum so the
596
+ * pattern doesn't need to compute it by hand.
597
+ */
598
+ export function buildOutcome(args, env = process.env) {
599
+ const envOutcome = loadEnvOutcome(env);
600
+ if (!envOutcome && !args.outcomeFlagsSeen) return null;
601
+
602
+ /** @type {Record<string, unknown>} */
603
+ const merged = envOutcome ? { ...envOutcome } : {};
604
+
605
+ const cli = args.outcome;
606
+
607
+ if (cli.outcome_text !== null) merged.outcome_text = cli.outcome_text;
608
+ if (cli.findings_count !== null) merged.findings_count = cli.findings_count;
609
+
610
+ const cliSeverities = cli.findings_by_severity;
611
+ if (Object.keys(cliSeverities).length > 0) {
612
+ const baseSev =
613
+ merged.findings_by_severity &&
614
+ typeof merged.findings_by_severity === "object" &&
615
+ !Array.isArray(merged.findings_by_severity)
616
+ ? { ...merged.findings_by_severity }
617
+ : {};
618
+ merged.findings_by_severity = { ...baseSev, ...cliSeverities };
619
+ }
620
+
621
+ if (cli.artifacts.length > 0) merged.artifacts = cli.artifacts;
622
+ if (cli.requires_approval) merged.requires_approval = true;
623
+ if (cli.approval_payload !== null)
624
+ merged.approval_payload = cli.approval_payload;
625
+ if (cli.escalations.length > 0) merged.escalations = cli.escalations;
626
+
627
+ /* Derive `findings_count` from severity totals when neither side
628
+ * specified one explicitly. Catches the common case where the
629
+ * pattern emits per-severity counts and forgets the rollup. */
630
+ if (
631
+ merged.findings_count === undefined &&
632
+ merged.findings_by_severity &&
633
+ typeof merged.findings_by_severity === "object"
634
+ ) {
635
+ let sum = 0;
636
+ let any = false;
637
+ for (const v of Object.values(merged.findings_by_severity)) {
638
+ if (typeof v === "number" && Number.isFinite(v)) {
639
+ sum += v;
640
+ any = true;
641
+ }
642
+ }
643
+ if (any) merged.findings_count = sum;
644
+ }
645
+
646
+ return merged;
647
+ }
648
+
649
+ export function buildCallbackBody(args, env = process.env) {
212
650
  /** @type {Record<string, unknown>} */
213
651
  const body = { status: args.status };
214
652
  if (args.summary) body.summary = String(args.summary).slice(0, 1024);
215
653
  if (Object.keys(args.metrics).length > 0) body.metrics = args.metrics;
654
+ const outcome = buildOutcome(args, env);
655
+ if (outcome !== null) body.outcome = outcome;
216
656
  return body;
217
657
  }
218
658
 
@@ -100,7 +100,7 @@ function getAtPath(obj, dottedKey) {
100
100
 
101
101
  /**
102
102
  * Split a dotted key, preserving `<kind>/<id>` segments under artifacts.pins.
103
- * Example: artifacts.pins.pattern/cloud-developer → ["artifacts","pins","pattern/cloud-developer"]
103
+ * Example: artifacts.pins.pattern/role-developer → ["artifacts","pins","pattern/role-developer"]
104
104
  */
105
105
  function parsePath(dottedKey) {
106
106
  const raw = dottedKey.split(".");
@@ -9,11 +9,11 @@ export async function docsCommand(ctx, args) {
9
9
  const [sub, ...rest] = args;
10
10
  if (!sub || sub === "help") {
11
11
  console.log(`Usage:
12
- ship docs fetch <repo-relative-path>
13
- ship docs feedback --title "..." --summary "..." [--recommendation "line"]... [--source-context "..."]
12
+ shipctl docs fetch <repo-relative-path>
13
+ shipctl docs feedback --title "..." --summary "..." [--recommendation "line"]... [--source-context "..."]
14
14
 
15
- Vector search: ship search <query>
16
- Catalog bodies: ship pattern|tool|collection fetch <id>
15
+ Vector search: shipctl search <query>
16
+ Catalog bodies: shipctl pattern|tool|collection fetch <id>
17
17
 
18
18
  Global flags: --base-url URL --json`);
19
19
  return;