@elmundi/ship-cli 0.14.2 → 0.15.4
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 +17 -16
- package/bin/shipctl.mjs +4 -80
- package/lib/commands/feedback.mjs +1 -1
- package/lib/commands/help.mjs +47 -131
- package/lib/commands/init.mjs +17 -250
- package/lib/commands/knowledge.mjs +25 -328
- package/lib/commands/preflight.mjs +213 -0
- package/lib/commands/run.mjs +298 -119
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +73 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +39 -0
- package/lib/templates.mjs +2 -2
- package/lib/verify/checks/agents-on-disk.mjs +5 -28
- package/lib/verify/registry.mjs +7 -8
- package/package.json +1 -1
- package/lib/artifacts/fs-index.mjs +0 -230
- package/lib/cache/store.mjs +0 -422
- package/lib/commands/bootstrap.mjs +0 -4
- package/lib/commands/callback.mjs +0 -742
- package/lib/commands/docs.mjs +0 -90
- package/lib/commands/kickoff.mjs +0 -192
- package/lib/commands/lanes.mjs +0 -566
- package/lib/commands/manifest-catalog.mjs +0 -251
- package/lib/commands/migrate.mjs +0 -204
- package/lib/commands/new.mjs +0 -452
- package/lib/commands/patterns.mjs +0 -160
- package/lib/commands/process.mjs +0 -388
- package/lib/commands/search.mjs +0 -43
- package/lib/commands/sync.mjs +0 -824
- package/lib/config/migrate.mjs +0 -223
- package/lib/find-ship-root.mjs +0 -75
- package/lib/process/specialist-prompt-contract.mjs +0 -171
- package/lib/state/lockfile.mjs +0 -180
- package/lib/vendor/run-agent.workflow.yml +0 -254
- package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
- package/lib/verify/checks/cache-integrity.mjs +0 -51
- package/lib/verify/checks/gitignore-cache.mjs +0 -51
- package/lib/verify/checks/rules-markers.mjs +0 -135
package/lib/commands/run.mjs
CHANGED
|
@@ -4,19 +4,21 @@
|
|
|
4
4
|
* Customer's GH Actions cron fires this once per routine slot. The
|
|
5
5
|
* pipeline:
|
|
6
6
|
*
|
|
7
|
-
* 1. Read the routine from `.ship/config.yml` (
|
|
8
|
-
*
|
|
9
|
-
* 2.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
7
|
+
* 1. Read the routine from `.ship/config.yml` (specialist slug +
|
|
8
|
+
* optional inline prompt).
|
|
9
|
+
* 2. Resolve the agent role body via the workspace endpoint
|
|
10
|
+
* `GET /v1/workspaces/{ws}/agent-roles/{slug}/resolve` —
|
|
11
|
+
* workspace overrides win, otherwise the Ship default. Pull
|
|
12
|
+
* the `system` (shared base) body in parallel.
|
|
13
|
+
* 3. If the resolved role declares `fsm_stage`, ask Ship server
|
|
14
|
+
* for the next ticket in that stage
|
|
13
15
|
* (`GET /v1/.../tracker/next?state=<stage>`). Server picks the
|
|
14
16
|
* adapter (Linear / GH Issues / etc.) — CLI doesn't care.
|
|
15
|
-
* 4. Mint a `run_id` and render the prompt:
|
|
16
|
-
* details + a finish-protocol
|
|
17
|
-
*
|
|
18
|
-
* `FSM_STAGE` already substituted so the
|
|
19
|
-
* finish endpoint directly.
|
|
17
|
+
* 4. Mint a `run_id` and render the prompt: system body + role
|
|
18
|
+
* body + routine prompt + ticket details + a finish-protocol
|
|
19
|
+
* block with `SHIP_API_BASE`, `SHIP_API_TOKEN`, `SHIP_WORKSPACE_ID`,
|
|
20
|
+
* `RUN_ID`, `TICKET_REF`, `FSM_STAGE` already substituted so the
|
|
21
|
+
* agent can call the finish endpoint directly.
|
|
20
22
|
* 5. Launch the configured agent runtime (`cli/lib/agents/`) — Cursor
|
|
21
23
|
* Cloud today. Block until the runtime terminates.
|
|
22
24
|
* 6. The agent itself calls
|
|
@@ -46,15 +48,12 @@
|
|
|
46
48
|
import crypto from "node:crypto";
|
|
47
49
|
import path from "node:path";
|
|
48
50
|
|
|
49
|
-
import yaml from "yaml";
|
|
50
|
-
|
|
51
51
|
import { readConfig, findShipRoot } from "../config/io.mjs";
|
|
52
52
|
import { resolveExecutable } from "../runtime/routines.mjs";
|
|
53
|
-
import { fetchArtifact } from "../http.mjs";
|
|
54
|
-
import { readArtifactFile } from "../artifacts/fs-index.mjs";
|
|
55
|
-
import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
|
|
56
53
|
import { resolveProvider, runAgent } from "../agents/index.mjs";
|
|
57
54
|
|
|
55
|
+
const SYSTEM_ROLE_SLUG = "system";
|
|
56
|
+
|
|
58
57
|
|
|
59
58
|
const EXIT_OK = 0;
|
|
60
59
|
const EXIT_USAGE = 1;
|
|
@@ -73,8 +72,14 @@ export async function runCommand(ctx, rest) {
|
|
|
73
72
|
printHelp();
|
|
74
73
|
process.exit(EXIT_OK);
|
|
75
74
|
}
|
|
76
|
-
if (!args.routine) {
|
|
77
|
-
die(
|
|
75
|
+
if (!args.routine && !args.specialist) {
|
|
76
|
+
die(
|
|
77
|
+
EXIT_USAGE,
|
|
78
|
+
"either `--routine <id>` or `--specialist <slug>` is required.\nRun: shipctl run --help",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (args.routine && args.specialist) {
|
|
82
|
+
die(EXIT_USAGE, "`--routine` and `--specialist` are mutually exclusive.");
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
const cwd = args.cwd || process.cwd();
|
|
@@ -84,33 +89,83 @@ export async function runCommand(ctx, rest) {
|
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
const { config } = readConfig(cwd);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
// Routine mode: resolve from ``.ship/config.yml``. Specialist mode
|
|
93
|
+
// (used by the pipeline-pick fallback in the trigger workflow):
|
|
94
|
+
// synthesize a minimal executable so the rest of the pipeline can
|
|
95
|
+
// stay routine-shaped without inventing a ``pipeline:<slug>``
|
|
96
|
+
// routine in the YAML.
|
|
97
|
+
let resolved;
|
|
98
|
+
if (args.specialist) {
|
|
99
|
+
resolved = {
|
|
100
|
+
kind: "specialist",
|
|
101
|
+
id: args.specialist,
|
|
102
|
+
source: { specialist: args.specialist },
|
|
103
|
+
executable: {
|
|
104
|
+
id: args.specialist,
|
|
105
|
+
type: "specialist",
|
|
106
|
+
kind: "pipeline_pick",
|
|
107
|
+
specialist: args.specialist,
|
|
108
|
+
prompt: null,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
resolved = resolveExecutable(config, args.routine);
|
|
113
|
+
if (!resolved) {
|
|
114
|
+
die(EXIT_USAGE, `unknown routine '${args.routine}' in .ship/config.yml`);
|
|
115
|
+
}
|
|
90
116
|
}
|
|
91
117
|
|
|
118
|
+
// ``runId`` for emit/branch naming carries either the routine id or
|
|
119
|
+
// the specialist slug. Logging downstream uses ``runHandle``.
|
|
120
|
+
const runHandle = args.routine || `pipeline:${args.specialist}`;
|
|
121
|
+
|
|
92
122
|
const env = readEnv();
|
|
93
123
|
const { apiBase, apiToken, workspaceId, githubRepo } = env;
|
|
94
124
|
|
|
95
|
-
// 1) Resolve
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
// 1) Resolve specialist slug.
|
|
126
|
+
// ``routine.specialist`` is the canonical Phase-2.4 form; the legacy
|
|
127
|
+
// ``routine.pattern`` is mapped to a slug in
|
|
128
|
+
// ``cli/lib/runtime/routines.mjs`` (drops the ``role-`` prefix).
|
|
129
|
+
const specialistSlug = resolved.executable.specialist || args.specialist;
|
|
130
|
+
if (!specialistSlug) {
|
|
131
|
+
die(
|
|
132
|
+
EXIT_USAGE,
|
|
133
|
+
`routine '${args.routine}' has no 'specialist:' (or legacy 'pattern:') set`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!apiBase || !apiToken || !workspaceId) {
|
|
137
|
+
die(
|
|
138
|
+
EXIT_USAGE,
|
|
139
|
+
"agent-role resolve requires SHIP_API_BASE + SHIP_API_TOKEN + SHIP_WORKSPACE_ID",
|
|
140
|
+
);
|
|
99
141
|
}
|
|
100
142
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
// 2) Pull the resolved role + the shared system prompt in parallel.
|
|
144
|
+
// ``resolveAgentRole`` returns the workspace override when present,
|
|
145
|
+
// otherwise falls back to the Ship default. ``system`` is fetched
|
|
146
|
+
// optional — older deployments may not have it; we render without
|
|
147
|
+
// a system header in that case.
|
|
148
|
+
const [roleResolved, systemResolved] = await Promise.all([
|
|
149
|
+
resolveAgentRole({ apiBase, apiToken, workspaceId, slug: specialistSlug }),
|
|
150
|
+
resolveAgentRole({
|
|
151
|
+
apiBase,
|
|
152
|
+
apiToken,
|
|
153
|
+
workspaceId,
|
|
154
|
+
slug: SYSTEM_ROLE_SLUG,
|
|
155
|
+
optional: true,
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
if (!roleResolved) {
|
|
159
|
+
die(EXIT_USAGE, `unknown agent role '${specialistSlug}' for this workspace`);
|
|
160
|
+
}
|
|
161
|
+
// Per-routine FSM stage override takes precedence over the role's
|
|
162
|
+
// default. Lets one role (``ba``) drive both ``ba_requirements`` for
|
|
163
|
+
// SDLC and ``wbs`` for decomposition without per-process role clones.
|
|
164
|
+
const fsmStage = resolved.executable?.fsm_stage || roleResolved.fsm_stage || null;
|
|
165
|
+
const roleBody = roleResolved.prompt || "";
|
|
166
|
+
const systemBody = systemResolved?.prompt || "";
|
|
167
|
+
|
|
168
|
+
// 3) Resolve task. ``--dry-run`` skips the server call and uses a
|
|
114
169
|
// synthetic task so the operator can see the prompt shape without
|
|
115
170
|
// needing the new endpoints deployed.
|
|
116
171
|
let task = null;
|
|
@@ -134,20 +189,40 @@ export async function runCommand(ctx, rest) {
|
|
|
134
189
|
state: fsmStage,
|
|
135
190
|
});
|
|
136
191
|
if (!task) {
|
|
137
|
-
emit(args, {
|
|
192
|
+
emit(args, {
|
|
193
|
+
status: "noop",
|
|
194
|
+
routine: args.routine,
|
|
195
|
+
specialist: specialistSlug,
|
|
196
|
+
fsm_stage: fsmStage,
|
|
197
|
+
reason: "no_eligible_ticket",
|
|
198
|
+
run_handle: runHandle,
|
|
199
|
+
});
|
|
138
200
|
process.exit(EXIT_NO_TASK);
|
|
139
201
|
}
|
|
140
202
|
}
|
|
141
203
|
}
|
|
142
204
|
|
|
143
|
-
//
|
|
205
|
+
// 4) Fetch the workspace policy preamble so the agent prompt
|
|
206
|
+
// carries the same standing rules the Navigator chat does. Best-
|
|
207
|
+
// effort: a missing token, missing API base, or a network failure
|
|
208
|
+
// quietly skips the prepend — local / offline runs still work.
|
|
209
|
+
// ``role`` is now the specialist slug directly (no more
|
|
210
|
+
// ``spec.role`` indirection).
|
|
211
|
+
const policiesPreamble = await fetchPoliciesPreamble({
|
|
212
|
+
apiBase,
|
|
213
|
+
apiToken,
|
|
214
|
+
workspaceId,
|
|
215
|
+
role: specialistSlug,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 5) Mint a run_id + render prompt with finish-protocol values
|
|
144
219
|
// already substituted so the agent can call /agent-runs/finish from
|
|
145
220
|
// inside Cursor without holding any extra config.
|
|
146
221
|
const runId = `run_${crypto.randomBytes(8).toString("hex")}`;
|
|
147
|
-
const
|
|
148
|
-
patternBody,
|
|
149
|
-
baseBody,
|
|
150
|
-
role:
|
|
222
|
+
const renderedPrompt = renderPrompt({
|
|
223
|
+
patternBody: roleBody,
|
|
224
|
+
baseBody: systemBody,
|
|
225
|
+
role: specialistSlug,
|
|
151
226
|
routineSpec: resolved.executable,
|
|
152
227
|
task,
|
|
153
228
|
fsmStage,
|
|
@@ -156,27 +231,31 @@ export async function runCommand(ctx, rest) {
|
|
|
156
231
|
apiToken,
|
|
157
232
|
workspaceId,
|
|
158
233
|
runId,
|
|
159
|
-
role:
|
|
234
|
+
role: specialistSlug,
|
|
160
235
|
ticketRef: task?.ticket_ref || null,
|
|
161
236
|
fsmStage: fsmStage || null,
|
|
162
237
|
},
|
|
163
238
|
});
|
|
239
|
+
const prompt = policiesPreamble
|
|
240
|
+
? `${policiesPreamble.trim()}\n\n---\n\n${renderedPrompt}`
|
|
241
|
+
: renderedPrompt;
|
|
164
242
|
|
|
165
243
|
// ``--dry-run`` exits here so the operator can eyeball the rendered
|
|
166
244
|
// prompt + resolved task without launching an agent or touching any
|
|
167
|
-
// tracker. Useful when iterating on
|
|
245
|
+
// tracker. Useful when iterating on prompts.
|
|
168
246
|
if (args.dryRun || ctx.dryRun) {
|
|
169
247
|
if (args.json) {
|
|
170
248
|
console.log(JSON.stringify({
|
|
171
249
|
status: "dry-run",
|
|
172
250
|
routine: args.routine,
|
|
173
|
-
|
|
251
|
+
specialist: specialistSlug,
|
|
252
|
+
specialist_source: roleResolved.source,
|
|
174
253
|
fsm_stage: fsmStage,
|
|
175
254
|
task,
|
|
176
255
|
prompt,
|
|
177
256
|
}, null, 2));
|
|
178
257
|
} else {
|
|
179
|
-
console.error(`# ship: dry-run
|
|
258
|
+
console.error(`# ship: dry-run handle=${runHandle} specialist=${specialistSlug} (${roleResolved.source}) fsm_stage=${fsmStage || "(context-free)"}`);
|
|
180
259
|
if (task) {
|
|
181
260
|
console.error(`# ship: task ticket_ref=${task.ticket_ref} title=${JSON.stringify(task.title || "")}`);
|
|
182
261
|
} else {
|
|
@@ -189,8 +268,8 @@ export async function runCommand(ctx, rest) {
|
|
|
189
268
|
}
|
|
190
269
|
|
|
191
270
|
// 4) Launch agent runtime
|
|
192
|
-
const provider = resolveProvider(config, args.routine);
|
|
193
|
-
const branchName = makeBranchName(
|
|
271
|
+
const provider = resolveProvider(config, args.routine || specialistSlug);
|
|
272
|
+
const branchName = makeBranchName(runHandle, task?.ticket_ref);
|
|
194
273
|
const repoUrl = githubRepo ? `https://github.com/${githubRepo}` : null;
|
|
195
274
|
if (!repoUrl) die(EXIT_USAGE, "GITHUB_REPOSITORY env var is required to launch agent");
|
|
196
275
|
|
|
@@ -207,8 +286,9 @@ export async function runCommand(ctx, rest) {
|
|
|
207
286
|
emit(args, {
|
|
208
287
|
status: "error",
|
|
209
288
|
routine: args.routine,
|
|
210
|
-
|
|
289
|
+
specialist: specialistSlug,
|
|
211
290
|
run_id: runId,
|
|
291
|
+
run_handle: runHandle,
|
|
212
292
|
stage: "launch_agent",
|
|
213
293
|
error: err instanceof Error ? err.message : String(err),
|
|
214
294
|
});
|
|
@@ -223,13 +303,14 @@ export async function runCommand(ctx, rest) {
|
|
|
223
303
|
emit(args, {
|
|
224
304
|
status: "completed",
|
|
225
305
|
routine: args.routine,
|
|
226
|
-
|
|
306
|
+
specialist: specialistSlug,
|
|
227
307
|
fsm_stage: fsmStage,
|
|
228
308
|
ticket_ref: task?.ticket_ref || null,
|
|
229
309
|
agent_id: runtime.agentId,
|
|
230
310
|
branch: runtime.branchName,
|
|
231
311
|
cursor_status: runtime.status,
|
|
232
312
|
run_id: runId,
|
|
313
|
+
run_handle: runHandle,
|
|
233
314
|
});
|
|
234
315
|
process.exit(EXIT_OK);
|
|
235
316
|
}
|
|
@@ -256,49 +337,107 @@ function stripSlash(s) {
|
|
|
256
337
|
}
|
|
257
338
|
|
|
258
339
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Resolve an agent role through the workspace endpoint.
|
|
342
|
+
*
|
|
343
|
+
* Returns ``{slug, name, prompt, fsm_stage, source}`` (workspace row
|
|
344
|
+
* or Ship default), ``null`` for 404, throws on auth/network errors
|
|
345
|
+
* unless ``optional`` is set (then 404 *and* errors return ``null``
|
|
346
|
+
* with a one-line warning).
|
|
347
|
+
*/
|
|
348
|
+
async function resolveAgentRole({
|
|
349
|
+
apiBase,
|
|
350
|
+
apiToken,
|
|
351
|
+
workspaceId,
|
|
352
|
+
slug,
|
|
353
|
+
optional = false,
|
|
354
|
+
}) {
|
|
355
|
+
const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent-roles/${encodeURIComponent(slug)}/resolve`;
|
|
356
|
+
let res;
|
|
357
|
+
try {
|
|
358
|
+
res = await fetch(url, {
|
|
359
|
+
headers: {
|
|
360
|
+
Accept: "application/json",
|
|
361
|
+
Authorization: `Bearer ${apiToken}`,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (optional) {
|
|
366
|
+
console.error(
|
|
367
|
+
`warn: agent-role '${slug}' resolve failed (network): ${err instanceof Error ? err.message : err}`,
|
|
368
|
+
);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
265
372
|
}
|
|
266
|
-
|
|
373
|
+
if (res.status === 404) return null;
|
|
374
|
+
if (!res.ok) {
|
|
375
|
+
if (optional) {
|
|
376
|
+
console.error(
|
|
377
|
+
`warn: agent-role '${slug}' resolve returned ${res.status}; running without it`,
|
|
378
|
+
);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
throw new Error(
|
|
382
|
+
`agent-roles resolve ${res.status}: ${(await res.text()).slice(0, 300)}`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return res.json();
|
|
267
386
|
}
|
|
268
387
|
|
|
269
388
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Best-effort fetch of the workspace's policy preamble. Returns the
|
|
391
|
+
* markdown block to prepend, or ``null`` when there's nothing to
|
|
392
|
+
* inject (no policies, missing token / API base, network error,
|
|
393
|
+
* non-200 response). Never throws — a broken policies path mustn't
|
|
394
|
+
* break a routine run.
|
|
395
|
+
*
|
|
396
|
+
* Auth: workspace-membership token (``SHIP_API_TOKEN`` — same one
|
|
397
|
+
* the rest of ``run.mjs`` uses). The companion run-token endpoint
|
|
398
|
+
* at ``/v1/pipelines/runs/{run_id}/policies-preamble`` doesn't fit
|
|
399
|
+
* this flow because the CLI mints ``run_id`` locally; the
|
|
400
|
+
* workspace-scoped variant takes membership instead.
|
|
401
|
+
*/
|
|
402
|
+
async function fetchPoliciesPreamble({ apiBase, apiToken, workspaceId, role }) {
|
|
403
|
+
if (!apiBase || !apiToken || !workspaceId) return null;
|
|
404
|
+
const qs = role ? `?role=${encodeURIComponent(role)}` : "";
|
|
405
|
+
const url = `${apiBase}/v1/workspaces/${encodeURIComponent(workspaceId)}/policies/preamble${qs}`;
|
|
406
|
+
let res;
|
|
277
407
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
408
|
+
res = await fetch(url, {
|
|
409
|
+
headers: {
|
|
410
|
+
Accept: "application/json",
|
|
411
|
+
Authorization: `Bearer ${apiToken}`,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error(
|
|
416
|
+
`warn: policies preamble fetch failed (network): ${err instanceof Error ? err.message : err}`,
|
|
417
|
+
);
|
|
418
|
+
return null;
|
|
285
419
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
420
|
+
if (!res.ok) {
|
|
421
|
+
// 404 happens against older backends that don't have the
|
|
422
|
+
// workspace-scoped endpoint yet — silent skip there. Other
|
|
423
|
+
// statuses (401, 403, 500) get a one-line warning so operators
|
|
424
|
+
// notice misconfigurations without aborting the run.
|
|
425
|
+
if (res.status !== 404) {
|
|
426
|
+
console.error(
|
|
427
|
+
`warn: policies preamble fetch returned ${res.status}; running without preamble`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
let body;
|
|
433
|
+
try {
|
|
434
|
+
body = await res.json();
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
300
437
|
}
|
|
301
|
-
return
|
|
438
|
+
return typeof body?.preamble === "string" && body.preamble.trim()
|
|
439
|
+
? body.preamble
|
|
440
|
+
: null;
|
|
302
441
|
}
|
|
303
442
|
|
|
304
443
|
|
|
@@ -345,7 +484,27 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
|
|
|
345
484
|
out.push("");
|
|
346
485
|
}
|
|
347
486
|
out.push(expanded.trim());
|
|
487
|
+
out.push("");
|
|
488
|
+
out.push(renderLifecycleHooks());
|
|
348
489
|
if (task) {
|
|
490
|
+
// ELS-86: parent project context (Brief / WBS / Architecture /
|
|
491
|
+
// Test architecture / Tasks). The server lifts and caps it; we
|
|
492
|
+
// render it BEFORE the per-ticket block so the agent sees the
|
|
493
|
+
// surrounding plan first, then narrows to its own scope. Only
|
|
494
|
+
// present when the ticket is part of a decomposed project — the
|
|
495
|
+
// server returns ``project_context: null`` otherwise and we
|
|
496
|
+
// skip the block silently.
|
|
497
|
+
if (typeof task.project_context === "string" && task.project_context.trim()) {
|
|
498
|
+
out.push("");
|
|
499
|
+
out.push("## Project context");
|
|
500
|
+
out.push("");
|
|
501
|
+
out.push(
|
|
502
|
+
"_Excerpt of the parent project body. Read for surrounding plan;",
|
|
503
|
+
"your scope is the per-task block below, not the whole project._",
|
|
504
|
+
);
|
|
505
|
+
out.push("");
|
|
506
|
+
out.push(task.project_context.trim());
|
|
507
|
+
}
|
|
349
508
|
out.push("");
|
|
350
509
|
out.push("## Task");
|
|
351
510
|
out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
|
|
@@ -367,6 +526,33 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
|
|
|
367
526
|
}
|
|
368
527
|
|
|
369
528
|
|
|
529
|
+
function renderLifecycleHooks() {
|
|
530
|
+
// Phase 4: every run is bracketed by two auditable lifecycle hooks
|
|
531
|
+
// — knowledge-fetch first, knowledge-feedback last. We render them
|
|
532
|
+
// as explicit prompt instructions so they show up in the agent's
|
|
533
|
+
// tool-use log (the runner audits the log to flag runs that
|
|
534
|
+
// skipped them). Soft for now (no run-blocking enforcement) but
|
|
535
|
+
// the prompt is the canonical contract until the runner grows
|
|
536
|
+
// tool-stream interception.
|
|
537
|
+
return [
|
|
538
|
+
"## Lifecycle hooks (Phase 4)",
|
|
539
|
+
"",
|
|
540
|
+
"**First call — knowledge fetch.** Before any other tool call,",
|
|
541
|
+
`run \`shipctl knowledge fetch <bucket>\` for at least one bucket`,
|
|
542
|
+
"relevant to your role. The audit log flags runs whose first",
|
|
543
|
+
"tool call wasn't a knowledge fetch; do not skip this to save",
|
|
544
|
+
"tokens.",
|
|
545
|
+
"",
|
|
546
|
+
"**Last call — knowledge feedback.** Before calling the finish",
|
|
547
|
+
"endpoint, leave one-line learnings via the Ship knowledge",
|
|
548
|
+
"feedback channel (`shipctl feedback draft` → `feedback submit`)",
|
|
549
|
+
"if you discovered something a future run on this codebase would",
|
|
550
|
+
"want to know. Empty findings are a valid outcome — better no",
|
|
551
|
+
"feedback than fabricated polish.",
|
|
552
|
+
].join("\n");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
370
556
|
function renderExitProtocol(ctx) {
|
|
371
557
|
// Substitute the run-time values directly into the example so the
|
|
372
558
|
// agent doesn't have to figure out env var hookup. The token is
|
|
@@ -470,40 +656,21 @@ as a one-shot credential for this run.
|
|
|
470
656
|
}
|
|
471
657
|
|
|
472
658
|
|
|
473
|
-
/**
|
|
474
|
-
* Load a pattern body. Resolution order:
|
|
475
|
-
* 1) when running inside the Ship monorepo, read from
|
|
476
|
-
* ``artifacts/patterns/<id>/ARTIFACT.md`` on disk — fast and
|
|
477
|
-
* always reflects the working tree (good for dry-runs / local
|
|
478
|
-
* smoke tests before the server is rebuilt).
|
|
479
|
-
* 2) otherwise hit the server's ``POST /fetch``.
|
|
480
|
-
*/
|
|
481
|
-
async function loadPattern({ id, fetchBase, optional = false }) {
|
|
482
|
-
const shipRepo = resolveShipRepoRootForCatalog();
|
|
483
|
-
if (shipRepo) {
|
|
484
|
-
const file = readArtifactFile(shipRepo, "pattern", id);
|
|
485
|
-
if (file && typeof file.content === "string") return file.content;
|
|
486
|
-
}
|
|
487
|
-
try {
|
|
488
|
-
const { content } = await fetchArtifact(fetchBase, "pattern", id);
|
|
489
|
-
return content;
|
|
490
|
-
} catch (err) {
|
|
491
|
-
if (optional) {
|
|
492
|
-
console.error(`warn: failed to fetch pattern '${id}': ${err.message}`);
|
|
493
|
-
return "";
|
|
494
|
-
}
|
|
495
|
-
throw err;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
659
|
function makeBranchName(routine, ticketRef) {
|
|
501
660
|
const stamp = Date.now().toString(36);
|
|
661
|
+
// Sanitize ``routine`` too — for pipeline-pick runs ``runHandle`` is
|
|
662
|
+
// ``pipeline:<specialist>`` and the bare ``:`` is in git's reserved
|
|
663
|
+
// character set, which Cursor's ``/v0/agents`` validator rejects
|
|
664
|
+
// with HTTP 400 ("Invalid branch name. Branch names cannot start
|
|
665
|
+
// with '-', contain invalid characters (spaces, ~, ^, :, ?, *, [,
|
|
666
|
+
// ], \\, .., @{, //), end with '/', '.lock', or '.', or be named
|
|
667
|
+
// 'HEAD'."). Same regex as the ticketRef path.
|
|
668
|
+
const safeRoutine = String(routine).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
502
669
|
if (ticketRef) {
|
|
503
670
|
const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
504
|
-
return `cursor/ship-${
|
|
671
|
+
return `cursor/ship-${safeRoutine}-${safe}-${stamp}`;
|
|
505
672
|
}
|
|
506
|
-
return `cursor/ship-${
|
|
673
|
+
return `cursor/ship-${safeRoutine}-${stamp}`;
|
|
507
674
|
}
|
|
508
675
|
|
|
509
676
|
|
|
@@ -513,7 +680,14 @@ function makeBranchName(routine, ticketRef) {
|
|
|
513
680
|
|
|
514
681
|
|
|
515
682
|
function parseArgs(rest) {
|
|
516
|
-
const out = {
|
|
683
|
+
const out = {
|
|
684
|
+
routine: null,
|
|
685
|
+
specialist: null,
|
|
686
|
+
cwd: null,
|
|
687
|
+
json: false,
|
|
688
|
+
help: false,
|
|
689
|
+
dryRun: false,
|
|
690
|
+
};
|
|
517
691
|
const copy = [...rest];
|
|
518
692
|
while (copy.length) {
|
|
519
693
|
const a = copy[0];
|
|
@@ -521,6 +695,7 @@ function parseArgs(rest) {
|
|
|
521
695
|
if (a === "--json") { out.json = true; copy.shift(); continue; }
|
|
522
696
|
if (a === "--dry-run") { out.dryRun = true; copy.shift(); continue; }
|
|
523
697
|
if (a === "--routine" && copy[1] !== undefined) { out.routine = copy[1]; copy.splice(0, 2); continue; }
|
|
698
|
+
if (a === "--specialist" && copy[1] !== undefined) { out.specialist = copy[1]; copy.splice(0, 2); continue; }
|
|
524
699
|
if (a === "--cwd" && copy[1] !== undefined) { out.cwd = path.resolve(copy[1]); copy.splice(0, 2); continue; }
|
|
525
700
|
// Soft-ignore legacy flags that older trigger workflows still pass —
|
|
526
701
|
// the new pipeline doesn't need them and refusing would break repos
|
|
@@ -535,10 +710,14 @@ function parseArgs(rest) {
|
|
|
535
710
|
|
|
536
711
|
|
|
537
712
|
function printHelp() {
|
|
538
|
-
console.log(`shipctl run — execute one E14 routine end-to-end.
|
|
713
|
+
console.log(`shipctl run — execute one E14 routine or pipeline-pick specialist end-to-end.
|
|
539
714
|
|
|
540
715
|
Run
|
|
541
716
|
shipctl run --routine <id> [--json] [--cwd <dir>] [--dry-run]
|
|
717
|
+
shipctl run --specialist <slug> [--json] [--cwd <dir>] [--dry-run]
|
|
718
|
+
|
|
719
|
+
--routine id from process.routines in .ship/config.yml (cron-driven)
|
|
720
|
+
--specialist agent-role slug from the Ship registry (pipeline-pick fallback)
|
|
542
721
|
|
|
543
722
|
ENV
|
|
544
723
|
SHIP_API_BASE Ship server base URL (e.g. https://api.ship.elmundi.com)
|