@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/sync.mjs
CHANGED
|
@@ -51,7 +51,7 @@ FLAGS
|
|
|
51
51
|
version. Use when intentionally bumping a pin.
|
|
52
52
|
--dry-run Print the resolution plan; do not write or fetch.
|
|
53
53
|
--lock After sync, materialise every pattern referenced
|
|
54
|
-
by the declared
|
|
54
|
+
by the declared routines and write
|
|
55
55
|
.ship/shipctl.lock.json (used by
|
|
56
56
|
'shipctl run --offline').
|
|
57
57
|
--json Emit a structured JSON summary on stdout.
|
package/lib/commands/trigger.mjs
CHANGED
|
@@ -1,43 +1,51 @@
|
|
|
1
1
|
import { readConfig } from "../config/io.mjs";
|
|
2
|
+
import { dueLanesFromRoutines, dueRoutines } from "../runtime/routines.mjs";
|
|
2
3
|
|
|
3
|
-
const VERSION = "
|
|
4
|
+
const VERSION = "v2";
|
|
4
5
|
|
|
5
6
|
export async function triggerCommand(ctx, rest) {
|
|
6
7
|
const opts = parseArgs(rest);
|
|
7
|
-
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
8
|
-
const token = requireToken();
|
|
9
|
-
let workspaceId = opts.workspace;
|
|
10
|
-
if (!workspaceId) workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
11
|
-
const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
|
|
12
8
|
const { config } = readConfig(opts.cwd || process.cwd());
|
|
9
|
+
const local = dueRoutines(config, { event: opts.event, now: opts.now ? new Date(opts.now) : new Date() });
|
|
10
|
+
let due = local.due;
|
|
13
11
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
12
|
+
const baseUrl = resolveBaseUrl(opts.baseUrl || explicitGlobalBaseUrl(ctx));
|
|
13
|
+
const token = process.env.SHIP_API_TOKEN || "";
|
|
14
|
+
let claimStatus = "skipped:no-token";
|
|
15
|
+
if (token && due.length > 0 && !opts.noClaim) {
|
|
16
|
+
let workspaceId = opts.workspace;
|
|
17
|
+
if (!workspaceId) workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
18
|
+
const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
|
|
19
|
+
const claimed = [];
|
|
20
|
+
for (const routine of due) {
|
|
21
|
+
const claim = await claimRoutine(baseUrl, token, workspaceId, repoId, opts.event, routine);
|
|
22
|
+
if (claim.status === "claimed" || claim.status === "unavailable") {
|
|
23
|
+
claimed.push({ ...routine, claim_status: claim.status });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
due = claimed;
|
|
27
|
+
claimStatus = "attempted";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = {
|
|
31
|
+
event: opts.event,
|
|
32
|
+
status: due.length ? "due" : "noop",
|
|
33
|
+
due_routines: due,
|
|
34
|
+
due_lanes: dueLanesFromRoutines(due),
|
|
35
|
+
skipped_routines: local.skipped,
|
|
36
|
+
claim_status: claimStatus,
|
|
37
|
+
};
|
|
29
38
|
|
|
30
39
|
if (ctx.json || opts.json) {
|
|
31
40
|
console.log(JSON.stringify(result, null, 2));
|
|
32
41
|
return;
|
|
33
42
|
}
|
|
34
|
-
const due = Array.isArray(result.due_lanes) ? result.due_lanes : [];
|
|
35
43
|
if (!due.length) {
|
|
36
|
-
console.log(`Ship trigger ${opts.event}: no
|
|
44
|
+
console.log(`Ship trigger ${opts.event}: no routines due.`);
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
39
|
-
console.log(`Ship trigger ${opts.event}: ${due.length}
|
|
40
|
-
for (const
|
|
47
|
+
console.log(`Ship trigger ${opts.event}: ${due.length} routine(s) due`);
|
|
48
|
+
for (const routine of due) console.log(` - ${routine.routine_id}`);
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function explicitGlobalBaseUrl(ctx) {
|
|
@@ -45,13 +53,13 @@ function explicitGlobalBaseUrl(ctx) {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
function printHelp() {
|
|
48
|
-
console.log(`shipctl trigger —
|
|
56
|
+
console.log(`shipctl trigger — compute which routines are due (${VERSION})
|
|
49
57
|
|
|
50
58
|
USAGE
|
|
51
|
-
shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
|
|
59
|
+
shipctl trigger --event schedule [--repo <id|owner/name>] [--workspace <id>] [--json]
|
|
52
60
|
|
|
53
61
|
ENV
|
|
54
|
-
SHIP_API_TOKEN
|
|
62
|
+
SHIP_API_TOKEN Optional. When set, due routines are claimed in Ship.
|
|
55
63
|
SHIP_WORKSPACE_API_BASE Optional API base override.
|
|
56
64
|
SHIP_API_BASE Fallback API base override.
|
|
57
65
|
`);
|
|
@@ -64,6 +72,8 @@ function parseArgs(args) {
|
|
|
64
72
|
repo: null,
|
|
65
73
|
baseUrl: null,
|
|
66
74
|
cwd: null,
|
|
75
|
+
now: null,
|
|
76
|
+
noClaim: false,
|
|
67
77
|
json: false,
|
|
68
78
|
};
|
|
69
79
|
const copy = [...args];
|
|
@@ -87,10 +97,16 @@ function parseArgs(args) {
|
|
|
87
97
|
consume("--workspace", "workspace") ||
|
|
88
98
|
consume("--repo", "repo") ||
|
|
89
99
|
consume("--base-url", "baseUrl") ||
|
|
90
|
-
consume("--cwd", "cwd")
|
|
100
|
+
consume("--cwd", "cwd") ||
|
|
101
|
+
consume("--now", "now")
|
|
91
102
|
) {
|
|
92
103
|
continue;
|
|
93
104
|
}
|
|
105
|
+
if (copy[0] === "--no-claim") {
|
|
106
|
+
out.noClaim = true;
|
|
107
|
+
copy.shift();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
94
110
|
if (copy[0] === "--json") {
|
|
95
111
|
out.json = true;
|
|
96
112
|
copy.shift();
|
|
@@ -114,15 +130,6 @@ function parseArgs(args) {
|
|
|
114
130
|
return out;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
function requireToken() {
|
|
118
|
-
const token = process.env.SHIP_API_TOKEN || "";
|
|
119
|
-
if (!token) {
|
|
120
|
-
console.error("SHIP_API_TOKEN is required.");
|
|
121
|
-
process.exit(1);
|
|
122
|
-
}
|
|
123
|
-
return token;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
133
|
function resolveBaseUrl(explicit) {
|
|
127
134
|
if (explicit) return explicit.replace(/\/+$/, "");
|
|
128
135
|
if (process.env.SHIP_WORKSPACE_API_BASE) return process.env.SHIP_WORKSPACE_API_BASE.replace(/\/+$/, "");
|
|
@@ -169,6 +176,33 @@ async function apiPostJson(baseUrl, path, body, token) {
|
|
|
169
176
|
return apiRequest(baseUrl, path, "POST", token, body);
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
async function claimRoutine(baseUrl, token, workspaceId, repoId, event, routine) {
|
|
180
|
+
try {
|
|
181
|
+
return await apiPostJson(
|
|
182
|
+
baseUrl,
|
|
183
|
+
`/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/routine-runs/claim`,
|
|
184
|
+
{
|
|
185
|
+
event,
|
|
186
|
+
routine_id: routine.routine_id,
|
|
187
|
+
window_key: routine.window_key,
|
|
188
|
+
scheduled_for: routine.scheduled_for,
|
|
189
|
+
window_start: routine.window_start,
|
|
190
|
+
window_end: routine.window_end,
|
|
191
|
+
github: {
|
|
192
|
+
event_name: process.env.SHIP_EVENT_NAME || process.env.GITHUB_EVENT_NAME || "",
|
|
193
|
+
ref: process.env.SHIP_REF || process.env.GITHUB_REF || "",
|
|
194
|
+
sha: process.env.SHIP_SHA || process.env.GITHUB_SHA || "",
|
|
195
|
+
run_id: process.env.GITHUB_RUN_ID || "",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
token,
|
|
199
|
+
);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error(`warn: routine claim failed, running locally: ${err instanceof Error ? err.message : err}`);
|
|
202
|
+
return { status: "unavailable" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
172
206
|
async function apiRequest(baseUrl, path, method, token, body) {
|
|
173
207
|
const url = `${baseUrl}${path}`;
|
|
174
208
|
let res;
|
|
@@ -195,6 +229,5 @@ async function apiRequest(baseUrl, path, method, token, body) {
|
|
|
195
229
|
}
|
|
196
230
|
if (res.ok) return data;
|
|
197
231
|
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
198
|
-
|
|
199
|
-
process.exit(res.status >= 500 ? 3 : 1);
|
|
232
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
|
|
200
233
|
}
|
package/lib/config/schema.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export const LANE_EVENT_TYPES = Object.freeze([
|
|
|
33
33
|
export const LANE_IDEMPOTENCY_STORES = Object.freeze(["file", "backend"]);
|
|
34
34
|
export const LANE_IDEMPOTENCY_RESET_ON = Object.freeze(["version-change", "manual"]);
|
|
35
35
|
|
|
36
|
-
/* RFC-0008 C3.2 — fan-out strategy for multi-pattern
|
|
36
|
+
/* RFC-0008 C3.2 — fan-out strategy for multi-pattern routines.
|
|
37
37
|
*
|
|
38
38
|
* matrix — GitHub Actions matrix: one runner per pattern, parallel.
|
|
39
39
|
* sequential — Single runner, `shipctl run` iterates patterns in order.
|
|
@@ -49,6 +49,18 @@ export const LANE_FANOUT_MODES = Object.freeze([
|
|
|
49
49
|
]);
|
|
50
50
|
export const LANE_FANOUT_DEFAULT = "matrix";
|
|
51
51
|
|
|
52
|
+
export const PROCESS_AGENT_PROFILES = Object.freeze([
|
|
53
|
+
"auto",
|
|
54
|
+
"main",
|
|
55
|
+
"cheaper",
|
|
56
|
+
"cursor_agent",
|
|
57
|
+
"codex_cli",
|
|
58
|
+
"ship_cloud_agent",
|
|
59
|
+
"local_cli",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export const PROCESS_TRIGGER_TYPES = Object.freeze(["manual", "event", "schedule"]);
|
|
63
|
+
|
|
52
64
|
/* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
|
|
53
65
|
* file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
|
|
54
66
|
* restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
|
|
@@ -198,14 +210,14 @@ export function DEFAULT_PROCESS_CONFIG() {
|
|
|
198
210
|
{ from: "dev_implementation", to: "qa_manual" },
|
|
199
211
|
{ from: "qa_manual", to: "pr_review" },
|
|
200
212
|
],
|
|
201
|
-
routines:
|
|
213
|
+
routines: {},
|
|
202
214
|
};
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
/**
|
|
206
218
|
* Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
|
|
207
219
|
* return the current schema's shape; default to v2 so new installs
|
|
208
|
-
* benefit from
|
|
220
|
+
* benefit from process routines.
|
|
209
221
|
*/
|
|
210
222
|
export function DEFAULT_CONFIG() {
|
|
211
223
|
return DEFAULT_CONFIG_V2();
|
|
@@ -346,7 +358,7 @@ function validateV2Process(obj, errors, warnings) {
|
|
|
346
358
|
}
|
|
347
359
|
pushUnknownKeyWarnings(
|
|
348
360
|
state,
|
|
349
|
-
new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
|
|
361
|
+
new Set(["id", "name", "specialist", "agent_profile", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
|
|
350
362
|
prefix,
|
|
351
363
|
warnings,
|
|
352
364
|
);
|
|
@@ -363,9 +375,33 @@ function validateV2Process(obj, errors, warnings) {
|
|
|
363
375
|
if (state.instructions !== undefined && typeof state.instructions !== "string") {
|
|
364
376
|
errors.push(`${prefix}.instructions: must be a string when set`);
|
|
365
377
|
}
|
|
366
|
-
if (state.specialist !== undefined
|
|
367
|
-
|
|
378
|
+
if (state.specialist !== undefined) {
|
|
379
|
+
if (typeof state.specialist === "string") {
|
|
380
|
+
if (!state.specialist.trim()) {
|
|
381
|
+
errors.push(`${prefix}.specialist: must be a non-empty string when set`);
|
|
382
|
+
}
|
|
383
|
+
} else if (!isPlainObject(state.specialist)) {
|
|
384
|
+
errors.push(`${prefix}.specialist: must be an object or string when set`);
|
|
385
|
+
} else {
|
|
386
|
+
pushUnknownKeyWarnings(
|
|
387
|
+
state.specialist,
|
|
388
|
+
new Set(["id", "name", "agent_profile"]),
|
|
389
|
+
`${prefix}.specialist`,
|
|
390
|
+
warnings,
|
|
391
|
+
);
|
|
392
|
+
requireOptionalString(state.specialist.id, `${prefix}.specialist.id`, errors, { required: false });
|
|
393
|
+
requireOptionalString(state.specialist.name, `${prefix}.specialist.name`, errors, { required: false });
|
|
394
|
+
validateProcessAgentProfile(
|
|
395
|
+
state.specialist.agent_profile,
|
|
396
|
+
`${prefix}.specialist.agent_profile`,
|
|
397
|
+
errors,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
368
400
|
}
|
|
401
|
+
validateProcessAgentProfile(state.agent_profile, `${prefix}.agent_profile`, errors);
|
|
402
|
+
validateProcessTriggers(state.triggers, `${prefix}.triggers`, errors);
|
|
403
|
+
validateProcessConditions(state.exit_conditions, `${prefix}.exit_conditions`, errors);
|
|
404
|
+
validateProcessConditions(state.block_conditions, `${prefix}.block_conditions`, errors);
|
|
369
405
|
if (state.layout !== undefined) {
|
|
370
406
|
if (!isPlainObject(state.layout)) {
|
|
371
407
|
errors.push(`${prefix}.layout: must be an object when set`);
|
|
@@ -399,12 +435,162 @@ function validateV2Process(obj, errors, warnings) {
|
|
|
399
435
|
if (!stateIds.has(transition.to)) {
|
|
400
436
|
errors.push(`${prefix}.to: must reference an existing state id`);
|
|
401
437
|
}
|
|
438
|
+
requireOptionalString(transition.condition, `${prefix}.condition`, errors, { required: false });
|
|
402
439
|
}
|
|
403
440
|
}
|
|
404
441
|
}
|
|
405
442
|
|
|
406
|
-
|
|
407
|
-
|
|
443
|
+
validateProcessRoutines(process.routines, errors, warnings);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function validateProcessAgentProfile(value, prefix, errors) {
|
|
447
|
+
if (value === undefined || value === null) return;
|
|
448
|
+
if (typeof value !== "string" || !PROCESS_AGENT_PROFILES.includes(value)) {
|
|
449
|
+
errors.push(`${prefix}: must be one of ${PROCESS_AGENT_PROFILES.join("|")}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function validateProcessTriggers(value, prefix, errors) {
|
|
454
|
+
if (value === undefined) return;
|
|
455
|
+
if (!Array.isArray(value)) {
|
|
456
|
+
errors.push(`${prefix}: must be a list when set`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
460
|
+
const trigger = value[i];
|
|
461
|
+
const itemPrefix = `${prefix}[${i}]`;
|
|
462
|
+
if (!isPlainObject(trigger)) {
|
|
463
|
+
errors.push(`${itemPrefix}: must be an object`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!PROCESS_TRIGGER_TYPES.includes(trigger.type)) {
|
|
467
|
+
errors.push(`${itemPrefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
|
|
468
|
+
}
|
|
469
|
+
if (trigger.type === "schedule") {
|
|
470
|
+
requireOptionalString(trigger.interval, `${itemPrefix}.interval`, errors, { required: true });
|
|
471
|
+
} else if (trigger.type === "event") {
|
|
472
|
+
requireOptionalString(trigger.event, `${itemPrefix}.event`, errors, { required: true });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function validateProcessConditions(value, prefix, errors) {
|
|
478
|
+
if (value === undefined) return;
|
|
479
|
+
if (!Array.isArray(value)) {
|
|
480
|
+
errors.push(`${prefix}: must be a list when set`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
484
|
+
const condition = value[i];
|
|
485
|
+
const itemPrefix = `${prefix}[${i}]`;
|
|
486
|
+
if (!isPlainObject(condition)) {
|
|
487
|
+
errors.push(`${itemPrefix}: must be an object`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
requireOptionalString(condition.expression, `${itemPrefix}.expression`, errors, { required: true });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function validateProcessRoutines(value, errors, warnings) {
|
|
495
|
+
if (value === undefined) return;
|
|
496
|
+
const entries = Array.isArray(value)
|
|
497
|
+
? value.map((routine, i) => [String(i), routine, `process.routines[${i}]`])
|
|
498
|
+
: isPlainObject(value)
|
|
499
|
+
? Object.entries(value).map(([id, routine]) => [id, routine, `process.routines.${id}`])
|
|
500
|
+
: null;
|
|
501
|
+
if (!entries) {
|
|
502
|
+
errors.push("process.routines: must be a map or list when set");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (const [routineId, routine, prefix] of entries) {
|
|
506
|
+
if (!isPlainObject(routine)) {
|
|
507
|
+
errors.push(`${prefix}: must be an object`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
pushUnknownKeyWarnings(
|
|
511
|
+
routine,
|
|
512
|
+
new Set([
|
|
513
|
+
"id",
|
|
514
|
+
"name",
|
|
515
|
+
"cadence",
|
|
516
|
+
"enabled",
|
|
517
|
+
"trigger",
|
|
518
|
+
"description",
|
|
519
|
+
"prompt",
|
|
520
|
+
"instructions",
|
|
521
|
+
"pattern",
|
|
522
|
+
"patterns",
|
|
523
|
+
"pattern_version",
|
|
524
|
+
"fanout",
|
|
525
|
+
"idempotency",
|
|
526
|
+
"agent_profile",
|
|
527
|
+
"specialist_id",
|
|
528
|
+
"specialist_name",
|
|
529
|
+
"specialist",
|
|
530
|
+
"schedule",
|
|
531
|
+
"window",
|
|
532
|
+
"event",
|
|
533
|
+
]),
|
|
534
|
+
prefix,
|
|
535
|
+
warnings,
|
|
536
|
+
);
|
|
537
|
+
if (Array.isArray(value)) {
|
|
538
|
+
requireOptionalString(routine.id, `${prefix}.id`, errors, { required: true });
|
|
539
|
+
} else if (!LANE_ID_REGEX.test(routineId)) {
|
|
540
|
+
errors.push(`${prefix}: invalid routine id`);
|
|
541
|
+
}
|
|
542
|
+
requireOptionalString(routine.name, `${prefix}.name`, errors, { required: true });
|
|
543
|
+
requireOptionalString(routine.cadence, `${prefix}.cadence`, errors, { required: false });
|
|
544
|
+
if (routine.enabled !== undefined && routine.enabled !== null && typeof routine.enabled !== "boolean") {
|
|
545
|
+
errors.push(`${prefix}.enabled: must be a boolean when set`);
|
|
546
|
+
}
|
|
547
|
+
requireOptionalString(routine.description, `${prefix}.description`, errors, { required: false });
|
|
548
|
+
requireOptionalString(routine.instructions, `${prefix}.instructions`, errors, { required: false });
|
|
549
|
+
requireOptionalString(routine.prompt, `${prefix}.prompt`, errors, { required: false });
|
|
550
|
+
requireOptionalString(routine.pattern, `${prefix}.pattern`, errors, { required: false });
|
|
551
|
+
if (routine.patterns !== undefined) {
|
|
552
|
+
if (!Array.isArray(routine.patterns) || routine.patterns.some((p) => typeof p !== "string" || !p.trim())) {
|
|
553
|
+
errors.push(`${prefix}.patterns: must be a list of non-empty strings`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (routine.fanout !== undefined && !LANE_FANOUT_MODES.includes(routine.fanout)) {
|
|
557
|
+
errors.push(`${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}`);
|
|
558
|
+
}
|
|
559
|
+
requireOptionalString(routine.specialist_id, `${prefix}.specialist_id`, errors, { required: false });
|
|
560
|
+
requireOptionalString(routine.specialist_name, `${prefix}.specialist_name`, errors, { required: false });
|
|
561
|
+
validateProcessAgentProfile(routine.agent_profile, `${prefix}.agent_profile`, errors);
|
|
562
|
+
validateRoutineTrigger(routine.trigger, `${prefix}.trigger`, errors);
|
|
563
|
+
if (routine.schedule !== undefined && routine.schedule !== null) {
|
|
564
|
+
if (typeof routine.schedule !== "string" && !isPlainObject(routine.schedule)) {
|
|
565
|
+
errors.push(`${prefix}.schedule: must be an object or cron string when set`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function validateRoutineTrigger(value, prefix, errors) {
|
|
572
|
+
if (value === undefined || value === null) return;
|
|
573
|
+
if (!isPlainObject(value)) {
|
|
574
|
+
errors.push(`${prefix}: must be an object when set`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (!PROCESS_TRIGGER_TYPES.includes(value.type)) {
|
|
578
|
+
errors.push(`${prefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
|
|
579
|
+
}
|
|
580
|
+
if (value.type === "schedule") {
|
|
581
|
+
requireOptionalString(value.cron ?? value.interval, `${prefix}.cron`, errors, { required: true });
|
|
582
|
+
}
|
|
583
|
+
if (value.type === "event") {
|
|
584
|
+
requireOptionalString(value.event, `${prefix}.event`, errors, { required: true });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function requireOptionalString(value, prefix, errors, { required }) {
|
|
589
|
+
if ((value === undefined || value === null) && !required) return;
|
|
590
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
591
|
+
errors.push(
|
|
592
|
+
`${prefix}: must be ${required ? "a non-empty string" : "null or a non-empty string"}`,
|
|
593
|
+
);
|
|
408
594
|
}
|
|
409
595
|
}
|
|
410
596
|
|
|
@@ -544,8 +730,8 @@ function validateLane(laneId, lane, errors, warnings) {
|
|
|
544
730
|
) {
|
|
545
731
|
errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
|
|
546
732
|
}
|
|
547
|
-
// RFC-0008 C3.2 — `fanout` picks how multi-pattern
|
|
548
|
-
// Single-pattern
|
|
733
|
+
// RFC-0008 C3.2 — `fanout` picks how multi-pattern routines execute.
|
|
734
|
+
// Single-pattern routines ignore it (it's a no-op for them); we emit a
|
|
549
735
|
// warning rather than an error so schedule templates that set it
|
|
550
736
|
// blindly remain portable across single/multi-pattern use.
|
|
551
737
|
if (lane.fanout !== undefined) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export const SPECIALIST_PROMPT_GUARDRAILS = `# Ship specialist execution guardrails
|
|
2
|
+
|
|
3
|
+
These rules apply to specialist prompts assembled from process configuration,
|
|
4
|
+
ticket context, workspace policies, and knowledge buckets.
|
|
5
|
+
|
|
6
|
+
## Knowledge First
|
|
7
|
+
|
|
8
|
+
Before inventing a solution or procedure, search Ship knowledge for relevant
|
|
9
|
+
recipes, patterns, policies, and technical context. Use repository-specific
|
|
10
|
+
knowledge first when a repo is known, then workspace knowledge. If no relevant
|
|
11
|
+
article is found, say that explicitly before proposing a new approach.
|
|
12
|
+
|
|
13
|
+
Knowledge articles can also answer clarifying technical questions when the
|
|
14
|
+
ticket lacks implementation detail.
|
|
15
|
+
|
|
16
|
+
## Allowed Exits
|
|
17
|
+
|
|
18
|
+
A specialist cycle may end only through a Ship-controlled outcome:
|
|
19
|
+
|
|
20
|
+
- Ask for clarification: return a clarification intent for Ship to post as a
|
|
21
|
+
tracker comment and mirror into Inbox.
|
|
22
|
+
- Handoff: request one of the transitions explicitly configured in the process
|
|
23
|
+
FSM. Ship validates the transition before any side effect.
|
|
24
|
+
- Complete with result: produce the final result or PR reference for Ship to
|
|
25
|
+
record. Repository changes must be delivered through pull requests only.
|
|
26
|
+
|
|
27
|
+
## Boundaries
|
|
28
|
+
|
|
29
|
+
Do not perform direct ticket-system mutations. Do not execute transitions that
|
|
30
|
+
are not declared in the process configuration. Do not push directly to protected
|
|
31
|
+
branches or bypass review. All material actions must be represented in Ship
|
|
32
|
+
audit data. Workspace policies are mandatory and override recipe guidance.`;
|
|
33
|
+
|
|
34
|
+
export function renderSpecialistPromptGuardrails() {
|
|
35
|
+
return `${SPECIALIST_PROMPT_GUARDRAILS.trim()}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSpecialistPromptBundle({
|
|
39
|
+
process,
|
|
40
|
+
state,
|
|
41
|
+
allowedTransitions = [],
|
|
42
|
+
ticket = null,
|
|
43
|
+
policies = null,
|
|
44
|
+
}) {
|
|
45
|
+
const specialist = normalizeSpecialist(state.specialist);
|
|
46
|
+
const agentProfile =
|
|
47
|
+
state.agent_profile || specialist.agent_profile || "auto";
|
|
48
|
+
return {
|
|
49
|
+
process: {
|
|
50
|
+
id: process.id,
|
|
51
|
+
name: process.name || process.id,
|
|
52
|
+
primary: process.primary === true,
|
|
53
|
+
},
|
|
54
|
+
state: {
|
|
55
|
+
id: state.id,
|
|
56
|
+
name: state.name || state.id,
|
|
57
|
+
instructions: typeof state.instructions === "string" ? state.instructions : "",
|
|
58
|
+
triggers: Array.isArray(state.triggers) ? state.triggers : [],
|
|
59
|
+
exit_conditions: Array.isArray(state.exit_conditions) ? state.exit_conditions : [],
|
|
60
|
+
block_conditions: Array.isArray(state.block_conditions) ? state.block_conditions : [],
|
|
61
|
+
},
|
|
62
|
+
specialist,
|
|
63
|
+
agent_profile: agentProfile,
|
|
64
|
+
ticket,
|
|
65
|
+
policies,
|
|
66
|
+
allowed_transitions: allowedTransitions.map((transition) => ({
|
|
67
|
+
from: transition.from,
|
|
68
|
+
to: transition.to,
|
|
69
|
+
condition: transition.condition || null,
|
|
70
|
+
})),
|
|
71
|
+
guardrails: renderSpecialistPromptGuardrails(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderSpecialistPromptBundleMarkdown(bundle) {
|
|
76
|
+
const lines = [
|
|
77
|
+
"# Ship Specialist Prompt Bundle",
|
|
78
|
+
"",
|
|
79
|
+
"## Process",
|
|
80
|
+
"",
|
|
81
|
+
`- Process: ${bundle.process.name} (\`${bundle.process.id}\`)`,
|
|
82
|
+
`- Primary: ${bundle.process.primary ? "yes" : "no"}`,
|
|
83
|
+
`- State: ${bundle.state.name} (\`${bundle.state.id}\`)`,
|
|
84
|
+
"",
|
|
85
|
+
"## Specialist",
|
|
86
|
+
"",
|
|
87
|
+
`- Specialist: ${bundle.specialist.name} (\`${bundle.specialist.id}\`)`,
|
|
88
|
+
`- Agent profile: \`${bundle.agent_profile}\``,
|
|
89
|
+
"",
|
|
90
|
+
bundle.specialist.role || "No specialist role description configured.",
|
|
91
|
+
"",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (bundle.state.instructions) {
|
|
95
|
+
lines.push("## State Instructions", "", bundle.state.instructions, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
lines.push("## Ticket Context", "");
|
|
99
|
+
if (bundle.ticket) {
|
|
100
|
+
lines.push(...renderTicketLines(bundle.ticket), "");
|
|
101
|
+
} else {
|
|
102
|
+
lines.push("No ticket context was supplied. Use the Ship tracker picker before starting this specialist cycle.", "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push("## Workspace Policies", "");
|
|
106
|
+
if (bundle.policies && bundle.policies.trim()) {
|
|
107
|
+
lines.push(bundle.policies.trim(), "");
|
|
108
|
+
} else {
|
|
109
|
+
lines.push("Workspace policies were not supplied locally. In managed runs, Ship must inject enabled workspace policies before the agent starts.", "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push("## Allowed Transitions", "");
|
|
113
|
+
if (bundle.allowed_transitions.length) {
|
|
114
|
+
for (const transition of bundle.allowed_transitions) {
|
|
115
|
+
const condition = transition.condition ? ` when ${transition.condition}` : "";
|
|
116
|
+
lines.push(`- \`${transition.from}\` -> \`${transition.to}\`${condition}`);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
lines.push("- No outgoing transitions are configured for this state. The agent may only ask for clarification or complete with a result.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push(
|
|
123
|
+
"",
|
|
124
|
+
"## Required Guardrails",
|
|
125
|
+
"",
|
|
126
|
+
bundle.guardrails.trim(),
|
|
127
|
+
"",
|
|
128
|
+
);
|
|
129
|
+
return `${lines.join("\n").trim()}\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeSpecialist(value) {
|
|
133
|
+
if (typeof value === "string") {
|
|
134
|
+
return { id: value, name: value, role: "", agent_profile: null };
|
|
135
|
+
}
|
|
136
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
137
|
+
return {
|
|
138
|
+
id: String(value.id || value.name || "specialist"),
|
|
139
|
+
name: String(value.name || value.id || "Specialist"),
|
|
140
|
+
role: typeof value.role === "string" ? value.role : "",
|
|
141
|
+
agent_profile: typeof value.agent_profile === "string" ? value.agent_profile : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { id: "specialist", name: "Specialist", role: "", agent_profile: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderTicketLines(ticket) {
|
|
148
|
+
const lines = [];
|
|
149
|
+
for (const key of ["id", "key", "title", "url", "status", "description"]) {
|
|
150
|
+
const value = ticket[key];
|
|
151
|
+
if (typeof value === "string" && value.trim()) {
|
|
152
|
+
lines.push(`- ${titleCase(key)}: ${value.trim()}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const extra = Object.entries(ticket).filter(
|
|
156
|
+
([key, value]) =>
|
|
157
|
+
!["id", "key", "title", "url", "status", "description"].includes(key) &&
|
|
158
|
+
value != null &&
|
|
159
|
+
typeof value !== "object",
|
|
160
|
+
);
|
|
161
|
+
for (const [key, value] of extra) {
|
|
162
|
+
lines.push(`- ${titleCase(key)}: ${String(value)}`);
|
|
163
|
+
}
|
|
164
|
+
return lines.length ? lines : ["- Ticket context object was empty."];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function titleCase(value) {
|
|
168
|
+
return value
|
|
169
|
+
.replace(/[_-]+/g, " ")
|
|
170
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
171
|
+
}
|