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