@elmundi/ship-cli 0.15.3 → 0.16.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/bin/shipctl.mjs +12 -0
- package/lib/agent_api.mjs +192 -0
- package/lib/commands/inbox.mjs +171 -0
- package/lib/commands/project.mjs +152 -0
- package/lib/commands/run.mjs +32 -3
- package/lib/config/schema.mjs +5 -0
- package/lib/runtime/routines.mjs +5 -0
- package/package.json +1 -1
package/bin/shipctl.mjs
CHANGED
|
@@ -81,6 +81,18 @@ try {
|
|
|
81
81
|
process.exit(0);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
if (cmd === "inbox") {
|
|
85
|
+
const { inboxCommand } = await import("../lib/commands/inbox.mjs");
|
|
86
|
+
await inboxCommand(ctx, rest);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cmd === "project") {
|
|
91
|
+
const { projectCommand } = await import("../lib/commands/project.mjs");
|
|
92
|
+
await projectCommand(ctx, rest);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
if (cmd === "run") {
|
|
85
97
|
const { runCommand } = await import("../lib/commands/run.mjs");
|
|
86
98
|
await runCommand(ctx, rest);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared client helpers for ``shipctl`` commands that hit the
|
|
3
|
+
* agent-runs API (inbox / project / etc).
|
|
4
|
+
*
|
|
5
|
+
* Reuses the same auth model the existing ``shipctl knowledge`` flow
|
|
6
|
+
* established: bearer ``SHIP_API_TOKEN`` PAT, base URL resolved via
|
|
7
|
+
* the same precedence chain (flag → ``SHIP_WORKSPACE_API_BASE`` →
|
|
8
|
+
* ``SHIP_API_BASE`` → prod default), workspace id resolved via flag /
|
|
9
|
+
* ``SHIP_WORKSPACE_ID`` / sole-workspace lookup.
|
|
10
|
+
*
|
|
11
|
+
* Exit codes are uniform across commands so wrapper scripts can
|
|
12
|
+
* branch on them: 0 ok, 1 arg/config error, 2 auth error (401),
|
|
13
|
+
* 3 network/5xx.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PROD_BASE = "https://api.ship.elmundi.com";
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string|null|undefined} explicit
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveBaseUrl(explicit) {
|
|
24
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
25
|
+
const envWorkspace = process.env.SHIP_WORKSPACE_API_BASE;
|
|
26
|
+
if (envWorkspace) return envWorkspace.replace(/\/+$/, "");
|
|
27
|
+
const envGeneric = process.env.SHIP_API_BASE;
|
|
28
|
+
if (envGeneric) return envGeneric.replace(/\/+$/, "");
|
|
29
|
+
return PROD_BASE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export function requireToken() {
|
|
34
|
+
const token = process.env.SHIP_API_TOKEN || "";
|
|
35
|
+
if (!token) {
|
|
36
|
+
console.error(
|
|
37
|
+
"SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
|
|
38
|
+
);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return token;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} baseUrl
|
|
47
|
+
* @param {string} token
|
|
48
|
+
* @returns {Promise<string>}
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveSoleWorkspace(baseUrl, token) {
|
|
51
|
+
const rows = await apiRequest(baseUrl, "/v1/workspaces", "GET", token, null);
|
|
52
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
53
|
+
console.error("No workspaces visible to this token.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (rows.length > 1) {
|
|
57
|
+
const ids = rows.map((r) => `${r.id} (${r.name ?? "?"})`).join("\n ");
|
|
58
|
+
console.error(
|
|
59
|
+
`Token has access to more than one workspace; pass --workspace <id>.\n ${ids}`,
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
return String(rows[0].id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ workspace?: string|null, baseUrl?: string|null }} opts
|
|
69
|
+
* @param {{ baseUrl?: string|null, baseUrlSource?: string|null }} ctx
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveContext(opts, ctx) {
|
|
72
|
+
const baseUrl = resolveBaseUrl(
|
|
73
|
+
opts.baseUrl || (ctx?.baseUrlSource === "flag" ? ctx?.baseUrl : null),
|
|
74
|
+
);
|
|
75
|
+
const token = requireToken();
|
|
76
|
+
let workspaceId =
|
|
77
|
+
opts.workspace || (process.env.SHIP_WORKSPACE_ID || "").trim() || "";
|
|
78
|
+
if (!workspaceId) {
|
|
79
|
+
workspaceId = await resolveSoleWorkspace(baseUrl, token);
|
|
80
|
+
}
|
|
81
|
+
return { baseUrl, token, workspaceId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} baseUrl
|
|
87
|
+
* @param {string} path
|
|
88
|
+
* @param {"GET"|"POST"|"DELETE"} method
|
|
89
|
+
* @param {string} token
|
|
90
|
+
* @param {Record<string, unknown>|null} body
|
|
91
|
+
*/
|
|
92
|
+
export async function apiRequest(baseUrl, path, method, token, body) {
|
|
93
|
+
const url = `${baseUrl}${path}`;
|
|
94
|
+
let res;
|
|
95
|
+
try {
|
|
96
|
+
res = await fetch(url, {
|
|
97
|
+
method,
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
Accept: "application/json",
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
},
|
|
103
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(
|
|
107
|
+
`Network error calling ${url}: ${err instanceof Error ? err.message : err}`,
|
|
108
|
+
);
|
|
109
|
+
process.exit(3);
|
|
110
|
+
}
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
let data = null;
|
|
113
|
+
try {
|
|
114
|
+
data = text ? JSON.parse(text) : null;
|
|
115
|
+
} catch {
|
|
116
|
+
data = text;
|
|
117
|
+
}
|
|
118
|
+
if (res.ok) return data;
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
console.error(
|
|
121
|
+
`HTTP 401 on ${method} ${url} — SHIP_API_TOKEN is missing, expired, or lacks workspace access.`,
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
126
|
+
console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
|
|
127
|
+
process.exit(res.status >= 500 ? 3 : 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read a value from a flag (`--name`/`--name=value`). Returns true if
|
|
133
|
+
* consumed; mutates ``copy`` in place.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} copy
|
|
136
|
+
* @param {string} flag
|
|
137
|
+
* @param {Record<string, string|null>} out
|
|
138
|
+
* @param {string} key
|
|
139
|
+
*/
|
|
140
|
+
export function consumeStringFlag(copy, flag, out, key) {
|
|
141
|
+
if (copy[0] === flag && copy[1] !== undefined) {
|
|
142
|
+
copy.shift();
|
|
143
|
+
out[key] = String(copy.shift());
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
const p = `${flag}=`;
|
|
147
|
+
if (copy[0] && copy[0].startsWith(p)) {
|
|
148
|
+
out[key] = copy[0].slice(p.length);
|
|
149
|
+
copy.shift();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read a body / file argument. ``--body <inline>`` takes the next arg
|
|
158
|
+
* as the literal body; ``--body-file <path>`` reads the file content.
|
|
159
|
+
* Mutually exclusive — the second wins if both are given but we warn.
|
|
160
|
+
*
|
|
161
|
+
* @param {string[]} copy
|
|
162
|
+
* @param {Record<string, string|null>} out
|
|
163
|
+
*/
|
|
164
|
+
export function consumeBodyFlags(copy, out) {
|
|
165
|
+
if (consumeStringFlag(copy, "--body", out, "body")) return true;
|
|
166
|
+
if (consumeStringFlag(copy, "--body-file", out, "bodyFile")) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve ``--body`` vs ``--body-file`` into a single string. Reads
|
|
173
|
+
* stdin when ``--body-file=-`` so callers can pipe markdown.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ body?: string|null, bodyFile?: string|null }} out
|
|
176
|
+
*/
|
|
177
|
+
export async function readBodyFromOpts(out) {
|
|
178
|
+
if (out.body && out.bodyFile) {
|
|
179
|
+
console.error("Pass --body OR --body-file, not both.");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
if (out.bodyFile) {
|
|
183
|
+
const fs = await import("node:fs/promises");
|
|
184
|
+
if (out.bodyFile === "-") {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
187
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
188
|
+
}
|
|
189
|
+
return await fs.readFile(out.bodyFile, "utf8");
|
|
190
|
+
}
|
|
191
|
+
return out.body || "";
|
|
192
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl inbox` — write into the operator's mailbox from a routine.
|
|
3
|
+
*
|
|
4
|
+
* Routines like daily-retro / learning-capture / process-reviewer file
|
|
5
|
+
* a single ``type=report`` letter per run; the operator opens it in the
|
|
6
|
+
* mailbox preview and hits Acknowledge. SDLC pipelines occasionally
|
|
7
|
+
* file ``approval`` / ``improvement`` items.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
*
|
|
11
|
+
* shipctl inbox create
|
|
12
|
+
* --type <report|improvement|approval|exception>
|
|
13
|
+
* --title "Daily retro 2026-05-07"
|
|
14
|
+
* --body "<markdown>" | --body-file path/to/digest.md | --body-file -
|
|
15
|
+
* [--summary "<short list-row blurb>"]
|
|
16
|
+
* [--workspace <id>]
|
|
17
|
+
* [--ticket-ref <id>]
|
|
18
|
+
* [--json]
|
|
19
|
+
*
|
|
20
|
+
* Auth: bearer ``SHIP_API_TOKEN`` (mint at /settings).
|
|
21
|
+
*
|
|
22
|
+
* Exit codes:
|
|
23
|
+
* 0 inbox item created
|
|
24
|
+
* 1 arg / config / 4xx error
|
|
25
|
+
* 2 auth error (401)
|
|
26
|
+
* 3 network / 5xx error
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
apiRequest,
|
|
31
|
+
consumeBodyFlags,
|
|
32
|
+
consumeStringFlag,
|
|
33
|
+
readBodyFromOpts,
|
|
34
|
+
resolveContext,
|
|
35
|
+
} from "../agent_api.mjs";
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const VALID_TYPES = new Set([
|
|
39
|
+
"report",
|
|
40
|
+
"improvement",
|
|
41
|
+
"approval",
|
|
42
|
+
"exception",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export async function inboxCommand(ctx, rest) {
|
|
47
|
+
const [sub, ...args] = rest;
|
|
48
|
+
if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
|
|
49
|
+
printInboxHelp();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (sub === "create") {
|
|
53
|
+
await inboxCreateCommand(ctx, args);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.error(
|
|
57
|
+
`Unknown 'shipctl inbox' subcommand: ${sub}\nRun: shipctl inbox --help`,
|
|
58
|
+
);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
function printInboxHelp() {
|
|
64
|
+
console.log(`shipctl inbox — file letters in the operator's mailbox
|
|
65
|
+
|
|
66
|
+
SUBCOMMANDS
|
|
67
|
+
shipctl inbox create --type <kind> --title <s> --body <md>
|
|
68
|
+
[--summary <s>] [--ticket-ref <id>]
|
|
69
|
+
[--body-file <path|->] [--workspace <id>] [--json]
|
|
70
|
+
|
|
71
|
+
TYPES
|
|
72
|
+
report Read-only digest (daily retro, learning capture,
|
|
73
|
+
process review). Operator hits Acknowledge.
|
|
74
|
+
improvement Actionable suggestion. Operator accepts / dismisses.
|
|
75
|
+
approval Gated decision. Operator approves / rejects / dismisses.
|
|
76
|
+
exception Policy edge case. Operator acknowledges / dismisses.
|
|
77
|
+
|
|
78
|
+
ENV
|
|
79
|
+
SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
|
|
80
|
+
SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
|
|
81
|
+
SHIP_WORKSPACE_API_BASE Optional override for the control plane.
|
|
82
|
+
|
|
83
|
+
EXIT
|
|
84
|
+
0 letter filed
|
|
85
|
+
1 arg / config / 4xx
|
|
86
|
+
2 auth (401)
|
|
87
|
+
3 network / 5xx
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async function inboxCreateCommand(ctx, args) {
|
|
93
|
+
const opts = parseCreateArgs(args);
|
|
94
|
+
const body = (await readBodyFromOpts(opts)).trim();
|
|
95
|
+
if (!body) {
|
|
96
|
+
console.error("Pass --body <markdown> or --body-file <path|->");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
|
|
100
|
+
|
|
101
|
+
const result = await apiRequest(
|
|
102
|
+
baseUrl,
|
|
103
|
+
`/v1/workspaces/${encodeURIComponent(workspaceId)}/inbox/items`,
|
|
104
|
+
"POST",
|
|
105
|
+
token,
|
|
106
|
+
{
|
|
107
|
+
type: opts.type,
|
|
108
|
+
title: opts.title,
|
|
109
|
+
body,
|
|
110
|
+
summary: opts.summary || null,
|
|
111
|
+
ticket_ref: opts.ticketRef || null,
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (ctx?.json || opts.json) {
|
|
116
|
+
console.log(JSON.stringify(result, null, 2));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log(`Letter filed (${opts.type}): ${opts.title}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
function parseCreateArgs(args) {
|
|
124
|
+
const out = {
|
|
125
|
+
type: null,
|
|
126
|
+
title: null,
|
|
127
|
+
body: null,
|
|
128
|
+
bodyFile: null,
|
|
129
|
+
summary: null,
|
|
130
|
+
ticketRef: null,
|
|
131
|
+
workspace: null,
|
|
132
|
+
baseUrl: null,
|
|
133
|
+
json: false,
|
|
134
|
+
};
|
|
135
|
+
const copy = [...args];
|
|
136
|
+
while (copy.length) {
|
|
137
|
+
if (
|
|
138
|
+
consumeStringFlag(copy, "--type", out, "type") ||
|
|
139
|
+
consumeStringFlag(copy, "--title", out, "title") ||
|
|
140
|
+
consumeStringFlag(copy, "--summary", out, "summary") ||
|
|
141
|
+
consumeStringFlag(copy, "--ticket-ref", out, "ticketRef") ||
|
|
142
|
+
consumeStringFlag(copy, "--workspace", out, "workspace") ||
|
|
143
|
+
consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
|
|
144
|
+
consumeBodyFlags(copy, out)
|
|
145
|
+
) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (copy[0] === "--json") {
|
|
149
|
+
out.json = true;
|
|
150
|
+
copy.shift();
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (copy[0] === "--help" || copy[0] === "-h") {
|
|
154
|
+
printInboxHelp();
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
console.error(`Unknown flag: ${copy[0]}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (!out.type || !VALID_TYPES.has(out.type)) {
|
|
161
|
+
console.error(
|
|
162
|
+
`--type must be one of ${[...VALID_TYPES].join(" | ")}`,
|
|
163
|
+
);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
if (!out.title || !out.title.trim()) {
|
|
167
|
+
console.error("--title is required");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl project` — manage tracker projects from a routine.
|
|
3
|
+
*
|
|
4
|
+
* Today the only subcommand is ``find-or-create``: the reviewer
|
|
5
|
+
* routines (tech-reviewer, qa-reviewer, security-officer) call this
|
|
6
|
+
* once per run to resolve their dedicated holding-pen project ("Tech
|
|
7
|
+
* Debt", "QA Debt", "Security") on the workspace's bound tracker.
|
|
8
|
+
* First run creates; every subsequent run short-circuits on the
|
|
9
|
+
* case-insensitive name match — no list-then-create race.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* shipctl project find-or-create
|
|
14
|
+
* --name "Tech Debt"
|
|
15
|
+
* --body "<markdown body for the new project — only used on create>"
|
|
16
|
+
* [--description "<one-liner; ≤240 chars; only used on create>"]
|
|
17
|
+
* [--workspace <id>]
|
|
18
|
+
* [--json]
|
|
19
|
+
*
|
|
20
|
+
* Output (default): ``project_id\tname\tcreated`` on a single line so
|
|
21
|
+
* shell scripts can parse it. ``--json`` returns the full server
|
|
22
|
+
* response.
|
|
23
|
+
*
|
|
24
|
+
* Auth: bearer ``SHIP_API_TOKEN``.
|
|
25
|
+
*
|
|
26
|
+
* Exit codes:
|
|
27
|
+
* 0 project resolved (found or created)
|
|
28
|
+
* 1 arg / config / 4xx error
|
|
29
|
+
* 2 auth error (401)
|
|
30
|
+
* 3 network / 5xx error
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
apiRequest,
|
|
35
|
+
consumeBodyFlags,
|
|
36
|
+
consumeStringFlag,
|
|
37
|
+
readBodyFromOpts,
|
|
38
|
+
resolveContext,
|
|
39
|
+
} from "../agent_api.mjs";
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
export async function projectCommand(ctx, rest) {
|
|
43
|
+
const [sub, ...args] = rest;
|
|
44
|
+
if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
|
|
45
|
+
printProjectHelp();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (sub === "find-or-create") {
|
|
49
|
+
await projectFindOrCreateCommand(ctx, args);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.error(
|
|
53
|
+
`Unknown 'shipctl project' subcommand: ${sub}\nRun: shipctl project --help`,
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
function printProjectHelp() {
|
|
60
|
+
console.log(`shipctl project — manage tracker projects
|
|
61
|
+
|
|
62
|
+
SUBCOMMANDS
|
|
63
|
+
shipctl project find-or-create --name <s> --body <md>
|
|
64
|
+
[--description <s>] [--body-file <path|->]
|
|
65
|
+
[--workspace <id>] [--json]
|
|
66
|
+
|
|
67
|
+
ENV
|
|
68
|
+
SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
|
|
69
|
+
SHIP_WORKSPACE_ID Optional. Skips the /v1/workspaces lookup.
|
|
70
|
+
SHIP_WORKSPACE_API_BASE Optional override for the control plane.
|
|
71
|
+
|
|
72
|
+
EXIT
|
|
73
|
+
0 project resolved
|
|
74
|
+
1 arg / config / 4xx
|
|
75
|
+
2 auth (401)
|
|
76
|
+
3 network / 5xx
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async function projectFindOrCreateCommand(ctx, args) {
|
|
82
|
+
const opts = parseFindOrCreateArgs(args);
|
|
83
|
+
const body = (await readBodyFromOpts(opts)).trim();
|
|
84
|
+
if (!body) {
|
|
85
|
+
console.error("Pass --body <markdown> or --body-file <path|->");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const { baseUrl, token, workspaceId } = await resolveContext(opts, ctx);
|
|
89
|
+
|
|
90
|
+
const result = await apiRequest(
|
|
91
|
+
baseUrl,
|
|
92
|
+
`/v1/workspaces/${encodeURIComponent(workspaceId)}/projects/find-or-create`,
|
|
93
|
+
"POST",
|
|
94
|
+
token,
|
|
95
|
+
{
|
|
96
|
+
name: opts.name,
|
|
97
|
+
body,
|
|
98
|
+
description: opts.description || null,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (ctx?.json || opts.json) {
|
|
103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const project = result?.project ?? {};
|
|
107
|
+
const created = result?.created ? "created" : "existing";
|
|
108
|
+
console.log(
|
|
109
|
+
`${project.id ?? ""}\t${project.name ?? opts.name}\t${created}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
function parseFindOrCreateArgs(args) {
|
|
115
|
+
const out = {
|
|
116
|
+
name: null,
|
|
117
|
+
body: null,
|
|
118
|
+
bodyFile: null,
|
|
119
|
+
description: null,
|
|
120
|
+
workspace: null,
|
|
121
|
+
baseUrl: null,
|
|
122
|
+
json: false,
|
|
123
|
+
};
|
|
124
|
+
const copy = [...args];
|
|
125
|
+
while (copy.length) {
|
|
126
|
+
if (
|
|
127
|
+
consumeStringFlag(copy, "--name", out, "name") ||
|
|
128
|
+
consumeStringFlag(copy, "--description", out, "description") ||
|
|
129
|
+
consumeStringFlag(copy, "--workspace", out, "workspace") ||
|
|
130
|
+
consumeStringFlag(copy, "--base-url", out, "baseUrl") ||
|
|
131
|
+
consumeBodyFlags(copy, out)
|
|
132
|
+
) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (copy[0] === "--json") {
|
|
136
|
+
out.json = true;
|
|
137
|
+
copy.shift();
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (copy[0] === "--help" || copy[0] === "-h") {
|
|
141
|
+
printProjectHelp();
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
console.error(`Unknown flag: ${copy[0]}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
if (!out.name || !out.name.trim()) {
|
|
148
|
+
console.error("--name is required");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
package/lib/commands/run.mjs
CHANGED
|
@@ -158,7 +158,10 @@ export async function runCommand(ctx, rest) {
|
|
|
158
158
|
if (!roleResolved) {
|
|
159
159
|
die(EXIT_USAGE, `unknown agent role '${specialistSlug}' for this workspace`);
|
|
160
160
|
}
|
|
161
|
-
|
|
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;
|
|
162
165
|
const roleBody = roleResolved.prompt || "";
|
|
163
166
|
const systemBody = systemResolved?.prompt || "";
|
|
164
167
|
|
|
@@ -484,6 +487,24 @@ function renderPrompt({ patternBody, baseBody, role, routineSpec, task, fsmStage
|
|
|
484
487
|
out.push("");
|
|
485
488
|
out.push(renderLifecycleHooks());
|
|
486
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
|
+
}
|
|
487
508
|
out.push("");
|
|
488
509
|
out.push("## Task");
|
|
489
510
|
out.push(`- **Ticket:** \`${task.ticket_ref}\` (${task.kind})`);
|
|
@@ -637,11 +658,19 @@ as a one-shot credential for this run.
|
|
|
637
658
|
|
|
638
659
|
function makeBranchName(routine, ticketRef) {
|
|
639
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, "-");
|
|
640
669
|
if (ticketRef) {
|
|
641
670
|
const safe = String(ticketRef).replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
642
|
-
return `cursor/ship-${
|
|
671
|
+
return `cursor/ship-${safeRoutine}-${safe}-${stamp}`;
|
|
643
672
|
}
|
|
644
|
-
return `cursor/ship-${
|
|
673
|
+
return `cursor/ship-${safeRoutine}-${stamp}`;
|
|
645
674
|
}
|
|
646
675
|
|
|
647
676
|
|
package/lib/config/schema.mjs
CHANGED
|
@@ -558,6 +558,10 @@ function validateProcessRoutines(value, errors, warnings) {
|
|
|
558
558
|
"schedule",
|
|
559
559
|
"window",
|
|
560
560
|
"event",
|
|
561
|
+
// ``fsm_stage`` overrides the role's default stage so one role
|
|
562
|
+
// can drive multiple processes (BA serves both
|
|
563
|
+
// ``ba_requirements`` for SDLC and ``wbs`` for decomposition).
|
|
564
|
+
"fsm_stage",
|
|
561
565
|
]),
|
|
562
566
|
prefix,
|
|
563
567
|
warnings,
|
|
@@ -616,6 +620,7 @@ function validateProcessRoutines(value, errors, warnings) {
|
|
|
616
620
|
);
|
|
617
621
|
}
|
|
618
622
|
validateProcessAgentProfile(routine.agent_profile, `${prefix}.agent_profile`, errors);
|
|
623
|
+
requireOptionalString(routine.fsm_stage, `${prefix}.fsm_stage`, errors, { required: false });
|
|
619
624
|
validateRoutineTrigger(routine.trigger, `${prefix}.trigger`, errors);
|
|
620
625
|
if (routine.schedule !== undefined && routine.schedule !== null) {
|
|
621
626
|
if (typeof routine.schedule !== "string" && !isPlainObject(routine.schedule)) {
|
package/lib/runtime/routines.mjs
CHANGED
|
@@ -64,6 +64,11 @@ export function routineToExecutable(id, routine) {
|
|
|
64
64
|
idempotency: routine.idempotency || null,
|
|
65
65
|
prompt: stringOrNull(routine.prompt) || stringOrNull(routine.instructions),
|
|
66
66
|
agent_profile: stringOrNull(routine.agent_profile) || stringOrNull(routine.specialist?.agent_profile),
|
|
67
|
+
// Per-routine FSM stage override. When set, ``shipctl run`` uses
|
|
68
|
+
// it instead of the role's default ``fsm_stage`` — that's how a
|
|
69
|
+
// single role (e.g. ``ba``) serves both SDLC (``ba_requirements``)
|
|
70
|
+
// and decomposition (``wbs``) without per-process role clones.
|
|
71
|
+
fsm_stage: stringOrNull(routine.fsm_stage),
|
|
67
72
|
};
|
|
68
73
|
}
|
|
69
74
|
|