@elmundi/ship-cli 0.12.1 → 0.14.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/README.md +70 -653
- package/bin/shipctl.mjs +9 -1
- package/lib/agents/cursor.mjs +143 -0
- package/lib/agents/index.mjs +51 -0
- package/lib/commands/help.mjs +31 -23
- package/lib/commands/knowledge.mjs +18 -7
- package/lib/commands/lanes.mjs +74 -35
- package/lib/commands/process.mjs +388 -0
- package/lib/commands/run.mjs +456 -763
- package/lib/commands/sync.mjs +1 -1
- package/lib/commands/trigger.mjs +73 -40
- package/lib/config/schema.mjs +196 -10
- package/lib/process/specialist-prompt-contract.mjs +171 -0
- package/lib/runtime/routines.mjs +264 -0
- package/lib/vendor/run-agent.workflow.yml +254 -0
- package/package.json +4 -4
package/lib/commands/run.mjs
CHANGED
|
@@ -1,856 +1,549 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `shipctl run` —
|
|
2
|
+
* `shipctl run` — E14 routine entry-point.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - `kind=event` and `kind=schedule` lanes execute when Ship's trigger
|
|
7
|
-
* router says they are due; router-side audit state prevents duplicate
|
|
8
|
-
* runs for the same schedule window.
|
|
4
|
+
* Customer's GH Actions cron fires this once per routine slot. The
|
|
5
|
+
* pipeline:
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* 1. Read the routine from `.ship/config.yml` (pattern_id, optional
|
|
8
|
+
* user-authored prompt).
|
|
9
|
+
* 2. Fetch the pattern body+frontmatter from Ship server
|
|
10
|
+
* (`POST /fetch kind=pattern id=<pattern_id>`).
|
|
11
|
+
* 3. If the pattern declares `spec.fsm_stage`, ask Ship server for
|
|
12
|
+
* the next ticket in that FSM stage
|
|
13
|
+
* (`GET /v1/.../tracker/next?state=<stage>`). Server picks the
|
|
14
|
+
* adapter (Linear / GH Issues / etc.) — CLI doesn't care.
|
|
15
|
+
* 4. Mint a `run_id` and render the prompt: pattern body + ticket
|
|
16
|
+
* details + a finish-protocol block with `SHIP_API_BASE`,
|
|
17
|
+
* `SHIP_API_TOKEN`, `SHIP_WORKSPACE_ID`, `RUN_ID`, `TICKET_REF`,
|
|
18
|
+
* `FSM_STAGE` already substituted so the agent can call the
|
|
19
|
+
* finish endpoint directly.
|
|
20
|
+
* 5. Launch the configured agent runtime (`cli/lib/agents/`) — Cursor
|
|
21
|
+
* Cloud today. Block until the runtime terminates.
|
|
22
|
+
* 6. The agent itself calls
|
|
23
|
+
* `POST /v1/workspaces/{ws}/agent-runs/finish` with its outcome.
|
|
24
|
+
* Ship's server applies tracker side-effects via the workspace
|
|
25
|
+
* Linear OAuth — the CLI doesn't read any branch / state file.
|
|
26
|
+
* 7. CLI exits 0 on Cursor `FINISHED`, non-0 if the runtime crashed.
|
|
27
|
+
* Whether the agent actually called `/finish` is observable via
|
|
28
|
+
* the audit log; the smoke test for "did the right thing happen"
|
|
29
|
+
* is the tracker label/state itself.
|
|
15
30
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
31
|
+
* Env contract (typically wired in `ship-trigger-schedule.yml`):
|
|
32
|
+
* - SHIP_API_BASE — Ship server, e.g. https://api.ship.elmundi.com
|
|
33
|
+
* - SHIP_API_TOKEN — workspace API token (admin scope; rendered
|
|
34
|
+
* into the agent's prompt so it can call
|
|
35
|
+
* /agent-runs/finish from inside Cursor)
|
|
36
|
+
* - SHIP_WORKSPACE_ID — UUID of the workspace this run belongs to
|
|
37
|
+
* - CURSOR_API_KEY — Cursor Cloud agent API key (when provider=cursor)
|
|
38
|
+
* - GITHUB_REPOSITORY — owner/repo (the repo the agent will check out)
|
|
39
|
+
*
|
|
40
|
+
* Note: there is **no SHIP_REPO_ID** — a workspace is the project, so
|
|
41
|
+
* the tracker is workspace-scoped. ``GITHUB_REPOSITORY`` only tells the
|
|
42
|
+
* agent runtime which checkout to spawn for code work. Branchless agents
|
|
43
|
+
* (intake, BA, planner) don't push commits at all.
|
|
21
44
|
*/
|
|
22
45
|
|
|
23
|
-
import
|
|
46
|
+
import crypto from "node:crypto";
|
|
24
47
|
import path from "node:path";
|
|
25
48
|
|
|
49
|
+
import yaml from "yaml";
|
|
50
|
+
|
|
26
51
|
import { readConfig, findShipRoot } from "../config/io.mjs";
|
|
27
|
-
import {
|
|
28
|
-
validateConfig,
|
|
29
|
-
CONFIG_SCHEMA_VERSION,
|
|
30
|
-
lanePatterns,
|
|
31
|
-
laneFanout,
|
|
32
|
-
LANE_FANOUT_MODES,
|
|
33
|
-
} from "../config/schema.mjs";
|
|
52
|
+
import { resolveExecutable } from "../runtime/routines.mjs";
|
|
34
53
|
import { fetchArtifact } from "../http.mjs";
|
|
35
|
-
import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
|
|
36
54
|
import { readArtifactFile } from "../artifacts/fs-index.mjs";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
55
|
+
import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
|
|
56
|
+
import { resolveProvider, runAgent } from "../agents/index.mjs";
|
|
57
|
+
|
|
39
58
|
|
|
40
59
|
const EXIT_OK = 0;
|
|
41
60
|
const EXIT_USAGE = 1;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const EXIT_IDEMPOTENCY = 4;
|
|
61
|
+
const EXIT_NO_TASK = 0; // intentional: no eligible ticket is a clean noop
|
|
62
|
+
const EXIT_AGENT_FAIL = 7;
|
|
45
63
|
|
|
46
|
-
const VALID_TRIGGERS = new Set(["event", "schedule", "manual", "once"]);
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
WHAT THIS COMMAND IS FOR
|
|
52
|
-
shipctl run is the **one-shot dispatch** entry point. It resolves a
|
|
53
|
-
lane from .ship/config.yml, fetches its pattern body, checks
|
|
54
|
-
idempotency, and emits the prompt for an agent to consume. Behaviour
|
|
55
|
-
by lane kind:
|
|
56
|
-
- kind: once — executed fully here, locally.
|
|
57
|
-
- kind: lane / event / — recognised but NOT executed locally;
|
|
58
|
-
schedule those run via the workspace's GitHub
|
|
59
|
-
Actions runner using the reusable
|
|
60
|
-
.github/workflows/run-agent.yml. shipctl
|
|
61
|
-
run exits 0 with a no-op summary so CI
|
|
62
|
-
wrappers can wire them safely.
|
|
63
|
-
|
|
64
|
-
USAGE
|
|
65
|
-
shipctl run --lane <id> [--pattern <id>] [--fanout <matrix|sequential|concurrent>]
|
|
66
|
-
[--trigger <event|schedule|manual|once>]
|
|
67
|
-
[--dry-run] [--offline]
|
|
68
|
-
[--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
|
|
69
|
-
[--cwd <dir>] [--json]
|
|
70
|
-
|
|
71
|
-
FLAGS
|
|
72
|
-
--lane <id> Lane id declared in .ship/config.yml. Required.
|
|
73
|
-
--pattern <id> For multi-pattern lanes: run only this pattern. This
|
|
74
|
-
is the per-entry call issued by the matrix workflow
|
|
75
|
-
(one matrix job per pattern). Must be one of the
|
|
76
|
-
lane's declared patterns.
|
|
77
|
-
--fanout <mode> Override the lane's configured fan-out for this run
|
|
78
|
-
(matrix|sequential|concurrent). Meaningful only
|
|
79
|
-
when the lane has ≥2 patterns and --pattern is not
|
|
80
|
-
set. Matrix mode without --pattern is rejected;
|
|
81
|
-
it requires a driving workflow.
|
|
82
|
-
--trigger <kind> Force the trigger context (event|schedule|manual|once).
|
|
83
|
-
If omitted, inferred from GITHUB_EVENT_NAME / SHIP_RUN_TRIGGER.
|
|
84
|
-
--dry-run Print the plan without touching idempotency markers or callback.
|
|
85
|
-
--offline Resolve patterns exclusively via .ship/shipctl.lock.json
|
|
86
|
-
and .ship/cache/ — never talks to the methodology API.
|
|
87
|
-
Fails if the lockfile or a cached body is missing.
|
|
88
|
-
Generate one with 'shipctl sync --lock'.
|
|
89
|
-
--ship-run-id <uuid> Pipeline run id. Falls back to SHIP_RUN_ID env.
|
|
90
|
-
--ship-callback-url <url> Full callback URL. Falls back to SHIP_CALLBACK_URL env.
|
|
91
|
-
--ship-run-token <jwt> Short-lived bearer. Falls back to SHIP_RUN_TOKEN env.
|
|
92
|
-
--cwd <dir> Repo root. Default: search upward for .ship/config.yml.
|
|
93
|
-
--json Emit a structured summary on stdout.
|
|
94
|
-
--help Show this help.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Entry point
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
95
68
|
|
|
96
|
-
EXIT
|
|
97
|
-
0 lane executed or no-op
|
|
98
|
-
1 usage / config error
|
|
99
|
-
2 config is v1 — run 'shipctl migrate' first
|
|
100
|
-
3 callback failed (lane itself may have succeeded)
|
|
101
|
-
4 idempotency marker read/write failure
|
|
102
|
-
10 missing SHIP_RUN_TOKEN when a callback URL is configured
|
|
103
69
|
|
|
104
|
-
EXAMPLE (CI step emitted by the reusable workflow)
|
|
105
|
-
shipctl run --lane seed_knowledge_starters | feed-to-agent
|
|
106
|
-
`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* @param {{json?: boolean, dryRun?: boolean, baseUrl?: string}} ctx
|
|
111
|
-
* @param {string[]} rest
|
|
112
|
-
*/
|
|
113
70
|
export async function runCommand(ctx, rest) {
|
|
114
71
|
const args = parseArgs(rest);
|
|
115
72
|
if (args.help) {
|
|
116
73
|
printHelp();
|
|
117
74
|
process.exit(EXIT_OK);
|
|
118
75
|
}
|
|
119
|
-
if (!args.
|
|
120
|
-
die(EXIT_USAGE, "`--
|
|
76
|
+
if (!args.routine) {
|
|
77
|
+
die(EXIT_USAGE, "`--routine <id>` is required.\nRun: shipctl run --help");
|
|
121
78
|
}
|
|
122
79
|
|
|
123
80
|
const cwd = args.cwd || process.cwd();
|
|
124
81
|
const root = findShipRoot(cwd);
|
|
125
82
|
if (!root) {
|
|
126
|
-
die(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
83
|
+
die(EXIT_USAGE, `.ship/config.yml not found (searched from ${path.resolve(cwd)} upward).`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { config } = readConfig(cwd);
|
|
87
|
+
const resolved = resolveExecutable(config, args.routine);
|
|
88
|
+
if (!resolved) {
|
|
89
|
+
die(EXIT_USAGE, `unknown routine '${args.routine}' in .ship/config.yml`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const env = readEnv();
|
|
93
|
+
const { apiBase, apiToken, workspaceId, githubRepo } = env;
|
|
94
|
+
|
|
95
|
+
// 1) Resolve pattern
|
|
96
|
+
const patternId = resolved.executable.pattern;
|
|
97
|
+
if (!patternId) {
|
|
98
|
+
die(EXIT_USAGE, `routine '${args.routine}' has no pattern set`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fetchBase = methodologyBase(env, config);
|
|
102
|
+
const rawPatternBody = await loadPattern({ id: patternId, fetchBase });
|
|
103
|
+
const { frontmatter, frontmatterRaw, body: patternBody } = splitFrontmatter(rawPatternBody);
|
|
104
|
+
const fsmStage = pickFsmStage(frontmatter, frontmatterRaw);
|
|
105
|
+
|
|
106
|
+
// Patterns reference ``{{BASE}}`` to splice in common-base. Fetch it
|
|
107
|
+
// up-front so renderPrompt can do the substitution. ``{{SKILLS_CONTEXT}}``
|
|
108
|
+
// inside common-base is left as "(no skills directory)" for the MVP —
|
|
109
|
+
// skills bundling lands in a follow-up.
|
|
110
|
+
const baseRaw = await loadPattern({ id: "common-base", fetchBase, optional: true });
|
|
111
|
+
const baseBody = baseRaw ? splitFrontmatter(baseRaw).body : "";
|
|
112
|
+
|
|
113
|
+
// 2) Resolve task. ``--dry-run`` skips the server call and uses a
|
|
114
|
+
// synthetic task so the operator can see the prompt shape without
|
|
115
|
+
// needing the new endpoints deployed.
|
|
116
|
+
let task = null;
|
|
117
|
+
if (fsmStage) {
|
|
118
|
+
if (args.dryRun || ctx.dryRun) {
|
|
119
|
+
task = {
|
|
120
|
+
ticket_ref: "dry-run/sample#1",
|
|
121
|
+
kind: "dry-run",
|
|
122
|
+
title: "Sample ticket for dry-run prompt rendering",
|
|
123
|
+
body: "This is a synthetic ticket body. The real one comes from `GET /tracker/next` when not in dry-run.",
|
|
124
|
+
url: null,
|
|
125
|
+
labels: ["sample"],
|
|
126
|
+
state: "open",
|
|
127
|
+
fsm_stage: fsmStage,
|
|
128
|
+
};
|
|
129
|
+
} else {
|
|
130
|
+
task = await getNextTask({
|
|
131
|
+
apiBase,
|
|
132
|
+
apiToken,
|
|
133
|
+
workspaceId,
|
|
134
|
+
state: fsmStage,
|
|
135
|
+
});
|
|
136
|
+
if (!task) {
|
|
137
|
+
emit(args, { status: "noop", routine: args.routine, pattern: patternId, fsm_stage: fsmStage, reason: "no_eligible_ticket" });
|
|
138
|
+
process.exit(EXIT_NO_TASK);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// 3) Mint a run_id + render prompt with finish-protocol values
|
|
144
|
+
// already substituted so the agent can call /agent-runs/finish from
|
|
145
|
+
// inside Cursor without holding any extra config.
|
|
146
|
+
const runId = `run_${crypto.randomBytes(8).toString("hex")}`;
|
|
147
|
+
const prompt = renderPrompt({
|
|
148
|
+
patternBody,
|
|
149
|
+
baseBody,
|
|
150
|
+
role: patternId,
|
|
151
|
+
routineSpec: resolved.executable,
|
|
152
|
+
task,
|
|
153
|
+
fsmStage,
|
|
154
|
+
finishCtx: {
|
|
155
|
+
apiBase,
|
|
156
|
+
apiToken,
|
|
157
|
+
workspaceId,
|
|
158
|
+
runId,
|
|
159
|
+
role: patternId,
|
|
160
|
+
ticketRef: task?.ticket_ref || null,
|
|
161
|
+
fsmStage: fsmStage || null,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ``--dry-run`` exits here so the operator can eyeball the rendered
|
|
166
|
+
// prompt + resolved task without launching an agent or touching any
|
|
167
|
+
// tracker. Useful when iterating on pattern bodies.
|
|
168
|
+
if (args.dryRun || ctx.dryRun) {
|
|
169
|
+
if (args.json) {
|
|
170
|
+
console.log(JSON.stringify({
|
|
171
|
+
status: "dry-run",
|
|
172
|
+
routine: args.routine,
|
|
173
|
+
pattern: patternId,
|
|
174
|
+
fsm_stage: fsmStage,
|
|
175
|
+
task,
|
|
176
|
+
prompt,
|
|
177
|
+
}, null, 2));
|
|
178
|
+
} else {
|
|
179
|
+
console.error(`# ship: dry-run routine=${args.routine} pattern=${patternId} fsm_stage=${fsmStage || "(context-free)"}`);
|
|
180
|
+
if (task) {
|
|
181
|
+
console.error(`# ship: task ticket_ref=${task.ticket_ref} title=${JSON.stringify(task.title || "")}`);
|
|
182
|
+
} else {
|
|
183
|
+
console.error("# ship: task=(none)");
|
|
184
|
+
}
|
|
185
|
+
console.error("# ---- prompt ----");
|
|
186
|
+
console.log(prompt);
|
|
187
|
+
}
|
|
188
|
+
process.exit(EXIT_OK);
|
|
145
189
|
}
|
|
146
190
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
].join("\n");
|
|
153
|
-
die(EXIT_USAGE, msg);
|
|
154
|
-
}
|
|
191
|
+
// 4) Launch agent runtime
|
|
192
|
+
const provider = resolveProvider(config, args.routine);
|
|
193
|
+
const branchName = makeBranchName(args.routine, task?.ticket_ref);
|
|
194
|
+
const repoUrl = githubRepo ? `https://github.com/${githubRepo}` : null;
|
|
195
|
+
if (!repoUrl) die(EXIT_USAGE, "GITHUB_REPOSITORY env var is required to launch agent");
|
|
155
196
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
197
|
+
let runtime;
|
|
198
|
+
try {
|
|
199
|
+
runtime = await runAgent(provider, {
|
|
200
|
+
repoUrl,
|
|
201
|
+
ref: env.githubRef || "main",
|
|
202
|
+
branchName,
|
|
203
|
+
prompt,
|
|
204
|
+
autoCreatePr: false,
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
emit(args, {
|
|
208
|
+
status: "error",
|
|
209
|
+
routine: args.routine,
|
|
210
|
+
pattern: patternId,
|
|
211
|
+
run_id: runId,
|
|
212
|
+
stage: "launch_agent",
|
|
213
|
+
error: err instanceof Error ? err.message : String(err),
|
|
214
|
+
});
|
|
215
|
+
process.exit(EXIT_AGENT_FAIL);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The agent calls POST /agent-runs/finish from inside Cursor with
|
|
219
|
+
// its outcome. The CLI's job ends here — Cursor's terminal status
|
|
220
|
+
// tells us the runtime didn't crash; whether the agent actually
|
|
221
|
+
// called /finish (and what outcome it reported) is observable in
|
|
222
|
+
// the audit log + the resulting tracker state.
|
|
223
|
+
emit(args, {
|
|
224
|
+
status: "completed",
|
|
225
|
+
routine: args.routine,
|
|
226
|
+
pattern: patternId,
|
|
227
|
+
fsm_stage: fsmStage,
|
|
228
|
+
ticket_ref: task?.ticket_ref || null,
|
|
229
|
+
agent_id: runtime.agentId,
|
|
230
|
+
branch: runtime.branchName,
|
|
231
|
+
cursor_status: runtime.status,
|
|
232
|
+
run_id: runId,
|
|
233
|
+
});
|
|
234
|
+
process.exit(EXIT_OK);
|
|
235
|
+
}
|
|
164
236
|
|
|
165
|
-
const effectiveTrigger = resolveTrigger(args.trigger, lane.kind);
|
|
166
|
-
if (!effectiveTrigger.fits) {
|
|
167
|
-
/* Not an error — scheduler fired us but the lane doesn't want this
|
|
168
|
-
* trigger. Exit 0 so parallel lanes in the same workflow don't all
|
|
169
|
-
* fail just because one didn't match. */
|
|
170
|
-
const summary = {
|
|
171
|
-
lane: args.lane,
|
|
172
|
-
kind: lane.kind,
|
|
173
|
-
trigger: effectiveTrigger.trigger,
|
|
174
|
-
status: "noop",
|
|
175
|
-
reason: `lane.kind=${lane.kind} does not accept trigger=${effectiveTrigger.trigger}`,
|
|
176
|
-
};
|
|
177
|
-
emitSummary(ctx, args, summary);
|
|
178
|
-
process.exit(EXIT_OK);
|
|
179
|
-
}
|
|
180
237
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// --pattern <id> → run only that pattern (the per-entry call
|
|
185
|
-
// issued by the matrix workflow). The pattern
|
|
186
|
-
// must be one of the lane's declared patterns,
|
|
187
|
-
// otherwise we refuse so typos don't silently
|
|
188
|
-
// execute an unrelated pattern.
|
|
189
|
-
// (none) → run every pattern the lane declares, using
|
|
190
|
-
// the lane's fan-out mode. Matrix mode without
|
|
191
|
-
// --pattern is rejected because it requires a
|
|
192
|
-
// driving workflow (see run-agent.yml).
|
|
193
|
-
const allPatterns = lanePatterns(lane);
|
|
194
|
-
if (allPatterns.length === 0) {
|
|
195
|
-
die(EXIT_USAGE, `lane ${JSON.stringify(args.lane)} declares no patterns.`);
|
|
196
|
-
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Helpers
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
197
241
|
|
|
198
|
-
const effectiveFanout = args.fanout || laneFanout(lane);
|
|
199
|
-
let patternsToRun;
|
|
200
|
-
let runMode; // ``single`` | ``sequential`` | ``concurrent``
|
|
201
|
-
if (args.pattern) {
|
|
202
|
-
if (!allPatterns.includes(args.pattern)) {
|
|
203
|
-
die(
|
|
204
|
-
EXIT_USAGE,
|
|
205
|
-
`--pattern=${JSON.stringify(args.pattern)} is not declared on lane ${JSON.stringify(args.lane)}. ` +
|
|
206
|
-
`Known patterns: ${allPatterns.join(", ")}.`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
patternsToRun = [args.pattern];
|
|
210
|
-
runMode = "single";
|
|
211
|
-
} else if (allPatterns.length === 1) {
|
|
212
|
-
patternsToRun = allPatterns;
|
|
213
|
-
runMode = "single";
|
|
214
|
-
} else if (effectiveFanout === "matrix") {
|
|
215
|
-
die(
|
|
216
|
-
EXIT_USAGE,
|
|
217
|
-
`lane ${JSON.stringify(args.lane)} has fanout=matrix and ${allPatterns.length} patterns ` +
|
|
218
|
-
`but no --pattern was provided. Matrix mode dispatches one 'shipctl run --pattern <id>' per ` +
|
|
219
|
-
`pattern via the workflow (see run-agent.yml). To run them in-process instead, pass ` +
|
|
220
|
-
`--fanout sequential or --fanout concurrent.`,
|
|
221
|
-
);
|
|
222
|
-
} else {
|
|
223
|
-
patternsToRun = allPatterns;
|
|
224
|
-
runMode = effectiveFanout;
|
|
225
|
-
}
|
|
226
242
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
} catch (err) {
|
|
237
|
-
await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
|
|
238
|
-
die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
|
|
239
|
-
}
|
|
240
|
-
}
|
|
243
|
+
function readEnv() {
|
|
244
|
+
return {
|
|
245
|
+
apiBase: stripSlash(process.env.SHIP_API_BASE || ""),
|
|
246
|
+
apiToken: process.env.SHIP_API_TOKEN || "",
|
|
247
|
+
workspaceId: process.env.SHIP_WORKSPACE_ID || "",
|
|
248
|
+
githubRepo: process.env.GITHUB_REPOSITORY || "",
|
|
249
|
+
githubRef: (process.env.GITHUB_REF_NAME || "main").trim(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
241
252
|
|
|
242
|
-
// Fetch every pattern body first so we can reject the whole run
|
|
243
|
-
// atomically if any one is unavailable — partial success is worse
|
|
244
|
-
// than a hard failure here (the caller can retry once the fetch
|
|
245
|
-
// error is cleared).
|
|
246
|
-
const fetchJobs = patternsToRun.map((patternId) =>
|
|
247
|
-
fetchPatternBody({
|
|
248
|
-
patternId,
|
|
249
|
-
patternVersion: lane.pattern_version || null,
|
|
250
|
-
offline: args.offline,
|
|
251
|
-
root,
|
|
252
|
-
ctx,
|
|
253
|
-
config,
|
|
254
|
-
}).then((result) => ({ patternId, result })),
|
|
255
|
-
);
|
|
256
|
-
// `sequential` vs `concurrent` only differ for future in-process agent
|
|
257
|
-
// invocation; today's CLI just emits the pattern bodies to stdout, so
|
|
258
|
-
// both modes fetch in parallel and print in declared order. We still
|
|
259
|
-
// record the requested mode on the summary so downstream consumers
|
|
260
|
-
// (and future work) can see the intent.
|
|
261
|
-
const fetched = await Promise.all(fetchJobs);
|
|
262
|
-
for (const { patternId, result } of fetched) {
|
|
263
|
-
if (!result.ok) {
|
|
264
|
-
die(EXIT_USAGE, `pattern ${patternId}: ${result.error}`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
const runs = fetched.map(({ patternId, result }) => ({
|
|
268
|
-
patternId,
|
|
269
|
-
body: result.body,
|
|
270
|
-
source: result.source,
|
|
271
|
-
sha256: sha256(result.body),
|
|
272
|
-
}));
|
|
273
|
-
|
|
274
|
-
// Composite SHA over all pattern bodies. ``reset_on=version-change``
|
|
275
|
-
// fires when any member's body drifts — which is the correct
|
|
276
|
-
// semantics for a multi-pattern audit lane: if one role's playbook
|
|
277
|
-
// updates, we want the whole lane to re-run.
|
|
278
|
-
const compositeBody = runs.map((r) => `#${r.patternId}\n${r.body}`).join("\n---\n");
|
|
279
|
-
const decision = idem
|
|
280
|
-
? decideRun(marker, compositeBody, idem.reset_on || "version-change")
|
|
281
|
-
: { run: true, reason: "trigger-router-due", marker: null };
|
|
282
|
-
if (!decision.run) {
|
|
283
|
-
const summary = {
|
|
284
|
-
lane: args.lane,
|
|
285
|
-
kind: lane.kind,
|
|
286
|
-
trigger: effectiveTrigger.trigger,
|
|
287
|
-
status: "noop",
|
|
288
|
-
reason: "already-done",
|
|
289
|
-
marker: decision.marker,
|
|
290
|
-
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
|
|
291
|
-
};
|
|
292
|
-
await tryCallback(
|
|
293
|
-
args,
|
|
294
|
-
"ok",
|
|
295
|
-
`lane ${args.lane}: already completed, no-op.`,
|
|
296
|
-
runMode === "single"
|
|
297
|
-
? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256, noop: true }
|
|
298
|
-
: { patterns: runs.map((r) => r.patternId), noop: true },
|
|
299
|
-
);
|
|
300
|
-
emitSummary(ctx, args, summary);
|
|
301
|
-
process.exit(EXIT_OK);
|
|
302
|
-
}
|
|
303
253
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
* each body preceded by a ``# ship: pattern=<id>`` banner so the
|
|
308
|
-
* agent-side (or a human) can split them back apart. */
|
|
309
|
-
if (args.dryRun || ctx.dryRun) {
|
|
310
|
-
const summary = {
|
|
311
|
-
lane: args.lane,
|
|
312
|
-
kind: lane.kind,
|
|
313
|
-
trigger: effectiveTrigger.trigger,
|
|
314
|
-
status: "dry-run",
|
|
315
|
-
reason: decision.reason,
|
|
316
|
-
mode: runMode,
|
|
317
|
-
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
|
|
318
|
-
};
|
|
319
|
-
if (runs.length === 1) {
|
|
320
|
-
summary.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
|
|
321
|
-
}
|
|
322
|
-
if (ctx.json || args.json) {
|
|
323
|
-
console.log(
|
|
324
|
-
JSON.stringify(
|
|
325
|
-
{ ...summary, pattern_bodies: Object.fromEntries(runs.map((r) => [r.patternId, r.body])) },
|
|
326
|
-
null,
|
|
327
|
-
2,
|
|
328
|
-
),
|
|
329
|
-
);
|
|
330
|
-
} else {
|
|
331
|
-
console.error(
|
|
332
|
-
`# ship: lane=${args.lane} kind=${lane.kind} trigger=${effectiveTrigger.trigger} mode=${runMode} (dry-run)`,
|
|
333
|
-
);
|
|
334
|
-
emitPatternBodies(runs, { json: false });
|
|
335
|
-
}
|
|
336
|
-
process.exit(EXIT_OK);
|
|
337
|
-
}
|
|
254
|
+
function stripSlash(s) {
|
|
255
|
+
return s.replace(/\/+$/, "");
|
|
256
|
+
}
|
|
338
257
|
|
|
339
|
-
/* Emit the prompt(s) for the agent to consume (same contract as
|
|
340
|
-
* `shipctl kickoff`). The reusable workflow pipes stdout into the
|
|
341
|
-
* configured agent runtime; multi-pattern output is delimited by a
|
|
342
|
-
* banner line per pattern so consumers can split on it.
|
|
343
|
-
*
|
|
344
|
-
* Workspace policy injection (RFC-Workspace-policy): before any
|
|
345
|
-
* pattern body, fetch the workspace's prose-rule policies from the
|
|
346
|
-
* backend and prepend them as a markdown block. This makes the
|
|
347
|
-
* agent treat the policies as hard preamble — the same shape the
|
|
348
|
-
* Navigator chat injects into ``TopicService.assemble_messages``.
|
|
349
|
-
* Best-effort: a missing token, missing callback URL, or a network
|
|
350
|
-
* failure quietly skips the prepend so local / offline runs still
|
|
351
|
-
* work. */
|
|
352
|
-
if (!(ctx.json || args.json)) {
|
|
353
|
-
const provider = resolveAgentProvider(config, args.lane);
|
|
354
|
-
if (provider) console.error(`# ship: lane=${args.lane} agent.provider=${provider} mode=${runMode}`);
|
|
355
|
-
const preamble = await fetchPoliciesPreamble(args);
|
|
356
|
-
if (preamble) emitPoliciesPreamble(preamble);
|
|
357
|
-
emitPatternBodies(runs, { json: false });
|
|
358
|
-
}
|
|
359
258
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
pattern_version: lane.pattern_version || null,
|
|
367
|
-
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
|
|
368
|
-
});
|
|
369
|
-
} catch (err) {
|
|
370
|
-
await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
|
|
371
|
-
die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
|
|
372
|
-
}
|
|
259
|
+
function methodologyBase(env, config) {
|
|
260
|
+
// Server's POST /fetch lives next to /v1, not under /api/methodology.
|
|
261
|
+
if (env.apiBase) return env.apiBase;
|
|
262
|
+
const fromConfig = config?.api?.base_url;
|
|
263
|
+
if (typeof fromConfig === "string" && fromConfig.trim()) {
|
|
264
|
+
return stripSlash(fromConfig);
|
|
373
265
|
}
|
|
266
|
+
throw new Error("SHIP_API_BASE not set and no api.base_url in .ship/config.yml");
|
|
267
|
+
}
|
|
374
268
|
|
|
375
|
-
const callbackMetrics = runMode === "single"
|
|
376
|
-
? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256 }
|
|
377
|
-
: {
|
|
378
|
-
pattern_id: runs[0].patternId,
|
|
379
|
-
pattern_sha256: runs[0].sha256,
|
|
380
|
-
patterns: runs.map((r) => r.patternId).join(","),
|
|
381
|
-
};
|
|
382
|
-
const callbackSummary = runMode === "single"
|
|
383
|
-
? `lane ${args.lane} completed (pattern ${runs[0].patternId}@${runs[0].sha256.slice(0, 8)}).`
|
|
384
|
-
: `lane ${args.lane} completed (${runs.length} patterns, mode=${runMode}).`;
|
|
385
|
-
const callbackResult = await tryCallback(args, "ok", callbackSummary, callbackMetrics);
|
|
386
|
-
|
|
387
|
-
if (ctx.json || args.json) {
|
|
388
|
-
// For single-pattern runs we keep the legacy ``pattern: {…}`` key
|
|
389
|
-
// alongside the new ``patterns: […]`` list so existing consumers
|
|
390
|
-
// (and tests) don't break when they upgrade shipctl before
|
|
391
|
-
// starting to declare multi-pattern lanes.
|
|
392
|
-
const summaryPayload = {
|
|
393
|
-
lane: args.lane,
|
|
394
|
-
kind: lane.kind,
|
|
395
|
-
trigger: effectiveTrigger.trigger,
|
|
396
|
-
status: "completed",
|
|
397
|
-
mode: runMode,
|
|
398
|
-
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
|
|
399
|
-
callback: callbackResult,
|
|
400
|
-
};
|
|
401
|
-
if (runs.length === 1) {
|
|
402
|
-
summaryPayload.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
|
|
403
|
-
}
|
|
404
|
-
console.log(JSON.stringify(summaryPayload, null, 2));
|
|
405
|
-
}
|
|
406
269
|
|
|
407
|
-
|
|
270
|
+
function splitFrontmatter(raw) {
|
|
271
|
+
if (!raw.startsWith("---")) return { frontmatter: {}, frontmatterRaw: "", body: raw };
|
|
272
|
+
const end = raw.indexOf("\n---\n", 4);
|
|
273
|
+
if (end < 0) return { frontmatter: {}, frontmatterRaw: "", body: raw };
|
|
274
|
+
const headRaw = raw.slice(3, end + 1);
|
|
275
|
+
const body = raw.slice(end + 5);
|
|
276
|
+
let parsed = {};
|
|
277
|
+
try {
|
|
278
|
+
parsed = yaml.parse(headRaw) || {};
|
|
279
|
+
} catch {
|
|
280
|
+
// Some Ship patterns have unquoted ``@elmundi/ship-core`` in
|
|
281
|
+
// ``authors`` which strict YAML rejects. The CLI doesn't need the
|
|
282
|
+
// full document — ``pickFsmStage`` falls back to a regex on the
|
|
283
|
+
// raw frontmatter text in that case.
|
|
284
|
+
parsed = {};
|
|
285
|
+
}
|
|
286
|
+
return { frontmatter: parsed, frontmatterRaw: headRaw, body };
|
|
408
287
|
}
|
|
409
288
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
for (const r of runs) {
|
|
424
|
-
process.stdout.write(`# ship: pattern=${r.patternId} sha256=${r.sha256}\n`);
|
|
425
|
-
const body = r.body;
|
|
426
|
-
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
289
|
+
|
|
290
|
+
function pickFsmStage(frontmatter, frontmatterRaw) {
|
|
291
|
+
const spec = frontmatter?.spec || {};
|
|
292
|
+
const v = spec.fsm_stage ?? spec.fsmStage;
|
|
293
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
294
|
+
if (frontmatterRaw) {
|
|
295
|
+
// Strict-YAML-fallback: regex on the raw frontmatter. Matches
|
|
296
|
+
// ``fsm_stage: triage`` (with optional surrounding whitespace, with
|
|
297
|
+
// or without quotes) anywhere in the spec block.
|
|
298
|
+
const m = frontmatterRaw.match(/^\s*fsm_stage:\s*['"]?([\w.-]+)['"]?/m);
|
|
299
|
+
if (m && m[1]) return m[1].trim();
|
|
427
300
|
}
|
|
301
|
+
return null;
|
|
428
302
|
}
|
|
429
303
|
|
|
430
|
-
/**
|
|
431
|
-
* Print the workspace-policies preamble once at the top of stdout,
|
|
432
|
-
* before the first pattern body. Trailing ``---`` separator visually
|
|
433
|
-
* distinguishes the preamble from the pattern markdown the agent is
|
|
434
|
-
* about to consume; an extra blank line above the separator keeps
|
|
435
|
-
* the markdown well-formed if the preamble already ends with one.
|
|
436
|
-
*/
|
|
437
|
-
function emitPoliciesPreamble(preamble) {
|
|
438
|
-
const trimmed = preamble.endsWith("\n") ? preamble : `${preamble}\n`;
|
|
439
|
-
process.stdout.write(trimmed);
|
|
440
|
-
process.stdout.write("\n---\n");
|
|
441
|
-
}
|
|
442
304
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
* - the URL doesn't end in ``/result`` (someone overrode the
|
|
454
|
-
* callback endpoint to a non-canonical path — too risky to
|
|
455
|
-
* guess),
|
|
456
|
-
* - the backend has no enabled policies (``preamble: null``),
|
|
457
|
-
* - or the request fails for any reason.
|
|
458
|
-
*
|
|
459
|
-
* Failures are surfaced as ``warn:`` lines on stderr so an operator
|
|
460
|
-
* can debug them without breaking the lane execution.
|
|
461
|
-
*/
|
|
462
|
-
async function fetchPoliciesPreamble(args) {
|
|
463
|
-
const callbackUrl = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
|
|
464
|
-
if (!callbackUrl) return null;
|
|
465
|
-
const token = args.runToken || process.env.SHIP_RUN_TOKEN;
|
|
466
|
-
if (!token) return null;
|
|
467
|
-
if (!callbackUrl.endsWith("/result")) {
|
|
468
|
-
console.error(
|
|
469
|
-
`warn: SHIP_CALLBACK_URL does not end in /result; skipping policies-preamble fetch (got ${callbackUrl}).`,
|
|
470
|
-
);
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
const url = `${callbackUrl.slice(0, -"/result".length)}/policies-preamble`;
|
|
474
|
-
try {
|
|
475
|
-
const res = await fetch(url, {
|
|
476
|
-
method: "GET",
|
|
477
|
-
headers: {
|
|
478
|
-
Accept: "application/json",
|
|
479
|
-
Authorization: `Bearer ${token}`,
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
if (!res.ok) {
|
|
483
|
-
console.error(
|
|
484
|
-
`warn: policies-preamble fetch returned HTTP ${res.status} ${res.statusText}; continuing without policies.`,
|
|
485
|
-
);
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
const body = await res.json().catch(() => null);
|
|
489
|
-
if (!body || typeof body !== "object") return null;
|
|
490
|
-
const preamble = body.preamble;
|
|
491
|
-
if (typeof preamble !== "string" || !preamble.trim()) return null;
|
|
492
|
-
return preamble;
|
|
493
|
-
} catch (err) {
|
|
494
|
-
console.error(
|
|
495
|
-
`warn: policies-preamble fetch failed: ${err instanceof Error ? err.message : err}`,
|
|
496
|
-
);
|
|
497
|
-
return null;
|
|
305
|
+
async function getNextTask({ apiBase, apiToken, workspaceId, state }) {
|
|
306
|
+
const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/tracker/next?state=${encodeURIComponent(state)}`;
|
|
307
|
+
const res = await fetch(url, {
|
|
308
|
+
headers: {
|
|
309
|
+
Accept: "application/json",
|
|
310
|
+
Authorization: `Bearer ${apiToken}`,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
throw new Error(`tracker/next ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
498
315
|
}
|
|
316
|
+
const body = await res.json();
|
|
317
|
+
return body.ticket || null;
|
|
499
318
|
}
|
|
500
319
|
|
|
501
320
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
321
|
+
function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage, finishCtx }) {
|
|
322
|
+
const issueRef = task?.ticket_ref ? task.ticket_ref : "(no ticket)";
|
|
323
|
+
const title = task?.title || "";
|
|
324
|
+
const description = task?.body || "";
|
|
325
|
+
|
|
326
|
+
// Pattern-template substitution. Order matters: expand {{BASE}} first
|
|
327
|
+
// so any further {{ROLE}} / {{SKILLS_CONTEXT}} placeholders inside
|
|
328
|
+
// common-base also get resolved.
|
|
329
|
+
const baseExpanded = (baseBody || "")
|
|
330
|
+
.replace(/\{\{ROLE\}\}/g, role)
|
|
331
|
+
.replace(/\{\{ISSUE\}\}/g, issueRef)
|
|
332
|
+
.replace(/\{\{SKILLS_CONTEXT\}\}/g, "(no skills directory bundled in this run)");
|
|
333
|
+
|
|
334
|
+
const expanded = patternBody
|
|
335
|
+
.replace(/\{\{BASE\}\}/g, baseExpanded)
|
|
336
|
+
.replace(/\{\{ROLE\}\}/g, role)
|
|
337
|
+
.replace(/\{\{ISSUE\}\}/g, issueRef)
|
|
338
|
+
.replace(/\{\{TITLE\}\}/g, title.slice(0, 500))
|
|
339
|
+
.replace(/\{\{DESCRIPTION\}\}/g, description.slice(0, 8000));
|
|
340
|
+
|
|
341
|
+
const out = [];
|
|
342
|
+
if (routineSpec.prompt) {
|
|
343
|
+
out.push("## Routine instructions");
|
|
344
|
+
out.push(routineSpec.prompt.trim());
|
|
345
|
+
out.push("");
|
|
346
|
+
}
|
|
347
|
+
out.push(expanded.trim());
|
|
348
|
+
if (task) {
|
|
349
|
+
out.push("");
|
|
350
|
+
out.push("## Task");
|
|
351
|
+
out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
|
|
352
|
+
if (task.url) out.push(`- **URL:** ${task.url}`);
|
|
353
|
+
if (task.title) out.push(`- **Title:** ${task.title}`);
|
|
354
|
+
if (task.fsm_stage || fsmStage) out.push(`- **FSM stage:** \`${task.fsm_stage || fsmStage}\``);
|
|
355
|
+
if (Array.isArray(task.labels) && task.labels.length) {
|
|
356
|
+
out.push(`- **Labels:** ${task.labels.join(", ")}`);
|
|
527
357
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
out
|
|
531
|
-
|
|
532
|
-
return true;
|
|
358
|
+
if (task.body) {
|
|
359
|
+
out.push("");
|
|
360
|
+
out.push("### Description");
|
|
361
|
+
out.push(task.body);
|
|
533
362
|
}
|
|
534
|
-
return false;
|
|
535
|
-
};
|
|
536
|
-
while (copy.length) {
|
|
537
|
-
const a = copy[0];
|
|
538
|
-
if (a === "--help" || a === "-h") {
|
|
539
|
-
out.help = true;
|
|
540
|
-
copy.shift();
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
if (a === "--dry-run") {
|
|
544
|
-
out.dryRun = true;
|
|
545
|
-
copy.shift();
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
if (a === "--offline") {
|
|
549
|
-
out.offline = true;
|
|
550
|
-
copy.shift();
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
if (a === "--json") {
|
|
554
|
-
out.json = true;
|
|
555
|
-
copy.shift();
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
if (str("--lane", "lane")) continue;
|
|
559
|
-
if (str("--pattern", "pattern")) continue;
|
|
560
|
-
if (str("--fanout", "fanout")) continue;
|
|
561
|
-
if (str("--trigger", "trigger")) continue;
|
|
562
|
-
if (str("--ship-run-id", "runId")) continue;
|
|
563
|
-
if (str("--ship-callback-url", "callbackUrl")) continue;
|
|
564
|
-
if (str("--ship-run-token", "runToken")) continue;
|
|
565
|
-
if (str("--cwd", "cwd")) {
|
|
566
|
-
out.cwd = path.resolve(out.cwd);
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl run --help`);
|
|
570
|
-
}
|
|
571
|
-
if (out.trigger && !VALID_TRIGGERS.has(out.trigger)) {
|
|
572
|
-
die(
|
|
573
|
-
EXIT_USAGE,
|
|
574
|
-
`--trigger must be one of ${[...VALID_TRIGGERS].join("|")}; got ${out.trigger}`,
|
|
575
|
-
);
|
|
576
|
-
}
|
|
577
|
-
if (out.fanout && !LANE_FANOUT_MODES.includes(out.fanout)) {
|
|
578
|
-
die(
|
|
579
|
-
EXIT_USAGE,
|
|
580
|
-
`--fanout must be one of ${LANE_FANOUT_MODES.join("|")}; got ${out.fanout}`,
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
if (out.pattern !== null && (typeof out.pattern !== "string" || !out.pattern.trim())) {
|
|
584
|
-
die(EXIT_USAGE, "--pattern: must be a non-empty pattern id");
|
|
585
363
|
}
|
|
586
|
-
|
|
364
|
+
out.push("");
|
|
365
|
+
out.push(renderExitProtocol(finishCtx));
|
|
366
|
+
return out.join("\n");
|
|
587
367
|
}
|
|
588
368
|
|
|
589
|
-
function resolveTrigger(explicit, laneKind) {
|
|
590
|
-
const raw =
|
|
591
|
-
explicit ||
|
|
592
|
-
(process.env.SHIP_RUN_TRIGGER && process.env.SHIP_RUN_TRIGGER.trim()) ||
|
|
593
|
-
inferFromEnv();
|
|
594
|
-
const trigger = raw || "manual";
|
|
595
|
-
|
|
596
|
-
/* `once` lanes only run under `manual` or `once` triggers. Scheduler
|
|
597
|
-
* or event triggers must not accidentally repeat seeding because the
|
|
598
|
-
* cron happens to tick. */
|
|
599
|
-
if (laneKind === "once") {
|
|
600
|
-
return { fits: trigger === "manual" || trigger === "once", trigger };
|
|
601
|
-
}
|
|
602
|
-
if (laneKind === "schedule") {
|
|
603
|
-
return { fits: trigger === "schedule" || trigger === "manual", trigger };
|
|
604
|
-
}
|
|
605
|
-
if (laneKind === "event") {
|
|
606
|
-
return { fits: trigger === "event" || trigger === "manual", trigger };
|
|
607
|
-
}
|
|
608
|
-
return { fits: false, trigger };
|
|
609
|
-
}
|
|
610
369
|
|
|
611
|
-
function
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
370
|
+
function renderExitProtocol(ctx) {
|
|
371
|
+
// Substitute the run-time values directly into the example so the
|
|
372
|
+
// agent doesn't have to figure out env var hookup. The token is
|
|
373
|
+
// workspace-scoped and meant for this run; the prompt warns the
|
|
374
|
+
// agent not to echo it.
|
|
375
|
+
const apiBase = ctx?.apiBase || "$SHIP_API_BASE";
|
|
376
|
+
const apiToken = ctx?.apiToken || "$SHIP_API_TOKEN";
|
|
377
|
+
const workspaceId = ctx?.workspaceId || "$SHIP_WORKSPACE_ID";
|
|
378
|
+
const runId = ctx?.runId || "<run_id>";
|
|
379
|
+
const ticketRef = ctx?.ticketRef ?? null;
|
|
380
|
+
const fsm = ctx?.fsmStage ?? null;
|
|
381
|
+
const ticketLine = ticketRef === null
|
|
382
|
+
? '"ticket_ref": null,'
|
|
383
|
+
: `"ticket_ref": ${JSON.stringify(ticketRef)},`;
|
|
384
|
+
const fsmLine = fsm === null
|
|
385
|
+
? '"fsm_stage": null,'
|
|
386
|
+
: `"fsm_stage": ${JSON.stringify(fsm)},`;
|
|
387
|
+
|
|
388
|
+
return `## Required exit protocol
|
|
389
|
+
|
|
390
|
+
When you finish (or determine you cannot proceed), call Ship's finish
|
|
391
|
+
endpoint **once** with your outcome and stop. This is the only
|
|
392
|
+
sanctioned write surface — Ship's server applies tracker side-effects
|
|
393
|
+
through the workspace's existing OAuth.
|
|
394
|
+
|
|
395
|
+
**Do not** create empty branches or commit placeholder files. If your
|
|
396
|
+
role doesn't change code, no branch is required. If your role does
|
|
397
|
+
change code, push the code on the branch Ship CLI named for you, then
|
|
398
|
+
call finish.
|
|
399
|
+
|
|
400
|
+
**Do not** call any Linear / Jira / GitHub MCP that writes. Reading
|
|
401
|
+
via MCP is fine; writing is not. The finish endpoint is the only
|
|
402
|
+
write surface.
|
|
403
|
+
|
|
404
|
+
\`\`\`bash
|
|
405
|
+
curl -fsS -X POST '${apiBase}/v1/workspaces/${workspaceId}/agent-runs/finish' \\
|
|
406
|
+
-H 'Authorization: Bearer ${apiToken}' \\
|
|
407
|
+
-H 'Content-Type: application/json' \\
|
|
408
|
+
--data @- <<'JSON'
|
|
409
|
+
{
|
|
410
|
+
"run_id": ${JSON.stringify(runId)},
|
|
411
|
+
"outcome": "ready_next_step",
|
|
412
|
+
${ticketLine}
|
|
413
|
+
${fsmLine}
|
|
414
|
+
"stage_next": "<next FSM stage, e.g. ba_requirements>",
|
|
415
|
+
"comment": "Markdown summary of what you did. End with [Ship SDLC:${ctx?.role || "{{ROLE}}"}].",
|
|
416
|
+
"summary": null,
|
|
417
|
+
"payload": {}
|
|
616
418
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
419
|
+
JSON
|
|
420
|
+
\`\`\`
|
|
421
|
+
|
|
422
|
+
### Outcomes
|
|
423
|
+
|
|
424
|
+
- **\`ready_next_step\`** — your role finished cleanly. Set
|
|
425
|
+
\`stage_next\` to the next FSM stage. Server moves the ticket and
|
|
426
|
+
posts \`comment\` if provided.
|
|
427
|
+
- **\`needs_clarification\`** — you're waiting on a human. Set
|
|
428
|
+
\`comment\` with the question (server posts it) or omit it if you
|
|
429
|
+
already left the question via a separate read-only path. Server
|
|
430
|
+
tags the ticket \`needs:clarification\` so intake stops re-picking.
|
|
431
|
+
Status stays Todo. \`stage_next\` is ignored.
|
|
432
|
+
- **\`blocked\`** — you cannot proceed (missing secret, broken env,
|
|
433
|
+
conflicting branch). Server drops a blocker into the workspace
|
|
434
|
+
inbox; ticket unchanged. \`stage_next\` is ignored.
|
|
435
|
+
- **\`out_of_scope\`** — the ticket is invalid or shouldn't be
|
|
436
|
+
processed. Server moves it to Done with optional \`comment\`.
|
|
437
|
+
\`stage_next\` is ignored.
|
|
438
|
+
|
|
439
|
+
### Security
|
|
440
|
+
|
|
441
|
+
\`SHIP_API_TOKEN\` is rendered into this prompt so you can call the
|
|
442
|
+
finish endpoint. **Do not echo it back into commit messages, PR
|
|
443
|
+
descriptions, comments, logs, or any output you produce.** Treat it
|
|
444
|
+
as a one-shot credential for this run.
|
|
445
|
+
`;
|
|
622
446
|
}
|
|
623
447
|
|
|
624
|
-
async function fetchPatternBody({ patternId, patternVersion, offline, root, ctx, config }) {
|
|
625
|
-
/* --offline takes precedence when requested: we MUST NOT hit the
|
|
626
|
-
* network or fall through to another source. The lockfile is the
|
|
627
|
-
* single source of truth. This makes CI runs reproducible and keeps
|
|
628
|
-
* air-gapped installs honest. */
|
|
629
|
-
if (offline) return fetchFromLockfile({ patternId, root, strict: true });
|
|
630
448
|
|
|
631
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Load a pattern body. Resolution order:
|
|
451
|
+
* 1) when running inside the Ship monorepo, read from
|
|
452
|
+
* ``artifacts/patterns/<id>/ARTIFACT.md`` on disk — fast and
|
|
453
|
+
* always reflects the working tree (good for dry-runs / local
|
|
454
|
+
* smoke tests before the server is rebuilt).
|
|
455
|
+
* 2) otherwise hit the server's ``POST /fetch``.
|
|
456
|
+
*/
|
|
457
|
+
async function loadPattern({ id, fetchBase, optional = false }) {
|
|
632
458
|
const shipRepo = resolveShipRepoRootForCatalog();
|
|
633
459
|
if (shipRepo) {
|
|
634
|
-
const file = readArtifactFile(shipRepo, "pattern",
|
|
635
|
-
if (file)
|
|
636
|
-
const verification = verifyAgainstLockfile({
|
|
637
|
-
root,
|
|
638
|
-
patternId,
|
|
639
|
-
body: file.content,
|
|
640
|
-
});
|
|
641
|
-
if (verification.warning) console.error(`warn: ${verification.warning}`);
|
|
642
|
-
return { ok: true, body: file.content, source: "monorepo", lock: verification };
|
|
643
|
-
}
|
|
460
|
+
const file = readArtifactFile(shipRepo, "pattern", id);
|
|
461
|
+
if (file && typeof file.content === "string") return file.content;
|
|
644
462
|
}
|
|
645
|
-
|
|
646
|
-
/* 2) Network: same resolver `shipctl kickoff` uses. */
|
|
647
|
-
const base = resolveMethodologyBase(ctx, config);
|
|
648
463
|
try {
|
|
649
|
-
const { content } = await fetchArtifact(
|
|
650
|
-
|
|
651
|
-
if (verification.warning) console.error(`warn: ${verification.warning}`);
|
|
652
|
-
return { ok: true, body: content, source: "http", lock: verification };
|
|
464
|
+
const { content } = await fetchArtifact(fetchBase, "pattern", id);
|
|
465
|
+
return content;
|
|
653
466
|
} catch (err) {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const fallback = fetchFromLockfile({ patternId, root, strict: false });
|
|
658
|
-
if (fallback.ok) {
|
|
659
|
-
console.error(
|
|
660
|
-
`warn: network fetch failed for pattern/${patternId}; using locked copy (${fallback.source}).`,
|
|
661
|
-
);
|
|
662
|
-
return fallback;
|
|
467
|
+
if (optional) {
|
|
468
|
+
console.error(`warn: failed to fetch pattern '${id}': ${err.message}`);
|
|
469
|
+
return "";
|
|
663
470
|
}
|
|
664
|
-
|
|
665
|
-
ok: false,
|
|
666
|
-
error: `failed to fetch pattern ${patternId}: ${err instanceof Error ? err.message : err}`,
|
|
667
|
-
};
|
|
471
|
+
throw err;
|
|
668
472
|
}
|
|
669
473
|
}
|
|
670
474
|
|
|
671
|
-
function verifyAgainstLockfile({ root, patternId, body }) {
|
|
672
|
-
let lock;
|
|
673
|
-
try {
|
|
674
|
-
lock = readLockfile(root);
|
|
675
|
-
} catch (err) {
|
|
676
|
-
return { present: false, ok: null, warning: `lockfile unreadable: ${err.message}` };
|
|
677
|
-
}
|
|
678
|
-
if (!lock) return { present: false, ok: null };
|
|
679
|
-
const entry = lookupLock(lock, "pattern", patternId);
|
|
680
|
-
if (!entry) {
|
|
681
|
-
return {
|
|
682
|
-
present: true,
|
|
683
|
-
ok: null,
|
|
684
|
-
warning: `lockfile present but has no entry for pattern/${patternId}; run 'shipctl sync --lock'.`,
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
const result = verifyBody(entry, body);
|
|
688
|
-
if (!result.ok) {
|
|
689
|
-
return {
|
|
690
|
-
present: true,
|
|
691
|
-
ok: false,
|
|
692
|
-
reason: result.reason,
|
|
693
|
-
expected: result.expected,
|
|
694
|
-
actual: result.actual,
|
|
695
|
-
warning: `pattern/${patternId} sha256 drift vs lockfile (${result.reason}; expected ${result.expected?.slice(0, 8)} got ${result.actual?.slice(0, 8)})`,
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
return { present: true, ok: true, version: entry.version };
|
|
699
|
-
}
|
|
700
475
|
|
|
701
|
-
function
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return {
|
|
707
|
-
ok: false,
|
|
708
|
-
error: `lockfile unreadable: ${err.message}. Run 'shipctl sync --lock' to rebuild.`,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
if (!lock) {
|
|
712
|
-
if (!strict) return { ok: false, error: "lockfile missing" };
|
|
713
|
-
return {
|
|
714
|
-
ok: false,
|
|
715
|
-
error:
|
|
716
|
-
"--offline requires .ship/shipctl.lock.json. Run 'shipctl sync --lock' in an online environment first.",
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
const entry = lookupLock(lock, "pattern", patternId);
|
|
720
|
-
if (!entry) {
|
|
721
|
-
return {
|
|
722
|
-
ok: false,
|
|
723
|
-
error:
|
|
724
|
-
strict
|
|
725
|
-
? `--offline: pattern/${patternId} missing from .ship/shipctl.lock.json. Run 'shipctl sync --lock' to re-resolve.`
|
|
726
|
-
: `pattern/${patternId} not in lockfile`,
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
const abs = path.join(root, entry.cached_path);
|
|
730
|
-
let body;
|
|
731
|
-
try {
|
|
732
|
-
body = fs.readFileSync(abs, "utf8");
|
|
733
|
-
} catch (err) {
|
|
734
|
-
return {
|
|
735
|
-
ok: false,
|
|
736
|
-
error: `--offline: cached pattern body unreadable at ${entry.cached_path} (${err instanceof Error ? err.message : err}). Run 'shipctl sync --lock'.`,
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
const verification = verifyBody(entry, body);
|
|
740
|
-
if (!verification.ok) {
|
|
741
|
-
return {
|
|
742
|
-
ok: false,
|
|
743
|
-
error: `--offline: sha256 mismatch for pattern/${patternId} (expected ${verification.expected?.slice(0, 8)}, got ${verification.actual?.slice(0, 8)}). Re-run 'shipctl sync --lock'.`,
|
|
744
|
-
};
|
|
476
|
+
function makeBranchName(routine, ticketRef) {
|
|
477
|
+
const stamp = Date.now().toString(36);
|
|
478
|
+
if (ticketRef) {
|
|
479
|
+
const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
480
|
+
return `cursor/ship-${routine}-${safe}-${stamp}`;
|
|
745
481
|
}
|
|
746
|
-
return {
|
|
747
|
-
ok: true,
|
|
748
|
-
body,
|
|
749
|
-
source: "lockfile",
|
|
750
|
-
lock: { present: true, ok: true, version: entry.version },
|
|
751
|
-
};
|
|
482
|
+
return `cursor/ship-${routine}-${stamp}`;
|
|
752
483
|
}
|
|
753
484
|
|
|
754
|
-
function resolveMethodologyBase(ctx, config) {
|
|
755
|
-
const fromFlag = ctx.baseUrl;
|
|
756
|
-
const fromEnv =
|
|
757
|
-
typeof process.env.SHIP_API_BASE === "string" && process.env.SHIP_API_BASE.trim()
|
|
758
|
-
? process.env.SHIP_API_BASE.trim().replace(/\/$/, "")
|
|
759
|
-
: null;
|
|
760
|
-
/* Wizard-seeded Actions secret: exact Ship API origin (``POST /fetch`` lives
|
|
761
|
-
* at the root next to ``/v1``). Do not append ``/api/methodology`` here. */
|
|
762
|
-
if (fromEnv) {
|
|
763
|
-
return fromEnv;
|
|
764
|
-
}
|
|
765
|
-
const raw = config?.api?.base_url;
|
|
766
|
-
if (typeof raw === "string" && raw.trim()) {
|
|
767
|
-
const u = raw.replace(/\/$/, "");
|
|
768
|
-
return u.includes("/api/methodology") ? u : `${u}/api/methodology`;
|
|
769
|
-
}
|
|
770
|
-
return fromFlag;
|
|
771
|
-
}
|
|
772
485
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const out = { ...(extra || {}) };
|
|
796
|
-
if (args && args.lane && !out.lane_id) out.lane_id = args.lane;
|
|
797
|
-
if (env.GITHUB_RUN_ID && !out.gh_workflow_run_id) {
|
|
798
|
-
out.gh_workflow_run_id = env.GITHUB_RUN_ID;
|
|
799
|
-
}
|
|
800
|
-
if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID && !out.gh_html_url) {
|
|
801
|
-
out.gh_html_url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Argument plumbing
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
function parseArgs(rest) {
|
|
492
|
+
const out = { routine: null, cwd: null, json: false, help: false, dryRun: false };
|
|
493
|
+
const copy = [...rest];
|
|
494
|
+
while (copy.length) {
|
|
495
|
+
const a = copy[0];
|
|
496
|
+
if (a === "--help" || a === "-h") { out.help = true; copy.shift(); continue; }
|
|
497
|
+
if (a === "--json") { out.json = true; copy.shift(); continue; }
|
|
498
|
+
if (a === "--dry-run") { out.dryRun = true; copy.shift(); continue; }
|
|
499
|
+
if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
|
|
500
|
+
if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
|
|
501
|
+
// Soft-ignore legacy flags that older trigger workflows still pass —
|
|
502
|
+
// the new pipeline doesn't need them and refusing would break repos
|
|
503
|
+
// that haven't re-seeded yet. ``--lane`` is the back-compat spelling
|
|
504
|
+
// of ``--routine`` from before the rename.
|
|
505
|
+
if (a === "--trigger" && copy[1] !== undefined) { copy.splice(0, 2); continue; }
|
|
506
|
+
if (a === "--lane" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
|
|
507
|
+
die(EXIT_USAGE, `unknown argument: ${a}`);
|
|
802
508
|
}
|
|
803
|
-
if (env.GITHUB_EVENT_NAME && !out.gh_event) out.gh_event = env.GITHUB_EVENT_NAME;
|
|
804
509
|
return out;
|
|
805
510
|
}
|
|
806
511
|
|
|
807
|
-
async function tryCallback(args, status, summary, extraMetrics = {}) {
|
|
808
|
-
const url = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
|
|
809
|
-
if (!url) return { ok: null, skipped: "no-callback-url" };
|
|
810
|
-
const token = args.runToken || process.env.SHIP_RUN_TOKEN;
|
|
811
|
-
if (!token) {
|
|
812
|
-
console.error(
|
|
813
|
-
"warn: SHIP_RUN_TOKEN missing; skipping callback. (Set via --ship-run-token or env.)",
|
|
814
|
-
);
|
|
815
|
-
return { ok: false, skipped: "no-token" };
|
|
816
|
-
}
|
|
817
|
-
const body = { status: status === "ok" ? "succeeded" : status === "fail" ? "failed" : status };
|
|
818
|
-
if (summary) body.summary = String(summary).slice(0, 1024);
|
|
819
|
-
const metrics = collectCallbackMetrics(args, extraMetrics);
|
|
820
|
-
if (Object.keys(metrics).length > 0) body.metrics = metrics;
|
|
821
512
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
513
|
+
function printHelp() {
|
|
514
|
+
console.log(`shipctl run — execute one E14 routine end-to-end.
|
|
515
|
+
|
|
516
|
+
Run
|
|
517
|
+
shipctl run --routine <id> [--json] [--cwd <dir>] [--dry-run]
|
|
518
|
+
|
|
519
|
+
ENV
|
|
520
|
+
SHIP_API_BASE Ship server base URL (e.g. https://api.ship.elmundi.com)
|
|
521
|
+
SHIP_API_TOKEN workspace API token; rendered into the agent prompt
|
|
522
|
+
so the agent can call /agent-runs/finish itself
|
|
523
|
+
SHIP_WORKSPACE_ID UUID of the workspace (a workspace is one project)
|
|
524
|
+
GITHUB_REPOSITORY owner/repo (which checkout the agent gets)
|
|
525
|
+
CURSOR_API_KEY Cursor Cloud API key (when agent.default.provider=cursor)
|
|
526
|
+
|
|
527
|
+
EXIT
|
|
528
|
+
0 agent runtime reached a terminal state (FINISHED/CANCELLED/ERRORED).
|
|
529
|
+
Whether the agent actually called /agent-runs/finish is observable
|
|
530
|
+
in the audit log — this CLI no longer waits on that signal.
|
|
531
|
+
1 usage / config error
|
|
532
|
+
7 agent runtime failed to launch
|
|
533
|
+
`);
|
|
842
534
|
}
|
|
843
535
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
536
|
+
|
|
537
|
+
function emit(args, payload) {
|
|
538
|
+
if (args.json) {
|
|
539
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
847
540
|
} else {
|
|
848
|
-
console.error(
|
|
849
|
-
|
|
850
|
-
);
|
|
541
|
+
console.error(`# ship: ${payload.status}${payload.reason ? ` reason=${payload.reason}` : ""}${payload.routine ? ` routine=${payload.routine}` : ""}`);
|
|
542
|
+
if (payload.error) console.error(`# error: ${payload.error}`);
|
|
851
543
|
}
|
|
852
544
|
}
|
|
853
545
|
|
|
546
|
+
|
|
854
547
|
function die(code, msg) {
|
|
855
548
|
console.error(msg);
|
|
856
549
|
process.exit(code);
|