@catalyst-cloud/cli 0.16.23 → 0.16.25
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/args.js +17 -4
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/env/draft.js +56 -29
- package/dist/env/machine-values.js +63 -0
- package/dist/env/machine.js +119 -0
- package/dist/env/scan-claude-mcp.js +274 -0
- package/dist/env/scan-envrc.js +271 -0
- package/dist/env/settings-toml.js +8 -0
- package/dist/env/shell-plan.js +4 -1
- package/dist/env-import.js +274 -0
- package/dist/env.js +101 -9
- package/dist/hosts.js +2 -14
- package/dist/mcp-status.js +111 -0
- package/dist/mcp.js +5 -2
- package/dist/onboard-next.js +5 -1
- package/dist/onboard-runner.js +112 -20
- package/dist/prompt.js +15 -2
- package/dist/runner-command.js +8 -3
- package/dist/runner-update-fleet.js +442 -0
- package/dist/runner-update.js +7 -7
- package/dist/secret.js +3 -3
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +6 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { dockerRunnerEngine, prepareRunnerImages, spawnDocker } from "./onboard-runner.js";
|
|
4
|
+
import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseState, releaseLock, takeLock, writePrivateAtomic, writeReleaseState, } from "./runner-update.js";
|
|
5
|
+
// `catalyst runner update --fleet <dir>`: the same approved release, applied to a multi-team
|
|
6
|
+
// supervisor fleet (CTC-6530). One supervisor container serves several scopes from a Compose file
|
|
7
|
+
// written as JSON (`compose.json`), under one operator enrollment, and every host credential on the
|
|
8
|
+
// machine reads the same release. The fleet differs from a single-team runner in three ways:
|
|
9
|
+
//
|
|
10
|
+
// - the images live in `compose.json`, not `.env`: `services.supervisor.image` and its
|
|
11
|
+
// `environment.CATALYST_RUNNER_IMAGE`. Only those two fields change;
|
|
12
|
+
// - phase containers are never waited out. The supervisor is recreated alone and re-adopts the
|
|
13
|
+
// running ones, which keep the image they were granted. So there is no idle gate;
|
|
14
|
+
// - the deadline watchdog is a separate Compose project and is left alone. The release's watchdog
|
|
15
|
+
// image is not applied here.
|
|
16
|
+
//
|
|
17
|
+
// The release is read from a one-off container of the current supervisor image that mounts only the
|
|
18
|
+
// credential, read-only. Readiness is per scope: every scope that logged `supervisor_scope_qualified`
|
|
19
|
+
// in the minutes before the update must log it again from the new container, which must stay up with
|
|
20
|
+
// no restart and log no `host_not_ready` for those scopes. Otherwise the previous `compose.json` comes
|
|
21
|
+
// back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
|
|
22
|
+
// once it qualifies or rolls back, so a run killed in between is finished by the next one.
|
|
23
|
+
export const FLEET_COMPOSE_FILE = "compose.json";
|
|
24
|
+
export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
|
|
25
|
+
const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
|
|
26
|
+
/** The scopes the supervisor served before a recreate, kept until that recreate is qualified or rolled
|
|
27
|
+
* back. A run that was killed in between finds it and qualifies against it, never against the new
|
|
28
|
+
* container's own log, which would read an unqualified supervisor as serving. */
|
|
29
|
+
export const IN_FLIGHT_FILE = "fleet-update-in-flight.json";
|
|
30
|
+
/** How far back a qualification counts as current before the update. The supervisor repeats it
|
|
31
|
+
* on every loop while a scope is ready, so a ready scope logs it many times in this window. */
|
|
32
|
+
const QUALIFIED_LOOKBACK_MS = 5 * 60_000;
|
|
33
|
+
const QUALIFY_MIN_MS = 90_000;
|
|
34
|
+
const QUALIFY_DEADLINE_MS = 240_000;
|
|
35
|
+
const QUALIFY_POLL_MS = 10_000;
|
|
36
|
+
const CHILD_NAME_PREFIX = "catalyst-pa_";
|
|
37
|
+
/** The two image fields in a fleet `compose.json`, or null when the file is not a fleet layout. */
|
|
38
|
+
export function fleetImages(compose) {
|
|
39
|
+
const supervisor = isRecord(compose) && isRecord(compose.services) ? compose.services.supervisor : null;
|
|
40
|
+
if (!isRecord(supervisor) || typeof supervisor.image !== "string" || !isRecord(supervisor.environment))
|
|
41
|
+
return null;
|
|
42
|
+
const runner = supervisor.environment.CATALYST_RUNNER_IMAGE;
|
|
43
|
+
return typeof runner === "string" ? { supervisor: supervisor.image, runner } : null;
|
|
44
|
+
}
|
|
45
|
+
/** `compose` with only the two image fields replaced. */
|
|
46
|
+
export function withFleetImages(compose, images) {
|
|
47
|
+
const next = structuredClone(compose);
|
|
48
|
+
next.services.supervisor.image = images.supervisor;
|
|
49
|
+
next.services.supervisor.environment.CATALYST_RUNNER_IMAGE = images.runner;
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
const logEvents = (text) => text.split("\n").flatMap((line) => {
|
|
53
|
+
const start = line.indexOf("{");
|
|
54
|
+
if (start === -1)
|
|
55
|
+
return [];
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(line.slice(start));
|
|
58
|
+
return isRecord(parsed) ? [parsed] : [];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const scopeKey = (event) => typeof event.tenant === "string" && typeof event.team === "string" ? `${event.tenant}/${event.team}` : null;
|
|
65
|
+
/** The scopes (`tenant/team`) that logged `supervisor_scope_qualified` in this log text. */
|
|
66
|
+
export function qualifiedScopes(text) {
|
|
67
|
+
const scopes = new Set();
|
|
68
|
+
for (const event of logEvents(text)) {
|
|
69
|
+
const key = event.event === "supervisor_scope_qualified" ? scopeKey(event) : null;
|
|
70
|
+
if (key)
|
|
71
|
+
scopes.add(key);
|
|
72
|
+
}
|
|
73
|
+
return scopes;
|
|
74
|
+
}
|
|
75
|
+
/** A `host_not_ready` line for one of `scopes`, or one that names no scope. Other scopes may be
|
|
76
|
+
* unready for their own reasons (a team with no repository yet) and do not stop an update. */
|
|
77
|
+
export function notReadyScope(text, scopes) {
|
|
78
|
+
for (const event of logEvents(text)) {
|
|
79
|
+
if (event.event !== "host_not_ready")
|
|
80
|
+
continue;
|
|
81
|
+
const key = scopeKey(event);
|
|
82
|
+
if (key === null || scopes.has(key))
|
|
83
|
+
return key ?? "unscoped";
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function readInFlight(dir) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(join(dir, IN_FLIGHT_FILE), "utf8"));
|
|
90
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.scopes))
|
|
91
|
+
return null;
|
|
92
|
+
const scopes = parsed.scopes.filter((scope) => typeof scope === "string");
|
|
93
|
+
return scopes.length > 0 ? new Set(scopes) : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function clearInFlight(dir) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(join(dir, IN_FLIGHT_FILE));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Already gone.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function runFleetUpdate(engine, options) {
|
|
108
|
+
const { dir } = options;
|
|
109
|
+
const now = options.now ?? Date.now;
|
|
110
|
+
if (!existsSync(join(dir, FLEET_COMPOSE_FILE)))
|
|
111
|
+
return { outcome: "failed", detail: "fleet_not_installed", generation: null, exitCode: 1 };
|
|
112
|
+
const lock = options.check ? null : takeLock(dir, now());
|
|
113
|
+
if (!options.check && lock === null)
|
|
114
|
+
return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
|
|
115
|
+
try {
|
|
116
|
+
const result = await update(engine, options, now);
|
|
117
|
+
if (!options.check) {
|
|
118
|
+
const state = readReleaseState(dir);
|
|
119
|
+
writeReleaseState(dir, { ...state, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (lock !== null)
|
|
125
|
+
releaseLock(dir, lock);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function update(engine, options, now) {
|
|
129
|
+
const { dir, signal } = options;
|
|
130
|
+
const composePath = join(dir, FLEET_COMPOSE_FILE);
|
|
131
|
+
const state = readReleaseState(dir);
|
|
132
|
+
const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
|
|
133
|
+
const composeText = readFileSync(composePath, "utf8");
|
|
134
|
+
let compose;
|
|
135
|
+
try {
|
|
136
|
+
compose = JSON.parse(composeText);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
compose = null;
|
|
140
|
+
}
|
|
141
|
+
const current = fleetImages(compose);
|
|
142
|
+
const project = isRecord(compose) && typeof compose.name === "string" ? compose.name : "";
|
|
143
|
+
if (!isRecord(compose) || current === null || !/^[a-z0-9][a-z0-9_-]*$/.test(project))
|
|
144
|
+
return done("failed", "fleet_compose_unrecognized", { exitCode: 1 });
|
|
145
|
+
const read = await engine.desiredRelease(dir, compose, signal);
|
|
146
|
+
const decision = decideRelease(state.generation, read);
|
|
147
|
+
if (decision.action === "retain")
|
|
148
|
+
return decision.reason === "not_newer"
|
|
149
|
+
? done("current", "not_newer")
|
|
150
|
+
: done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
|
|
151
|
+
const { release } = decision;
|
|
152
|
+
const target = { runner: release.images.runner.reference, supervisor: release.images.supervisor.reference };
|
|
153
|
+
const changed = ["runner", "supervisor"].filter((c) => current[c] !== target[c]);
|
|
154
|
+
const tagged = { releaseId: release.releaseId };
|
|
155
|
+
const live = await engine.supervisor(dir, project, signal);
|
|
156
|
+
if (live === undefined)
|
|
157
|
+
return done("retained", "docker_unavailable", tagged);
|
|
158
|
+
const runs = (images) => !!live?.running && live.image === images.supervisor && live.runnerImage === images.runner;
|
|
159
|
+
const inFlight = readInFlight(dir);
|
|
160
|
+
if (inFlight === null && decision.action === "verify" && changed.length === 0 && runs(target))
|
|
161
|
+
return done("current", "not_newer");
|
|
162
|
+
if (options.check)
|
|
163
|
+
return done("planned", changed.length > 0 ? `would_update:${changed.join(",")}` : "already_configured", { ...tagged, generation: release.generation });
|
|
164
|
+
// The scopes the running supervisor qualifies are what the new one has to qualify again, unless an
|
|
165
|
+
// earlier run stopped mid-recreate: then the scopes it saved are the baseline.
|
|
166
|
+
let scopes = inFlight;
|
|
167
|
+
if (scopes === null) {
|
|
168
|
+
if (!live?.running)
|
|
169
|
+
return done("retained", "supervisor_not_running", { ...tagged, exitCode: 1 });
|
|
170
|
+
const recent = await engine.logs(live.id, new Date(now() - QUALIFIED_LOOKBACK_MS).toISOString(), signal);
|
|
171
|
+
if (recent === null)
|
|
172
|
+
return done("retained", "docker_unavailable", tagged);
|
|
173
|
+
scopes = qualifiedScopes(recent);
|
|
174
|
+
if (scopes.size === 0)
|
|
175
|
+
return done("retained", "no_qualified_scope", { ...tagged, exitCode: 1 });
|
|
176
|
+
}
|
|
177
|
+
const record = (outcome, detail, previous, exitCode = 0) => {
|
|
178
|
+
writeReleaseState(dir, {
|
|
179
|
+
...state,
|
|
180
|
+
generation: release.generation,
|
|
181
|
+
releaseId: release.releaseId,
|
|
182
|
+
appliedAtMs: now(),
|
|
183
|
+
// The watchdog is not this updater's to move, so it records none.
|
|
184
|
+
previous: previous ? { ...previous, watchdog: "" } : state.previous,
|
|
185
|
+
});
|
|
186
|
+
return { outcome, detail, generation: release.generation, exitCode, ...tagged };
|
|
187
|
+
};
|
|
188
|
+
if (inFlight === null && changed.length === 0 && runs(target))
|
|
189
|
+
return record("recorded", "already_running_release", null);
|
|
190
|
+
// What a failure goes back to: today's file, or for a half-applied file the one saved before it.
|
|
191
|
+
let rollbackText = composeText;
|
|
192
|
+
if (changed.length === 0) {
|
|
193
|
+
try {
|
|
194
|
+
rollbackText = readFileSync(join(dir, PREVIOUS_COMPOSE_FILE), "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
rollbackText = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let rollbackImages = null;
|
|
201
|
+
try {
|
|
202
|
+
rollbackImages = rollbackText === null ? null : fleetImages(JSON.parse(rollbackText));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
rollbackImages = null;
|
|
206
|
+
}
|
|
207
|
+
const unprepared = await engine.prepare([target.runner, target.supervisor], signal);
|
|
208
|
+
if (unprepared !== null)
|
|
209
|
+
return done("retained", unprepared, tagged);
|
|
210
|
+
const candidateText = `${JSON.stringify(withFleetImages(compose, target), null, 2)}\n`;
|
|
211
|
+
const candidatePath = join(dir, CANDIDATE_COMPOSE_FILE);
|
|
212
|
+
writePrivateAtomic(candidatePath, candidateText);
|
|
213
|
+
const valid = await engine.configValid(dir, project, candidatePath, signal);
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(candidatePath);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Already gone.
|
|
219
|
+
}
|
|
220
|
+
if (!valid)
|
|
221
|
+
return done("retained", "compose_config_invalid", { ...tagged, exitCode: 1 });
|
|
222
|
+
const childrenBefore = await engine.children(signal);
|
|
223
|
+
if (childrenBefore === null)
|
|
224
|
+
return done("retained", "docker_unavailable", tagged);
|
|
225
|
+
// From here the machine changes. The previous file is the rollback.
|
|
226
|
+
if (changed.length > 0)
|
|
227
|
+
writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
|
|
228
|
+
writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
|
|
229
|
+
writePrivateAtomic(composePath, candidateText);
|
|
230
|
+
if ((await engine.recreate(dir, project, signal)) && (await qualify(engine, dir, project, target, scopes, options, now))) {
|
|
231
|
+
clearInFlight(dir);
|
|
232
|
+
if (changed.length > 0)
|
|
233
|
+
await engine.removeImages(changed.map((c) => current[c]), signal);
|
|
234
|
+
// The recreate must leave every running phase container as it was: it is re-adopted, not restarted.
|
|
235
|
+
const childrenAfter = await engine.children(signal);
|
|
236
|
+
const disturbed = childrenAfter === null
|
|
237
|
+
? -1
|
|
238
|
+
: Object.entries(childrenBefore).filter(([id, before]) => {
|
|
239
|
+
const after = childrenAfter[id];
|
|
240
|
+
return before.running && after?.running === true && after.fingerprint !== before.fingerprint;
|
|
241
|
+
}).length;
|
|
242
|
+
const detail = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
|
|
243
|
+
const previous = changed.length > 0 ? current : null;
|
|
244
|
+
return disturbed === 0
|
|
245
|
+
? record("updated", detail, previous)
|
|
246
|
+
: record("updated", `${detail};${disturbed < 0 ? "children_unreadable" : `children_changed:${disturbed}`}`, previous, 1);
|
|
247
|
+
}
|
|
248
|
+
if (rollbackText === null || rollbackImages === null)
|
|
249
|
+
return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
|
|
250
|
+
writePrivateAtomic(composePath, rollbackText);
|
|
251
|
+
const restored = (await engine.recreate(dir, project, signal)) &&
|
|
252
|
+
(await qualify(engine, dir, project, rollbackImages, scopes, options, now));
|
|
253
|
+
// A rollback that did not qualify keeps the baseline for the next run.
|
|
254
|
+
if (restored)
|
|
255
|
+
clearInFlight(dir);
|
|
256
|
+
return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
|
|
257
|
+
}
|
|
258
|
+
/** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
|
|
259
|
+
* scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
|
|
260
|
+
async function qualify(engine, dir, project, images, scopes, options, now) {
|
|
261
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
262
|
+
const start = now();
|
|
263
|
+
const deadline = start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
|
|
264
|
+
const minimum = start + (options.qualifyMinMs ?? QUALIFY_MIN_MS);
|
|
265
|
+
let id = null;
|
|
266
|
+
for (;;) {
|
|
267
|
+
if (options.signal?.aborted)
|
|
268
|
+
return false;
|
|
269
|
+
const live = await engine.supervisor(dir, project, options.signal);
|
|
270
|
+
if (live) {
|
|
271
|
+
id ??= live.id;
|
|
272
|
+
if (live.id !== id || !live.running || live.restartCount !== 0)
|
|
273
|
+
return false;
|
|
274
|
+
if (live.image !== images.supervisor || live.runnerImage !== images.runner)
|
|
275
|
+
return false;
|
|
276
|
+
const text = await engine.logs(live.id, live.startedAt, options.signal);
|
|
277
|
+
if (text !== null) {
|
|
278
|
+
if (notReadyScope(text, scopes) !== null)
|
|
279
|
+
return false;
|
|
280
|
+
const seen = qualifiedScopes(text);
|
|
281
|
+
if (now() >= minimum && [...scopes].every((scope) => seen.has(scope)))
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (now() >= deadline)
|
|
286
|
+
return false;
|
|
287
|
+
await sleep(QUALIFY_POLL_MS);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const CONTAINER_ID = /^[0-9a-f]{12,64}$/;
|
|
291
|
+
/** The one volume or bind in `service.volumes` that holds `file`, as a read-only `--mount` value. */
|
|
292
|
+
export function credentialMount(compose, service, file) {
|
|
293
|
+
const project = typeof compose.name === "string" ? compose.name : "";
|
|
294
|
+
const topLevel = isRecord(compose.volumes) ? compose.volumes : {};
|
|
295
|
+
let best = null;
|
|
296
|
+
for (const entry of Array.isArray(service.volumes) ? service.volumes : []) {
|
|
297
|
+
let source;
|
|
298
|
+
let target;
|
|
299
|
+
let type;
|
|
300
|
+
if (typeof entry === "string") {
|
|
301
|
+
[source, target] = entry.split(":");
|
|
302
|
+
type = typeof source === "string" && source.startsWith("/") ? "bind" : "volume";
|
|
303
|
+
}
|
|
304
|
+
else if (isRecord(entry)) {
|
|
305
|
+
({ source, target, type } = entry);
|
|
306
|
+
}
|
|
307
|
+
if (typeof source !== "string" || typeof target !== "string" || source === "")
|
|
308
|
+
continue;
|
|
309
|
+
if (file !== target && !file.startsWith(`${target.replace(/\/$/, "")}/`))
|
|
310
|
+
continue;
|
|
311
|
+
// The deepest mount holding the file is the one the container reads it from.
|
|
312
|
+
if (best !== null && best.target.length >= target.length)
|
|
313
|
+
continue;
|
|
314
|
+
const declared = topLevel[source];
|
|
315
|
+
const name = isRecord(declared) && typeof declared.name === "string" ? declared.name
|
|
316
|
+
: isRecord(declared) && declared.external === true ? source : `${project}_${source}`;
|
|
317
|
+
const mount = type === "bind" ? `type=bind,src=${source},dst=${target},readonly`
|
|
318
|
+
: type === "volume" && /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) ? `type=volume,src=${name},dst=${target},readonly` : null;
|
|
319
|
+
best = { target, mount };
|
|
320
|
+
}
|
|
321
|
+
return best?.mount ?? null;
|
|
322
|
+
}
|
|
323
|
+
/** The real engine: the docker CLI, and the setup engine's own pull. */
|
|
324
|
+
export function dockerFleetUpdateEngine(deps = {}) {
|
|
325
|
+
const exec = deps.exec ?? spawnDocker;
|
|
326
|
+
const env = Object.fromEntries(Object.entries(deps.env ?? process.env)
|
|
327
|
+
.filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
|
|
328
|
+
const setup = deps.setup ?? dockerRunnerEngine({ exec, env: deps.env });
|
|
329
|
+
const docker = async (args, options = {}) => {
|
|
330
|
+
try {
|
|
331
|
+
return await exec(args, { env, ...options });
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return { code: 1, stdout: "", stderr: "" };
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const compose = (dir, project, file = join(dir, FLEET_COMPOSE_FILE)) => ["compose", "--project-name", project, "--file", file];
|
|
338
|
+
return {
|
|
339
|
+
// Not `compose run`: that would mount the Docker socket and every scope's state. This container
|
|
340
|
+
// gets the credential alone, read-only, and the two variables the script reads.
|
|
341
|
+
async desiredRelease(_dir, file, signal) {
|
|
342
|
+
const service = isRecord(file.services) && isRecord(file.services.supervisor) ? file.services.supervisor : null;
|
|
343
|
+
const environment = service && isRecord(service.environment) ? service.environment : null;
|
|
344
|
+
const credential = environment?.CATALYST_HOST_CREDENTIAL_FILE;
|
|
345
|
+
const mirror = environment?.CATALYST_MIRROR_URL;
|
|
346
|
+
if (!service || typeof service.image !== "string" || typeof credential !== "string" || typeof mirror !== "string")
|
|
347
|
+
return { kind: "no_credential" };
|
|
348
|
+
const mount = credentialMount(file, service, credential);
|
|
349
|
+
if (!mount)
|
|
350
|
+
return { kind: "no_credential" };
|
|
351
|
+
const user = typeof service.user === "string" && /^[0-9]+(:[0-9]+)?$/.test(service.user) ? ["--user", service.user] : [];
|
|
352
|
+
const read = await docker(["run", "--rm", "--network", "bridge", "--read-only", "--tmpfs", "/tmp", "--cap-drop", "ALL",
|
|
353
|
+
"--security-opt", "no-new-privileges", ...user, "--mount", mount,
|
|
354
|
+
"--env", `CATALYST_HOST_CREDENTIAL_FILE=${credential}`, "--env", `CATALYST_MIRROR_URL=${mirror}`,
|
|
355
|
+
"--entrypoint", "bun", service.image, "-e", DESIRED_RELEASE_SCRIPT], { signal, timeoutMs: 120_000 });
|
|
356
|
+
if (read.code !== 0)
|
|
357
|
+
return { kind: "no_credential" };
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(read.stdout);
|
|
360
|
+
if (!isRecord(parsed))
|
|
361
|
+
return { kind: "no_credential" };
|
|
362
|
+
const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
|
|
363
|
+
if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
|
|
364
|
+
return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
|
|
365
|
+
if (parsed.kind === "unreachable" && hostId)
|
|
366
|
+
return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
|
|
367
|
+
return { kind: "no_credential" };
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return { kind: "no_credential" };
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
async supervisor(dir, project, signal) {
|
|
374
|
+
const listed = await docker([...compose(dir, project), "ps", "--all", "--quiet", "supervisor"], { cwd: dir, signal });
|
|
375
|
+
if (listed.code !== 0)
|
|
376
|
+
return undefined;
|
|
377
|
+
const id = listed.stdout.trim();
|
|
378
|
+
if (id === "")
|
|
379
|
+
return null;
|
|
380
|
+
if (!CONTAINER_ID.test(id))
|
|
381
|
+
return undefined;
|
|
382
|
+
const inspected = await docker(["inspect", id], { signal });
|
|
383
|
+
try {
|
|
384
|
+
const [c] = JSON.parse(inspected.stdout);
|
|
385
|
+
const runner = c.Config.Env.find((line) => line.startsWith("CATALYST_RUNNER_IMAGE="));
|
|
386
|
+
return {
|
|
387
|
+
id: c.Id,
|
|
388
|
+
image: c.Config.Image,
|
|
389
|
+
runnerImage: runner ? runner.slice("CATALYST_RUNNER_IMAGE=".length) : "",
|
|
390
|
+
running: c.State.Running,
|
|
391
|
+
restartCount: c.RestartCount,
|
|
392
|
+
startedAt: c.State.StartedAt,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
async logs(id, since, signal) {
|
|
400
|
+
if (!CONTAINER_ID.test(id))
|
|
401
|
+
return null;
|
|
402
|
+
const read = await docker(["logs", "--since", since, id], { signal, timeoutMs: 30_000 });
|
|
403
|
+
return read.code === 0 ? `${read.stdout}\n${read.stderr ?? ""}` : null;
|
|
404
|
+
},
|
|
405
|
+
async children(signal) {
|
|
406
|
+
const listed = await docker(["ps", "--all", "--quiet", "--no-trunc", "--filter", `name=${CHILD_NAME_PREFIX}`], { signal });
|
|
407
|
+
if (listed.code !== 0)
|
|
408
|
+
return null;
|
|
409
|
+
const ids = listed.stdout.split("\n").map((line) => line.trim()).filter((line) => CONTAINER_ID.test(line));
|
|
410
|
+
if (ids.length === 0)
|
|
411
|
+
return {};
|
|
412
|
+
const inspected = await docker(["inspect", ...ids], { signal });
|
|
413
|
+
try {
|
|
414
|
+
const containers = JSON.parse(inspected.stdout);
|
|
415
|
+
return Object.fromEntries(containers.map((c) => [c.Id, {
|
|
416
|
+
running: c.State.Running,
|
|
417
|
+
fingerprint: `${c.State.StartedAt}|${c.State.Pid}|${c.Image}|${c.RestartCount}`,
|
|
418
|
+
}]));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
async prepare(images, signal) {
|
|
425
|
+
const info = await setup.info(signal);
|
|
426
|
+
if (!info)
|
|
427
|
+
return "docker_unavailable";
|
|
428
|
+
const refused = await prepareRunnerImages(setup, images, info.arch, signal);
|
|
429
|
+
return refused === null ? null : `${refused.reason}${refused.image ? `:${refused.image}` : ""}`;
|
|
430
|
+
},
|
|
431
|
+
async configValid(dir, project, file, signal) {
|
|
432
|
+
return (await docker([...compose(dir, project, file), "config", "--quiet"], { cwd: dirname(file), signal })).code === 0;
|
|
433
|
+
},
|
|
434
|
+
async recreate(dir, project, signal) {
|
|
435
|
+
return (await docker([...compose(dir, project), "up", "--detach", "--no-deps", "--force-recreate", "supervisor"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
|
|
436
|
+
},
|
|
437
|
+
async removeImages(images, signal) {
|
|
438
|
+
for (const ref of images)
|
|
439
|
+
await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
package/dist/runner-update.js
CHANGED
|
@@ -42,7 +42,7 @@ export const RELEASE_ENV_KEYS = {
|
|
|
42
42
|
supervisor: "CATALYST_SUPERVISOR_IMAGE",
|
|
43
43
|
watchdog: "CATALYST_WATCHDOG_IMAGE",
|
|
44
44
|
};
|
|
45
|
-
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
export const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
46
|
const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
47
47
|
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
48
48
|
const SHA40 = /^[0-9a-f]{40}$/;
|
|
@@ -122,12 +122,12 @@ export function readReleaseState(dir) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
/** Write a private file by rename, so a reader never sees half of it. */
|
|
125
|
-
function writePrivateAtomic(path, text) {
|
|
125
|
+
export function writePrivateAtomic(path, text) {
|
|
126
126
|
const temp = `${path}.${process.pid}.tmp`;
|
|
127
127
|
writeFileSync(temp, text, { mode: 0o600 });
|
|
128
128
|
renameSync(temp, path);
|
|
129
129
|
}
|
|
130
|
-
function writeReleaseState(dir, state) {
|
|
130
|
+
export function writeReleaseState(dir, state) {
|
|
131
131
|
writePrivateAtomic(join(dir, RELEASE_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
132
132
|
}
|
|
133
133
|
/** `.env` with each `KEY=` line for `values` replaced in place (or appended), every other line kept. */
|
|
@@ -309,7 +309,7 @@ const alive = (pid) => {
|
|
|
309
309
|
};
|
|
310
310
|
/** An exclusive lock file holding the owner's pid. It is taken over only when that process is gone,
|
|
311
311
|
* never by age: one run can legitimately take longer than any fixed limit (three image pulls). */
|
|
312
|
-
function takeLock(dir, nowMs) {
|
|
312
|
+
export function takeLock(dir, nowMs) {
|
|
313
313
|
const path = join(dir, LOCK_FILE);
|
|
314
314
|
const token = `${process.pid} ${nowMs}\n`;
|
|
315
315
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -333,7 +333,7 @@ function takeLock(dir, nowMs) {
|
|
|
333
333
|
}
|
|
334
334
|
return null;
|
|
335
335
|
}
|
|
336
|
-
function releaseLock(dir, token) {
|
|
336
|
+
export function releaseLock(dir, token) {
|
|
337
337
|
const path = join(dir, LOCK_FILE);
|
|
338
338
|
try {
|
|
339
339
|
if (readFileSync(path, "utf8") === token)
|
|
@@ -345,7 +345,7 @@ function releaseLock(dir, token) {
|
|
|
345
345
|
}
|
|
346
346
|
/** Runs inside the supervisor image with its own environment. Prints one JSON object, never the
|
|
347
347
|
* secret: the bearer is built and sent from inside the container. */
|
|
348
|
-
const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
348
|
+
export const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
349
349
|
const out = (v) => process.stdout.write(JSON.stringify(v));
|
|
350
350
|
let state;
|
|
351
351
|
try { state = JSON.parse(require("node:fs").readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE, "utf8")); }
|
|
@@ -367,7 +367,7 @@ const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
|
367
367
|
out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
|
|
368
368
|
}
|
|
369
369
|
})();`;
|
|
370
|
-
const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
370
|
+
export const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
371
371
|
/** The real engine: the docker CLI and the setup engine's own pull, custody and Compose steps. */
|
|
372
372
|
export function dockerRunnerUpdateEngine(deps = {}) {
|
|
373
373
|
const exec = deps.exec ?? spawnDocker;
|
package/dist/secret.js
CHANGED
|
@@ -31,7 +31,7 @@ export const SECRETS_IMPORT_PATH = "/me/secrets/import";
|
|
|
31
31
|
const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
32
32
|
const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
|
|
33
33
|
/** Why an import line was not stored, in words a person can act on. */
|
|
34
|
-
const IMPORT_REASONS = {
|
|
34
|
+
export const IMPORT_REASONS = {
|
|
35
35
|
name_exists: "already set for this repository; pass --rotate <NAME> to replace it",
|
|
36
36
|
name_taken_by_env_var: "already a plain environment variable; a name is a secret or a variable, never both",
|
|
37
37
|
invalid_name: "not an env-style name (A-Z, 0-9 and _, starting with a letter)",
|
|
@@ -69,7 +69,7 @@ function requireRepo(args, verb) {
|
|
|
69
69
|
return repo;
|
|
70
70
|
}
|
|
71
71
|
/** The cloud's refusal, in its own words, plus what to do for the two we can explain. */
|
|
72
|
-
function refusalLine(status, body, repo) {
|
|
72
|
+
export function refusalLine(status, body, repo) {
|
|
73
73
|
if (status === 401) {
|
|
74
74
|
return "refused (401): the cloud did not accept your credential here. If `catalyst me` works, this cloud predates secret writes from the CLI and needs a newer release; otherwise log in again.";
|
|
75
75
|
}
|
|
@@ -91,7 +91,7 @@ function renderDeclared(declared, repo) {
|
|
|
91
91
|
return [`every declared secret has a value (${declared.declared.length})`];
|
|
92
92
|
return [`declared but still without a value (${declared.missing.length}): ${declared.missing.join(", ")}`];
|
|
93
93
|
}
|
|
94
|
-
const ACCEPT = [400, 401, 403, 404, 409, 422];
|
|
94
|
+
export const ACCEPT = [400, 401, 403, 404, 409, 422];
|
|
95
95
|
async function readStdinText() {
|
|
96
96
|
const chunks = [];
|
|
97
97
|
for await (const chunk of process.stdin)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.25",
|
|
4
4
|
"description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"prepublishOnly": "node scripts/release-train-check.mjs --publish cli"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@catalyst-cloud/sdk": "0.16.
|
|
56
|
+
"@catalyst-cloud/sdk": "0.16.11",
|
|
57
57
|
"@clack/prompts": "1.8.1",
|
|
58
58
|
"fast-string-width": "3.0.2",
|
|
59
59
|
"fast-wrap-ansi": "0.2.2",
|
|
60
60
|
"smol-toml": "^1.7.1",
|
|
61
61
|
"yaml": "^2.9.0",
|
|
62
|
-
"@catalyst-cloud/sdk-replica-node": "0.16.
|
|
62
|
+
"@catalyst-cloud/sdk-replica-node": "0.16.11"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@catalyst-cloud/schema": "0.16.
|
|
65
|
+
"@catalyst-cloud/schema": "0.16.9",
|
|
66
66
|
"@types/node": "^26.3.0",
|
|
67
67
|
"@vitest/coverage-v8": "^4.1.11",
|
|
68
68
|
"typescript": "^7.0.2",
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Catalyst's GitHub
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Catalyst Linear
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Onboard me
|
|
10
10
|
|
|
@@ -6,7 +6,7 @@ The assistant prepares `.catalyst/catalyst.toml` from the repository. Never ask
|
|
|
6
6
|
|
|
7
7
|
1. Check `.catalyst/catalyst.toml` and `catalyst.env.json` in the selected repository. Classify it as missing, legacy-only, valid TOML, or invalid TOML. `catalyst env check --json` checks the TOML environment table; when only the legacy file exists, it reports that the JSON declaration is no longer read.
|
|
8
8
|
2. For a legacy file, run `catalyst env migrate [path-to-catalyst.env.json]`. It prints a TOML environment table containing names only; every name is optional, and names identified as secrets are marked `secret = true`. Do not copy JSON values or provenance into the new file.
|
|
9
|
-
3. For missing or invalid declarations,
|
|
9
|
+
3. For missing or invalid declarations, run `catalyst env draft --root <repo> --json` first. It drafts names from the repository's standard files and from this computer: the names the repository's `.envrc` exports, and the HTTP MCP servers in Claude Code's `~/.claude.json` with each header turned into a variable reference. It never writes or prints a value, and it lists what it did not draft and why. Then refine the draft from the repository's package manager files, README setup instructions, `package.json` scripts or equivalent, build and test workflows, `.env.example`, and source references. Use `catalyst env inventory [repo] --json` as supporting evidence. Do not inspect `.env` values or run repository scripts just to infer settings.
|
|
10
10
|
4. Draft `#:schema https://staging.catalystcloud.dev/schemas/catalyst.schema.json`, `[project]` with the mapped project's `linear_team`, the environment variables evidenced by build/test/setup, and setup/verify steps based on the repository's real package manager and build/test commands. Exclude deploy-only variables and platform bindings unless build or tests use them. Mark known secrets with `secret = true`. Set `required = false` on every variable; never make a phase depend on a newly declared value by default.
|
|
11
11
|
5. Validate the complete draft against the live schema at the URL in its `#:schema` line, then run `catalyst env check <path>`. The environment check is an additional check; it does not replace full schema validation. If a live-schema validator is unavailable or either check fails, fix the draft or report the exact blocker without claiming it is valid.
|
|
12
12
|
|
|
@@ -14,7 +14,11 @@ The assistant prepares `.catalyst/catalyst.toml` from the repository. Never ask
|
|
|
14
14
|
|
|
15
15
|
Show a short preview with the project/team, setup and verify commands, variable names, which are marked secret, and confirmation that every variable is optional. Do not include values. Ask the person to approve this exact draft. Before approval, do not write or replace `.catalyst/catalyst.toml`, remove `catalyst.env.json`, or open a settings PR.
|
|
16
16
|
|
|
17
|
-
After approval, write the validated file.
|
|
17
|
+
After approval, write the validated file.
|
|
18
|
+
|
|
19
|
+
When the approved file came from `catalyst env draft`, ask: "Want me to store this computer's values for these names in Catalyst?" On a yes, run `catalyst env import --root <repo> --yes --json` and report its `stored`, `kept`, `noValue` and `notStored` names, with the fix it gives for each. The command reads the values itself and never prints them, so do not read them yourself. It needs an admin or owner seat. A name in `noValue` that `.envrc` computes needs the command run in the person's own shell, where direnv has loaded the repository.
|
|
20
|
+
|
|
21
|
+
When converting a legacy-only repository, remove `catalyst.env.json` in the same change. Open the settings PR, then offer to watch it merge. A reviewed merge is the approval. Catalyst approves the exact merged revision, with no second step, when someone other than the PR's author approved the merged head commit, or when a workspace owner or admin merged the PR. Tell the person this before they merge: the approving review is their consent to the setup commands and secret names in the file, so a reviewer should read it like code. A direct push, an unreviewed merge, or an approval left before the last commit leaves the revision proposed. No command approves one repository's revision yet, so only then give an owner or admin a direct link to approve it at Settings → Your projects → the project → Repositories → the repository → Environment → Setup declaration → Approve this revision. The direct route is `/settings/projects/$projectId/repositories/$repoId/environment/declaration`; use the project and repository ids from Catalyst instead of guessing them.
|
|
18
22
|
|
|
19
23
|
## File shape
|
|
20
24
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Run this project
|
|
10
10
|
|
package/skills/unstick/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Unstick
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# What needs me
|
|
10
10
|
|