@elmundi/ship-cli 0.14.2 → 0.15.3
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 +266 -116
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +68 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +34 -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/config/migrate.mjs
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `shipctl migrate` — convert `.ship/config.yml` from v1 to v2.
|
|
3
|
-
*
|
|
4
|
-
* v2 introduces the `lanes` map, `agent.default/overrides`, and deprecates
|
|
5
|
-
* the `workflow` artifact kind. The migration is deliberately conservative:
|
|
6
|
-
*
|
|
7
|
-
* - Every v1 field we still recognise is copied verbatim, not rewritten.
|
|
8
|
-
* - `stack.agent.provider` is lifted into `agent.default.provider`; we
|
|
9
|
-
* leave `stack.agent` intact so rolling back to an old shipctl still
|
|
10
|
-
* finds the field where v1 expected it.
|
|
11
|
-
* - The legacy `lanes:` list-of-strings (a shape customers wrote by
|
|
12
|
-
* hand between 0.9.x and 0.11.x — not a formal v1 field but widely
|
|
13
|
-
* present on disk) is translated into the v2 `lanes:` map using the
|
|
14
|
-
* preset defaults table below.
|
|
15
|
-
* - Unknown keys survive at the top level; v2 validation emits a
|
|
16
|
-
* warning but does not drop them.
|
|
17
|
-
*
|
|
18
|
-
* The migration is idempotent: running it against a v2 config returns
|
|
19
|
-
* the input untouched.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
CONFIG_SCHEMA_VERSION,
|
|
24
|
-
DEFAULT_PROCESS_CONFIG,
|
|
25
|
-
LEGACY_CONFIG_SCHEMA_VERSION,
|
|
26
|
-
} from "./schema.mjs";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Default lane translations for the well-known v1 `lanes:` list entries.
|
|
30
|
-
* Each entry maps the v1 string id to a v2 lane body. These were the
|
|
31
|
-
* four lane ids the `monorepo`, `web-app`, and `api-backend` presets
|
|
32
|
-
* bundled; anything outside this table is left for the caller to fill
|
|
33
|
-
* in manually (the migrator warns and adds a stub).
|
|
34
|
-
*
|
|
35
|
-
* Keep these aligned with `artifacts/collections/preset-*` and with
|
|
36
|
-
* RFC-0007 §"Lane-id reservations".
|
|
37
|
-
*/
|
|
38
|
-
const V1_LANE_DEFAULTS = Object.freeze({
|
|
39
|
-
pr_review: {
|
|
40
|
-
kind: "event",
|
|
41
|
-
pattern: "flow-pr-self-review",
|
|
42
|
-
on: "pull_request",
|
|
43
|
-
permissions: { contents: "read", "pull-requests": "write" },
|
|
44
|
-
},
|
|
45
|
-
daily_standup: {
|
|
46
|
-
kind: "schedule",
|
|
47
|
-
pattern: "flow-daily-retro",
|
|
48
|
-
cron: "0 9 * * 1-5",
|
|
49
|
-
},
|
|
50
|
-
tech_debt: {
|
|
51
|
-
kind: "schedule",
|
|
52
|
-
pattern: "flow-learning-capture",
|
|
53
|
-
cron: "0 10 * * 1",
|
|
54
|
-
},
|
|
55
|
-
self_heal: {
|
|
56
|
-
kind: "event",
|
|
57
|
-
pattern: "op-workflow-self-heal",
|
|
58
|
-
on: "workflow_run",
|
|
59
|
-
when: { conclusion: "failure" },
|
|
60
|
-
permissions: { contents: "read", actions: "read", "pull-requests": "write" },
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* @typedef {{
|
|
66
|
-
* migrated: boolean,
|
|
67
|
-
* config: object,
|
|
68
|
-
* warnings: string[],
|
|
69
|
-
* stub_lanes: string[],
|
|
70
|
-
* }} MigrationResult
|
|
71
|
-
*/
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Migrate a parsed config object from v1 to v2. Returns a fresh config
|
|
75
|
-
* object (input is not mutated) plus any non-fatal warnings.
|
|
76
|
-
*
|
|
77
|
-
* @param {object} input
|
|
78
|
-
* @returns {MigrationResult}
|
|
79
|
-
*/
|
|
80
|
-
export function migrateV1ToV2(input) {
|
|
81
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
82
|
-
throw new Error("migrate: config must be a mapping");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (input.version === CONFIG_SCHEMA_VERSION) {
|
|
86
|
-
return {
|
|
87
|
-
migrated: false,
|
|
88
|
-
config: input,
|
|
89
|
-
warnings: ["config already at v2; nothing to do"],
|
|
90
|
-
stub_lanes: [],
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
if (input.version !== LEGACY_CONFIG_SCHEMA_VERSION) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
`migrate: unsupported source version ${JSON.stringify(input.version)}; only v${LEGACY_CONFIG_SCHEMA_VERSION} is supported`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/* Deep-clone first — never touch the caller's object. YAML parse
|
|
100
|
-
* output is pure JSON so JSON round-trip is safe. */
|
|
101
|
-
const src = JSON.parse(JSON.stringify(input));
|
|
102
|
-
const warnings = [];
|
|
103
|
-
const stubLanes = [];
|
|
104
|
-
|
|
105
|
-
const out = {
|
|
106
|
-
version: CONFIG_SCHEMA_VERSION,
|
|
107
|
-
/* Bump the hard floor to the release that introduces v2. Anyone
|
|
108
|
-
* stuck on <0.12 will fail loudly on read instead of silently
|
|
109
|
-
* pretending v2 is v1. */
|
|
110
|
-
shipctl_min: bumpFloor(src.shipctl_min, "0.12.0"),
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/* api / stack / cache / telemetry / artifacts survive verbatim — v2
|
|
114
|
-
* only added new top-level siblings. Preserve unknown keys too so a
|
|
115
|
-
* future field added by a newer shipctl on the same config doesn't
|
|
116
|
-
* get eaten by an older migrator. */
|
|
117
|
-
for (const k of Object.keys(src)) {
|
|
118
|
-
if (k === "version" || k === "shipctl_min") continue;
|
|
119
|
-
if (k === "lanes") continue; /* handled below */
|
|
120
|
-
out[k] = src[k];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/* agent.default / agent.overrides */
|
|
124
|
-
out.agent = out.agent && typeof out.agent === "object" ? out.agent : {};
|
|
125
|
-
if (!out.agent.default || typeof out.agent.default !== "object") {
|
|
126
|
-
out.agent.default = { provider: null };
|
|
127
|
-
}
|
|
128
|
-
if (!out.agent.overrides || typeof out.agent.overrides !== "object") {
|
|
129
|
-
out.agent.overrides = {};
|
|
130
|
-
}
|
|
131
|
-
/* Lift stack.agent.provider into agent.default.provider if unset.
|
|
132
|
-
* We intentionally leave the original value in place so v1 readers
|
|
133
|
-
* keep working; v2 readers prefer agent.default.provider anyway. */
|
|
134
|
-
const liftedProvider = src.stack?.agent?.provider ?? null;
|
|
135
|
-
if (liftedProvider && !out.agent.default.provider) {
|
|
136
|
-
out.agent.default.provider = liftedProvider;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (!out.process || typeof out.process !== "object" || Array.isArray(out.process)) {
|
|
140
|
-
out.process = cloneDefault(DEFAULT_PROCESS_CONFIG());
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/* lanes: translate from the legacy list-of-strings shape. */
|
|
144
|
-
out.lanes = {};
|
|
145
|
-
const srcLanes = src.lanes;
|
|
146
|
-
if (Array.isArray(srcLanes)) {
|
|
147
|
-
for (const laneId of srcLanes) {
|
|
148
|
-
if (typeof laneId !== "string") {
|
|
149
|
-
warnings.push(`lanes: skipped non-string entry ${JSON.stringify(laneId)}`);
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const normalised = laneId.trim();
|
|
153
|
-
if (!normalised) continue;
|
|
154
|
-
const def = V1_LANE_DEFAULTS[normalised];
|
|
155
|
-
if (def) {
|
|
156
|
-
out.lanes[normalised] = cloneDefault(def);
|
|
157
|
-
} else {
|
|
158
|
-
/* Unknown v1 lane — emit a stub so the customer sees exactly
|
|
159
|
-
* which fields need attention on the next `shipctl doctor`. */
|
|
160
|
-
out.lanes[normalised] = {
|
|
161
|
-
kind: "schedule",
|
|
162
|
-
pattern: `TODO-pattern-for-${normalised}`,
|
|
163
|
-
cron: "TODO",
|
|
164
|
-
};
|
|
165
|
-
stubLanes.push(normalised);
|
|
166
|
-
warnings.push(
|
|
167
|
-
`lanes.${normalised}: no preset mapping; wrote a stub (fill in kind/pattern/cron before shipping)`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
} else if (srcLanes && typeof srcLanes === "object") {
|
|
172
|
-
/* Already a map (e.g. someone hand-edited partway). Copy as-is;
|
|
173
|
-
* the v2 validator will flag any malformed lanes on the next
|
|
174
|
-
* `shipctl doctor` or write. */
|
|
175
|
-
out.lanes = JSON.parse(JSON.stringify(srcLanes));
|
|
176
|
-
} else if (srcLanes !== undefined) {
|
|
177
|
-
warnings.push(
|
|
178
|
-
`lanes: unexpected v1 shape ${typeof srcLanes}; dropped. Add lanes manually or rerun 'shipctl init'.`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
migrated: true,
|
|
184
|
-
config: out,
|
|
185
|
-
warnings,
|
|
186
|
-
stub_lanes: stubLanes,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function cloneDefault(laneBody) {
|
|
191
|
-
return JSON.parse(JSON.stringify(laneBody));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Parse a semver-ish "X.Y.Z" and return the higher of the current floor
|
|
196
|
-
* and the minimum the caller wants. If `current` is not a string or
|
|
197
|
-
* doesn't parse, we fall back to the minimum unconditionally — an
|
|
198
|
-
* unreadable floor is no floor.
|
|
199
|
-
*
|
|
200
|
-
* @param {unknown} current
|
|
201
|
-
* @param {string} minimum
|
|
202
|
-
* @returns {string}
|
|
203
|
-
*/
|
|
204
|
-
function bumpFloor(current, minimum) {
|
|
205
|
-
if (typeof current !== "string") return minimum;
|
|
206
|
-
const cur = parseSemver(current);
|
|
207
|
-
const min = parseSemver(minimum);
|
|
208
|
-
if (!cur || !min) return minimum;
|
|
209
|
-
return compareSemver(cur, min) >= 0 ? current : minimum;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function parseSemver(s) {
|
|
213
|
-
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(s).trim());
|
|
214
|
-
if (!m) return null;
|
|
215
|
-
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function compareSemver(a, b) {
|
|
219
|
-
for (let i = 0; i < 3; i += 1) {
|
|
220
|
-
if (a[i] !== b[i]) return a[i] - b[i];
|
|
221
|
-
}
|
|
222
|
-
return 0;
|
|
223
|
-
}
|
package/lib/find-ship-root.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
const MARKER_DIRS = [
|
|
5
|
-
"artifacts/patterns",
|
|
6
|
-
"artifacts/tools",
|
|
7
|
-
"artifacts/collections",
|
|
8
|
-
];
|
|
9
|
-
|
|
10
|
-
function markersOk(dir) {
|
|
11
|
-
return MARKER_DIRS.every((rel) => {
|
|
12
|
-
const abs = path.join(dir, rel);
|
|
13
|
-
try {
|
|
14
|
-
return fs.statSync(abs).isDirectory();
|
|
15
|
-
} catch {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Walk parents from cwd for a directory containing the v2 artifacts/ tree.
|
|
23
|
-
* @returns {string | null}
|
|
24
|
-
*/
|
|
25
|
-
export function tryFindShipRepoRootFromWalk() {
|
|
26
|
-
let dir = path.resolve(process.cwd());
|
|
27
|
-
for (;;) {
|
|
28
|
-
if (markersOk(dir)) return dir;
|
|
29
|
-
const parent = path.dirname(dir);
|
|
30
|
-
if (parent === dir) break;
|
|
31
|
-
dir = parent;
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Root of the Ship monorepo (artifacts/<plural>/<id>/ARTIFACT.md present).
|
|
38
|
-
* Set `SHIP_REPO` to an absolute path when not running from inside the tree.
|
|
39
|
-
*/
|
|
40
|
-
export function findShipRepoRoot() {
|
|
41
|
-
const env = process.env.SHIP_REPO?.trim();
|
|
42
|
-
if (env) {
|
|
43
|
-
const r = path.resolve(env);
|
|
44
|
-
if (!markersOk(r)) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return r;
|
|
50
|
-
}
|
|
51
|
-
const walked = tryFindShipRepoRootFromWalk();
|
|
52
|
-
if (walked) return walked;
|
|
53
|
-
throw new Error(
|
|
54
|
-
"Ship repo root not found: run from inside the ship clone, or set SHIP_REPO to the repository root.",
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* When `SHIP_REPO` is unset, returns repo root only if cwd is inside the tree; otherwise `null` (use hosted catalog).
|
|
60
|
-
* When `SHIP_REPO` is set, validates and returns that path or throws.
|
|
61
|
-
* @returns {string | null}
|
|
62
|
-
*/
|
|
63
|
-
export function resolveShipRepoRootForCatalog() {
|
|
64
|
-
const env = process.env.SHIP_REPO?.trim();
|
|
65
|
-
if (env) {
|
|
66
|
-
const r = path.resolve(env);
|
|
67
|
-
if (!markersOk(r)) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
return r;
|
|
73
|
-
}
|
|
74
|
-
return tryFindShipRepoRootFromWalk();
|
|
75
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/state/lockfile.mjs
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `shipctl.lock.json` — reproducible-build anchor for Ship lanes (RFC-0007
|
|
3
|
-
* Phase 4). Records the exact `(kind, id, version, content_sha256)` of
|
|
4
|
-
* every artifact a lane depends on, plus where that body is materialised on
|
|
5
|
-
* disk. Without it, `shipctl run --offline` has no way to decide whether
|
|
6
|
-
* a cached pattern is "the one the lane expects" — the lockfile gives a
|
|
7
|
-
* positive, auditable answer.
|
|
8
|
-
*
|
|
9
|
-
* Only patterns are locked today (lanes only reference patterns via
|
|
10
|
-
* `lane.pattern`). The schema is forward-compatible: tools and collections
|
|
11
|
-
* can be added to the same `artifacts` map with no version bump.
|
|
12
|
-
*
|
|
13
|
-
* Concurrency: the writer does an atomic rename (write to `.tmp` in the
|
|
14
|
-
* same directory, rename over the target). Readers always open a read-only
|
|
15
|
-
* handle so a race doesn't yield a truncated parse.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import fs from "node:fs";
|
|
19
|
-
import path from "node:path";
|
|
20
|
-
|
|
21
|
-
import { artifactSha256 } from "../cache/store.mjs";
|
|
22
|
-
|
|
23
|
-
const LOCKFILE_REL = path.join(".ship", "shipctl.lock.json");
|
|
24
|
-
export const LOCKFILE_SCHEMA_VERSION = 1;
|
|
25
|
-
|
|
26
|
-
export function lockfilePath(shipRoot) {
|
|
27
|
-
return path.join(shipRoot, LOCKFILE_REL);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @typedef {Object} LockfileEntry
|
|
32
|
-
* @property {string} version Resolved version of the artifact.
|
|
33
|
-
* @property {string} content_sha256 Hex digest (RFC-0005 normalisation).
|
|
34
|
-
* @property {string} cached_path Relative path inside the ship root.
|
|
35
|
-
* @property {"http" | "monorepo" | "inline"} source
|
|
36
|
-
* @property {boolean} pinned Whether a config pin constrained this.
|
|
37
|
-
* @property {string} [channel] Manifest channel at time of lock.
|
|
38
|
-
*
|
|
39
|
-
* @typedef {Object} Lockfile
|
|
40
|
-
* @property {1} version
|
|
41
|
-
* @property {string} generated_at ISO-8601 UTC.
|
|
42
|
-
* @property {string} shipctl_version
|
|
43
|
-
* @property {{ base_url: string, channel: string } | null} source
|
|
44
|
-
* @property {Record<string, LockfileEntry>} artifacts Key: "<kind>/<id>".
|
|
45
|
-
* @property {string[]} [notes] Free-form operator hints.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @param {string} shipRoot
|
|
50
|
-
* @returns {Lockfile | null}
|
|
51
|
-
*/
|
|
52
|
-
export function readLockfile(shipRoot) {
|
|
53
|
-
const file = lockfilePath(shipRoot);
|
|
54
|
-
if (!fs.existsSync(file)) return null;
|
|
55
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
56
|
-
let parsed;
|
|
57
|
-
try {
|
|
58
|
-
parsed = JSON.parse(raw);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
throw new Error(`shipctl.lock.json: invalid JSON (${err instanceof Error ? err.message : err})`);
|
|
61
|
-
}
|
|
62
|
-
if (!parsed || typeof parsed !== "object") {
|
|
63
|
-
throw new Error("shipctl.lock.json: root must be an object");
|
|
64
|
-
}
|
|
65
|
-
if (parsed.version !== LOCKFILE_SCHEMA_VERSION) {
|
|
66
|
-
throw new Error(
|
|
67
|
-
`shipctl.lock.json: unsupported version ${parsed.version} (shipctl supports v${LOCKFILE_SCHEMA_VERSION}).`,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
if (!parsed.artifacts || typeof parsed.artifacts !== "object") {
|
|
71
|
-
throw new Error("shipctl.lock.json: missing `artifacts` map");
|
|
72
|
-
}
|
|
73
|
-
return parsed;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @param {string} shipRoot
|
|
78
|
-
* @param {Lockfile} data
|
|
79
|
-
*/
|
|
80
|
-
export function writeLockfile(shipRoot, data) {
|
|
81
|
-
const file = lockfilePath(shipRoot);
|
|
82
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
-
|
|
84
|
-
/* Sort the artifacts map so diffs stay minimal between runs. We can't
|
|
85
|
-
* rely on JSON object key order, but `JSON.stringify` honours insertion
|
|
86
|
-
* order on v8, and every modern engine we care about follows suit. */
|
|
87
|
-
const sorted = {};
|
|
88
|
-
const keys = Object.keys(data.artifacts || {}).sort();
|
|
89
|
-
for (const k of keys) sorted[k] = data.artifacts[k];
|
|
90
|
-
|
|
91
|
-
const normalised = {
|
|
92
|
-
version: LOCKFILE_SCHEMA_VERSION,
|
|
93
|
-
generated_at: data.generated_at,
|
|
94
|
-
shipctl_version: data.shipctl_version,
|
|
95
|
-
source: data.source || null,
|
|
96
|
-
artifacts: sorted,
|
|
97
|
-
notes: Array.isArray(data.notes) ? [...data.notes] : [],
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const tmp = `${file}.tmp`;
|
|
101
|
-
fs.writeFileSync(tmp, `${JSON.stringify(normalised, null, 2)}\n`, "utf8");
|
|
102
|
-
fs.renameSync(tmp, file);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Build the lookup key the rest of shipctl uses.
|
|
107
|
-
* @param {string} kind
|
|
108
|
-
* @param {string} id
|
|
109
|
-
*/
|
|
110
|
-
export function lockKey(kind, id) {
|
|
111
|
-
return `${kind}/${id}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* @param {Lockfile} lock
|
|
116
|
-
* @param {string} kind
|
|
117
|
-
* @param {string} id
|
|
118
|
-
* @returns {LockfileEntry | null}
|
|
119
|
-
*/
|
|
120
|
-
export function lookupLock(lock, kind, id) {
|
|
121
|
-
if (!lock) return null;
|
|
122
|
-
const key = lockKey(kind, id);
|
|
123
|
-
const entry = lock.artifacts?.[key];
|
|
124
|
-
return entry || null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Compute the lockfile entry for a freshly-materialised artifact body.
|
|
129
|
-
* Keeping the signature narrow — just `body` and a bit of provenance —
|
|
130
|
-
* means the caller can't accidentally smuggle transient fields into the
|
|
131
|
-
* lock.
|
|
132
|
-
*
|
|
133
|
-
* @param {Object} params
|
|
134
|
-
* @param {string} params.body Artifact text (markdown + frontmatter).
|
|
135
|
-
* @param {string} params.version Resolved version string.
|
|
136
|
-
* @param {string} params.cachedPath Path relative to the ship root.
|
|
137
|
-
* @param {"http" | "monorepo" | "inline"} params.source
|
|
138
|
-
* @param {boolean} [params.pinned=false]
|
|
139
|
-
* @param {string} [params.channel]
|
|
140
|
-
* @returns {LockfileEntry}
|
|
141
|
-
*/
|
|
142
|
-
export function entryFromBody({
|
|
143
|
-
body,
|
|
144
|
-
version,
|
|
145
|
-
cachedPath,
|
|
146
|
-
source,
|
|
147
|
-
pinned = false,
|
|
148
|
-
channel,
|
|
149
|
-
}) {
|
|
150
|
-
return {
|
|
151
|
-
version,
|
|
152
|
-
content_sha256: artifactSha256(body),
|
|
153
|
-
cached_path: String(cachedPath).replace(/\\/g, "/"),
|
|
154
|
-
source,
|
|
155
|
-
pinned: Boolean(pinned),
|
|
156
|
-
...(channel ? { channel: String(channel) } : {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Decide whether a body matches a lockfile entry. Returns a structured
|
|
162
|
-
* `{ ok, reason }` so callers can emit specific diagnostics.
|
|
163
|
-
*
|
|
164
|
-
* @param {LockfileEntry | null} entry
|
|
165
|
-
* @param {string} body
|
|
166
|
-
* @returns {{ ok: boolean, reason?: string, expected?: string, actual?: string }}
|
|
167
|
-
*/
|
|
168
|
-
export function verifyBody(entry, body) {
|
|
169
|
-
if (!entry) return { ok: false, reason: "missing-entry" };
|
|
170
|
-
const actual = artifactSha256(body);
|
|
171
|
-
if (actual !== entry.content_sha256) {
|
|
172
|
-
return {
|
|
173
|
-
ok: false,
|
|
174
|
-
reason: "sha-mismatch",
|
|
175
|
-
expected: entry.content_sha256,
|
|
176
|
-
actual,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
return { ok: true };
|
|
180
|
-
}
|