@elmundi/ship-cli 0.11.2 → 0.12.1
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/README.md +255 -22
- package/bin/shipctl.mjs +11 -7
- package/lib/bootstrap/render.mjs +49 -0
- package/lib/commands/callback.mjs +457 -17
- package/lib/commands/config.mjs +1 -1
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/help.mjs +137 -77
- package/lib/commands/kickoff.mjs +3 -3
- package/lib/commands/knowledge.mjs +216 -18
- package/lib/commands/lanes.mjs +36 -11
- package/lib/commands/manifest-catalog.mjs +5 -5
- package/lib/commands/patterns.mjs +5 -5
- package/lib/commands/run.mjs +329 -89
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +83 -8
- package/lib/commands/trigger.mjs +200 -0
- package/lib/config/migrate.mjs +13 -5
- package/lib/config/schema.mjs +253 -2
- package/lib/config.mjs +3 -0
- package/lib/state/idempotency.mjs +1 -1
- package/lib/templates.mjs +3 -3
- package/package.json +2 -2
|
@@ -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 —
|
|
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
|
-
|
|
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
|
|
128
|
+
--help, -h Show this help.
|
|
76
129
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
86
|
-
--
|
|
87
|
-
--
|
|
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
|
-
|
|
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
|
|
package/lib/commands/config.mjs
CHANGED
|
@@ -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/
|
|
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(".");
|
package/lib/commands/docs.mjs
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
12
|
+
shipctl docs fetch <repo-relative-path>
|
|
13
|
+
shipctl docs feedback --title "..." --summary "..." [--recommendation "line"]... [--source-context "..."]
|
|
14
14
|
|
|
15
|
-
Vector search:
|
|
16
|
-
Catalog bodies:
|
|
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;
|