@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.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/CHANGELOG.md +4 -0
- package/README.md +109 -53
- package/dist/args.js +237 -0
- package/dist/ask.js +137 -0
- package/dist/cli.js +165 -279
- package/dist/config.js +106 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +178 -0
- package/dist/detach.js +10 -0
- package/dist/errors.js +27 -0
- package/dist/execution.js +156 -0
- package/dist/http.js +142 -0
- package/dist/prompt.js +35 -0
- package/dist/query.js +187 -0
- package/dist/ready.js +123 -0
- package/dist/replica.js +318 -0
- package/dist/sdk.js +26 -0
- package/dist/skill-shape.js +179 -0
- package/dist/skills.js +74 -0
- package/dist/ts-deps-loader.js +68 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +173 -0
- package/dist/write.js +224 -0
- package/package.json +9 -5
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-setup/SKILL.md +32 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +55 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +184 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +42 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +47 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
- package/skills/whats-happening/scripts/snapshot.mjs +145 -0
- package/skills/ask/SKILL.md +0 -37
- package/skills/concierge/SKILL.md +0 -41
- package/skills/join/SKILL.md +0 -30
- package/skills/linearis/SKILL.md +0 -40
- package/skills/setup/SKILL.md +0 -27
- package/skills/steward/SKILL.md +0 -30
package/dist/contract.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// contract.ts — GET /api/v1/agent/contract, cached with its ETag at ~/.config/catalyst-cloud/contract.json.
|
|
2
|
+
// Every tenant fact a verb needs (stage ids, label ids, route paths, the bookkeeping marker, thresholds)
|
|
3
|
+
// is read from here; no verb carries a literal fallback for any of them.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { contractPathFor, readManifest } from "./config.js";
|
|
7
|
+
import { CliError, MeError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./http.js";
|
|
9
|
+
export const CONTRACT_ROUTE = "/api/v1/agent/contract";
|
|
10
|
+
export function readContractCache(home) {
|
|
11
|
+
const path = contractPathFor(home);
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
if (typeof parsed.fetchedAt !== "string" || typeof parsed.contractVersion !== "string" || !parsed.doc)
|
|
17
|
+
return null;
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function writeContractCache(home, cache) {
|
|
25
|
+
const path = contractPathFor(home);
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
const tmp = `${path}.tmp`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
|
|
29
|
+
renameSync(tmp, path);
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
/** `1.x` → major 1; `1.2.3` → exact; `unpinned` → anything. Returns null when the range is unparseable. */
|
|
33
|
+
export function contractVersionInRange(version, range) {
|
|
34
|
+
if (range === "unpinned")
|
|
35
|
+
return true;
|
|
36
|
+
const major = /^(\d+)\.x$/.exec(range);
|
|
37
|
+
if (major)
|
|
38
|
+
return version.split(".")[0] === major[1];
|
|
39
|
+
if (/^\d+\.\d+\.\d+$/.test(range))
|
|
40
|
+
return version === range;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function assertContractRange(version, range) {
|
|
44
|
+
const ok = contractVersionInRange(version, range);
|
|
45
|
+
if (ok === null)
|
|
46
|
+
throw new CliError(`tenantContractRange "${range}" in package.json is not a range this CLI understands`, "contract-range");
|
|
47
|
+
if (!ok) {
|
|
48
|
+
throw new CliError(`the tenant serves contract version ${version} but this bundle accepts ${range} — update the bundle (npm update -g @catalyst-cloud/catalyst-skills) or ask your tenant admin which version is live`, "contract-version");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the contract per the cloud's own cache policy: a cache younger than `doc.cache.maxAgeSeconds`
|
|
53
|
+
* is used as-is; otherwise a conditional GET revalidates it (304 refreshes `fetchedAt`, 200 replaces
|
|
54
|
+
* the document). A network failure on a cache older than `staleRefusalSeconds` refuses; a 403 (a
|
|
55
|
+
* workstation key) refuses naming the account key; a major version outside the bundle's range refuses
|
|
56
|
+
* naming both versions.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadContract(ctx, cfg, opts = {}) {
|
|
59
|
+
const path = contractPathFor(ctx.home);
|
|
60
|
+
const cached = readContractCache(ctx.home);
|
|
61
|
+
const nowMs = ctx.now().getTime();
|
|
62
|
+
const ageSeconds = cached ? Math.max(0, Math.floor((nowMs - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
|
|
63
|
+
const range = readManifest().tenantContractRange;
|
|
64
|
+
if (cached && !opts.refresh && ageSeconds < cached.doc.cache.maxAgeSeconds) {
|
|
65
|
+
assertContractRange(cached.contractVersion, range);
|
|
66
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
67
|
+
}
|
|
68
|
+
if (opts.offline) {
|
|
69
|
+
if (cached) {
|
|
70
|
+
assertContractRange(cached.contractVersion, range);
|
|
71
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
72
|
+
}
|
|
73
|
+
throw new CliError("no cached contract and network reads are disabled", "contract-missing");
|
|
74
|
+
}
|
|
75
|
+
const api = apiClient(cfg, ctx);
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await api.getJson(CONTRACT_ROUTE, { etag: cached?.etag ?? null, accept: [304] });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof CliError && err.status === 403) {
|
|
82
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} refused (403): the contract needs an account key (ctc_acct_…); this key is a workstation key — ask your tenant admin for an account key`, "contract-forbidden", 2, 403);
|
|
83
|
+
}
|
|
84
|
+
if (err instanceof MeError && err.kind === "network" && cached) {
|
|
85
|
+
if (ageSeconds >= cached.doc.cache.staleRefusalSeconds) {
|
|
86
|
+
throw new CliError(`the cached contract is ${ageSeconds}s old (refusal after ${cached.doc.cache.staleRefusalSeconds}s) and the refresh failed: ${err.message}`, "contract-stale");
|
|
87
|
+
}
|
|
88
|
+
assertContractRange(cached.contractVersion, range);
|
|
89
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 304) {
|
|
94
|
+
if (!cached)
|
|
95
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} answered 304 with no cache to revalidate`, "contract-shape");
|
|
96
|
+
assertContractRange(cached.contractVersion, range);
|
|
97
|
+
writeContractCache(ctx.home, { ...cached, fetchedAt: ctx.now().toISOString() });
|
|
98
|
+
return { doc: cached.doc, source: "revalidated", ageSeconds: 0, path };
|
|
99
|
+
}
|
|
100
|
+
const doc = res.body;
|
|
101
|
+
if (!doc || typeof doc !== "object" || typeof doc.contractVersion !== "string" || !Array.isArray(doc.teams) || !Array.isArray(doc.routes)) {
|
|
102
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} returned an unexpected shape`, "contract-shape");
|
|
103
|
+
}
|
|
104
|
+
const version = res.headers.get("x-catalyst-contract-version") ?? doc.contractVersion;
|
|
105
|
+
assertContractRange(version, range);
|
|
106
|
+
writeContractCache(ctx.home, {
|
|
107
|
+
etag: res.headers.get("etag"),
|
|
108
|
+
fetchedAt: ctx.now().toISOString(),
|
|
109
|
+
contractVersion: version,
|
|
110
|
+
doc,
|
|
111
|
+
});
|
|
112
|
+
return { doc, source: "network", ageSeconds: 0, path };
|
|
113
|
+
}
|
|
114
|
+
// ── helpers every verb uses ──────────────────────────────────────────────────────────────────────
|
|
115
|
+
export function teamByKey(doc, key) {
|
|
116
|
+
const wanted = key.toUpperCase();
|
|
117
|
+
const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === wanted);
|
|
118
|
+
if (!team) {
|
|
119
|
+
const known = doc.teams.map((t) => t.key ?? t.id).join(", ") || "none";
|
|
120
|
+
throw new CliError(`team "${key}" is not on this tenant's contract (teams: ${known})`, "team-unknown");
|
|
121
|
+
}
|
|
122
|
+
return team;
|
|
123
|
+
}
|
|
124
|
+
export function teamById(doc, id) {
|
|
125
|
+
return doc.teams.find((t) => t.id === id) ?? null;
|
|
126
|
+
}
|
|
127
|
+
/** The team a ticket identifier belongs to, by its `KEY-` prefix. */
|
|
128
|
+
export function teamForTicket(doc, ticket) {
|
|
129
|
+
const dash = ticket.indexOf("-");
|
|
130
|
+
if (dash <= 0)
|
|
131
|
+
throw new CliError(`"${ticket}" is not a ticket identifier (expected KEY-123)`, "ticket-shape");
|
|
132
|
+
return teamByKey(doc, ticket.slice(0, dash));
|
|
133
|
+
}
|
|
134
|
+
export function stageIdForSlot(team, slot) {
|
|
135
|
+
const stage = team.stages[slot];
|
|
136
|
+
if (!stage) {
|
|
137
|
+
const mapped = Object.keys(team.stages).join(", ") || "none";
|
|
138
|
+
throw new CliError(`slot "${slot}" is not mapped for team ${team.key ?? team.id} (mapped: ${mapped})`, "slot-unmapped");
|
|
139
|
+
}
|
|
140
|
+
if (!stage.stateStillExists) {
|
|
141
|
+
throw new CliError(`slot "${slot}" on team ${team.key ?? team.id} maps to state ${stage.stateId}, which no longer exists in Linear — fix the mapping in settings first`, "slot-stale");
|
|
142
|
+
}
|
|
143
|
+
return stage.stateId;
|
|
144
|
+
}
|
|
145
|
+
/** Resolve a label by name through the contract's ask/hold/release lists; an unknown name is returned
|
|
146
|
+
* as-is when it looks like an id (the contract only lists Catalyst's own labels). */
|
|
147
|
+
export function labelId(team, nameOrId) {
|
|
148
|
+
const all = [...team.labels.ask, ...team.labels.hold, ...team.labels.release];
|
|
149
|
+
const hit = all.find((l) => l.name === nameOrId);
|
|
150
|
+
if (hit) {
|
|
151
|
+
if (!hit.preferredId) {
|
|
152
|
+
throw new CliError(`label "${nameOrId}" is absent from this workspace (contract scope: ${hit.scope}) — create it in Linear first`, "label-absent");
|
|
153
|
+
}
|
|
154
|
+
return hit.preferredId;
|
|
155
|
+
}
|
|
156
|
+
return nameOrId;
|
|
157
|
+
}
|
|
158
|
+
/** The route whose last path segment is `name` (e.g. `issue-comment`). The path itself is never assumed. */
|
|
159
|
+
export function routePath(doc, name) {
|
|
160
|
+
const route = doc.routes.find((r) => r.path.split("/").filter(Boolean).at(-1) === name);
|
|
161
|
+
if (!route) {
|
|
162
|
+
throw new CliError(`the tenant contract serves no "${name}" route (routes: ${doc.routes.map((r) => r.path).join(", ")})`, "route-unknown");
|
|
163
|
+
}
|
|
164
|
+
return route.path;
|
|
165
|
+
}
|
|
166
|
+
export function bookkeepingPrefix(doc) {
|
|
167
|
+
return doc.vocabulary.bookkeeping.marker;
|
|
168
|
+
}
|
|
169
|
+
/** Walk a dotted path (`teams.0.stages`) into the document. */
|
|
170
|
+
export function pickPath(doc, path) {
|
|
171
|
+
let cur = doc;
|
|
172
|
+
for (const seg of path.split(".").filter(Boolean)) {
|
|
173
|
+
if (cur === null || typeof cur !== "object")
|
|
174
|
+
return undefined;
|
|
175
|
+
cur = cur[seg];
|
|
176
|
+
}
|
|
177
|
+
return cur;
|
|
178
|
+
}
|
package/dist/detach.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// detach.ts — re-spawn this CLI detached (replica start --detach). Isolated so a test can stub it.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
/** Spawn `process.execPath <argv>` detached from this process, stdio ignored, and let it go. */
|
|
4
|
+
export function detachSelf(argv, env = process.env) {
|
|
5
|
+
const child = spawn(process.execPath, argv, { detached: true, stdio: "ignore", env });
|
|
6
|
+
child.unref();
|
|
7
|
+
if (typeof child.pid !== "number")
|
|
8
|
+
throw new Error("detached spawn returned no pid");
|
|
9
|
+
return { pid: child.pid };
|
|
10
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// errors.ts — the three error classes the exit contract is built on: usage → 1, MeError/CliError → 2.
|
|
2
|
+
export class UsageError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export class CliError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
exitCode;
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, code,
|
|
9
|
+
/** Exit code the dispatcher returns for this error. Defaults to 2 (the CliError contract). */
|
|
10
|
+
exitCode = 2,
|
|
11
|
+
/** The HTTP status that produced it, when one did. */
|
|
12
|
+
status) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class MeError extends Error {
|
|
20
|
+
kind;
|
|
21
|
+
status;
|
|
22
|
+
constructor(message, kind, status) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// execution.ts — what Catalyst is doing: `explain <ticket>`, `running`, `queue`, `accounts`.
|
|
2
|
+
// `explain` turns one work-eligibility row into a paragraph a human can act on, translating every
|
|
3
|
+
// exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
|
|
4
|
+
import { flagBool, flagString, positionals } from "./args.js";
|
|
5
|
+
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
6
|
+
import { loadContract } from "./contract.js";
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./http.js";
|
|
9
|
+
/** Every exclusion reason the eligibility evaluator names, in plain English. */
|
|
10
|
+
export const EXCLUSION_REASONS = {
|
|
11
|
+
ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
|
|
12
|
+
pipeline_complete: "every relay phase has already completed",
|
|
13
|
+
not_at_dispatch_stage: "the card is not in the team's dispatch column, so nothing is offered until it is moved there",
|
|
14
|
+
not_at_pr_stage: "merge is next but the card is not in the PR column",
|
|
15
|
+
blocked: "a live blocking relation holds it — the blocker has to close first",
|
|
16
|
+
cooling_down: "the offered phase is parked; a callback or an operator releases it, not a clock",
|
|
17
|
+
lease_held: "a live container already holds the lease for the offered phase",
|
|
18
|
+
intake_lease_held: "a later phase is offered while an intake container still holds this ticket",
|
|
19
|
+
ask_ticket: "it carries an ask label, and a question is never work",
|
|
20
|
+
ask_shape_suspected: "its own text reads as a decision request; a human releases it with the not-an-ask label",
|
|
21
|
+
externally_claimed: "a worker outside the cloud has claimed it",
|
|
22
|
+
environment_check_required: "the repository's environment check has not been run",
|
|
23
|
+
environment_check_running: "the repository's environment check is in flight",
|
|
24
|
+
environment_check_failed: "the repository's environment check failed",
|
|
25
|
+
environment_check_expired: "the repository's environment check verdict aged out",
|
|
26
|
+
environment_check_hash_mismatch: "the environment changed since its recorded verdict",
|
|
27
|
+
scope_overlap: "its declared file scope intersects a ticket already in flight",
|
|
28
|
+
waiting_on: "a merge-gate failure with no remediable cause holds the card at PR",
|
|
29
|
+
branch_missing: "the ticket branch has never been seen, so a branch-dependent phase has nothing to clone",
|
|
30
|
+
branch_gone: "the ticket branch was deleted after it existed",
|
|
31
|
+
pr_merged: "its PR has merged and no other PR is open — nothing is left to clone or re-merge",
|
|
32
|
+
no_change_hold: "a remediate round changed nothing; a human comment or a new push releases it",
|
|
33
|
+
validate_class_spent: "this validate failure already spent its one repair round in this episode",
|
|
34
|
+
stale_failure_episode: "the ladder advanced after the recorded failure, so the round would repair a phase already passed",
|
|
35
|
+
runner_image_breaker: "fleet-wide: the live runner image is failing every phase at startup; dispatch resumes when the pin moves",
|
|
36
|
+
no_branch_to_remediate: "a remediate round is queued on a ticket with no recorded branch",
|
|
37
|
+
retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
|
|
38
|
+
routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
|
|
39
|
+
repo_paused: "an operator paused the repository",
|
|
40
|
+
remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired",
|
|
41
|
+
};
|
|
42
|
+
/** Every fail-closed unknown the evaluator names. */
|
|
43
|
+
export const UNKNOWN_REASONS = {
|
|
44
|
+
ordering_never_published: "the dispatch order has never been published for this team",
|
|
45
|
+
ordering_stale: "the dispatch order is stale",
|
|
46
|
+
workflow_mapping_unknown: "the team's workflow mapping could not be read",
|
|
47
|
+
ticket_unknown: "the ticket is not in the mirror",
|
|
48
|
+
dependency_snapshot_unknown: "the dependency snapshot has never completed",
|
|
49
|
+
blocker_unknown: "a blocking relation could not be resolved",
|
|
50
|
+
label_snapshot_unknown: "the labels poll has never completed, so ask labels cannot be read",
|
|
51
|
+
prior_artifact_unknown: "a prior phase artifact could not be read",
|
|
52
|
+
scope_unknown: "the ticket declared no scope, or the declaration was truncated",
|
|
53
|
+
scope_occupancy_unknown: "an in-flight ticket's state could not be resolved",
|
|
54
|
+
branch_snapshot_unknown: "the pull-request poll has never completed, so branch evidence cannot be read",
|
|
55
|
+
};
|
|
56
|
+
export const ADVISORIES = {
|
|
57
|
+
human_addressed_unlabeled_ask_suspect: "assigned to a human with no delegate: this may be an unlabelled ask",
|
|
58
|
+
};
|
|
59
|
+
export function describeReason(reason) {
|
|
60
|
+
if (!reason)
|
|
61
|
+
return "no reason was given";
|
|
62
|
+
return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
|
|
63
|
+
}
|
|
64
|
+
export function renderExplain(ticket, row, team) {
|
|
65
|
+
if (!row)
|
|
66
|
+
return `${ticket}: not in the ${team} eligibility explainer — the ticket is unknown to the mirror, terminal, or on another team.`;
|
|
67
|
+
const parts = [];
|
|
68
|
+
const pos = typeof row.position === "number" ? `position ${row.position}` : "no queue position";
|
|
69
|
+
const status = row.status ?? "unknown";
|
|
70
|
+
if (status === "offered" || status === "eligible") {
|
|
71
|
+
parts.push(`${ticket} is ${status} (${pos})${row.phase ? ` for phase ${row.phase}` : ""}.`);
|
|
72
|
+
}
|
|
73
|
+
else if (status === "unknown") {
|
|
74
|
+
parts.push(`${ticket} cannot be judged (${pos}): ${describeReason(row.unknown ?? row.reason)}.`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
parts.push(`${ticket} is ${status} (${pos}): ${describeReason(row.reason)}.`);
|
|
78
|
+
}
|
|
79
|
+
if (row.detail)
|
|
80
|
+
parts.push(`Detail: ${row.detail}.`);
|
|
81
|
+
if (row.marker)
|
|
82
|
+
parts.push(`Marker: ${row.marker}.`);
|
|
83
|
+
if (row.nextPhase)
|
|
84
|
+
parts.push(`Next phase would be ${row.nextPhase}.`);
|
|
85
|
+
if (row.release)
|
|
86
|
+
parts.push(`Release: ${row.release}.`);
|
|
87
|
+
if (row.failure && typeof row.failure === "object") {
|
|
88
|
+
const f = row.failure;
|
|
89
|
+
const bits = ["phase", "class", "attempts", "consecutive", "lastFailedAt", "message", "summary"]
|
|
90
|
+
.filter((k) => f[k] !== undefined && f[k] !== null)
|
|
91
|
+
.map((k) => `${k}=${typeof f[k] === "string" ? f[k] : JSON.stringify(f[k])}`);
|
|
92
|
+
parts.push(`Last failure: ${bits.length ? bits.join(", ") : JSON.stringify(f)}.`);
|
|
93
|
+
}
|
|
94
|
+
if (row.advisories && row.advisories.length > 0) {
|
|
95
|
+
parts.push(`Advisories: ${row.advisories.map((a) => ADVISORIES[a] ?? a).join("; ")}.`);
|
|
96
|
+
}
|
|
97
|
+
return parts.join(" ");
|
|
98
|
+
}
|
|
99
|
+
export async function cmdExplain(args, ctx) {
|
|
100
|
+
const [ticket] = positionals(args);
|
|
101
|
+
if (!ticket)
|
|
102
|
+
throw new UsageError("explain needs a ticket: explain <ticket>");
|
|
103
|
+
const cfg = requireConfig(ctx);
|
|
104
|
+
if (flagBool(args, "history")) {
|
|
105
|
+
ctx.stdout(`${ticket}: per-ticket execution history is not visible to an account key yet — read it at ${normalizeBaseUrl(cfg.baseUrl)}/settings`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
109
|
+
const dash = ticket.indexOf("-");
|
|
110
|
+
if (dash <= 0)
|
|
111
|
+
throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
|
|
112
|
+
const team = ticket.slice(0, dash).toUpperCase();
|
|
113
|
+
const api = apiClient(cfg, ctx);
|
|
114
|
+
const res = await api.getJson("/api/v1/work-eligibility", {
|
|
115
|
+
query: { team, capabilities: doc.ladder.phases.join(",") },
|
|
116
|
+
});
|
|
117
|
+
const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
|
|
118
|
+
const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
|
|
119
|
+
if (args.json) {
|
|
120
|
+
ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team) }));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
ctx.stdout(renderExplain(ticket, row, team));
|
|
124
|
+
}
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
export async function cmdRunning(args, ctx) {
|
|
128
|
+
const cfg = requireConfig(ctx);
|
|
129
|
+
const api = apiClient(cfg, ctx);
|
|
130
|
+
const [activity, roster, leases] = await Promise.all([
|
|
131
|
+
api.getJson("/api/v1/fleet-activity/current"),
|
|
132
|
+
api.getJson("/api/v1/agent-roster/current"),
|
|
133
|
+
api.getJson("/api/v1/lease/attributions"),
|
|
134
|
+
]);
|
|
135
|
+
const out = { fleetActivity: activity.body, agentRoster: roster.body, leaseAttributions: leases.body };
|
|
136
|
+
if (args.json) {
|
|
137
|
+
ctx.stdout(JSON.stringify(out));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
ctx.stdout(`fleet activity: ${JSON.stringify(out.fleetActivity)}`);
|
|
141
|
+
ctx.stdout(`agent roster: ${JSON.stringify(out.agentRoster)}`);
|
|
142
|
+
ctx.stdout(`lease attributions: ${JSON.stringify(out.leaseAttributions)}`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
export async function cmdQueue(args, ctx) {
|
|
146
|
+
const cfg = requireConfig(ctx);
|
|
147
|
+
const api = apiClient(cfg, ctx);
|
|
148
|
+
const res = await api.getJson("/api/v1/dispatch-queue/current", { query: { team: flagString(args, "team") } });
|
|
149
|
+
ctx.stdout(args.json ? JSON.stringify(res.body) : JSON.stringify(res.body, null, 2));
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
export function cmdAccounts(ctx) {
|
|
153
|
+
const cfg = requireConfig(ctx);
|
|
154
|
+
ctx.stdout(`coding-account status is not visible to an account key yet — read it at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { CliError, MeError } from "./errors.js";
|
|
3
|
+
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
|
+
export class ApiClient {
|
|
5
|
+
cfg;
|
|
6
|
+
fetchImpl;
|
|
7
|
+
constructor(cfg, fetchImpl) {
|
|
8
|
+
this.cfg = cfg;
|
|
9
|
+
this.fetchImpl = fetchImpl;
|
|
10
|
+
}
|
|
11
|
+
/** `path` is absolute under the origin (e.g. `/api/v1/issues`). */
|
|
12
|
+
url(path, query) {
|
|
13
|
+
const u = new URL(`${normalizeBaseUrl(this.cfg.baseUrl)}${path}`);
|
|
14
|
+
for (const [k, v] of Object.entries(query ?? {}))
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
u.searchParams.set(k, String(v));
|
|
17
|
+
return u.toString();
|
|
18
|
+
}
|
|
19
|
+
async getJson(path, opts = {}) {
|
|
20
|
+
const headers = {
|
|
21
|
+
authorization: `Bearer ${this.cfg.key}`,
|
|
22
|
+
accept: "application/json",
|
|
23
|
+
};
|
|
24
|
+
if (opts.etag)
|
|
25
|
+
headers["if-none-match"] = opts.etag;
|
|
26
|
+
const url = this.url(path, opts.query);
|
|
27
|
+
const res = await this.send(url, { method: "GET", headers });
|
|
28
|
+
if (opts.accept?.includes(res.status)) {
|
|
29
|
+
return { status: res.status, body: undefined, headers: res.headers };
|
|
30
|
+
}
|
|
31
|
+
await this.refuseIfNotOk(res, `GET ${path}`);
|
|
32
|
+
return { status: res.status, body: await this.parseBody(res, `GET ${path}`), headers: res.headers };
|
|
33
|
+
}
|
|
34
|
+
async postJson(path, body) {
|
|
35
|
+
const url = this.url(path);
|
|
36
|
+
const res = await this.send(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
authorization: `Bearer ${this.cfg.key}`,
|
|
40
|
+
accept: "application/json",
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
await this.refuseIfNotOk(res, `POST ${path}`);
|
|
46
|
+
return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
|
|
47
|
+
}
|
|
48
|
+
async send(url, init) {
|
|
49
|
+
try {
|
|
50
|
+
return await this.fetchImpl(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async parseBody(res, what) {
|
|
57
|
+
try {
|
|
58
|
+
return (await res.json());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new MeError(`${what} returned a non-JSON body`, "shape");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async refuseIfNotOk(res, what) {
|
|
65
|
+
if (res.ok)
|
|
66
|
+
return;
|
|
67
|
+
let reason = "";
|
|
68
|
+
let raw = "";
|
|
69
|
+
try {
|
|
70
|
+
raw = await res.text();
|
|
71
|
+
const body = JSON.parse(raw);
|
|
72
|
+
reason = body.reason ?? body.error ?? body.message ?? "";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
reason = raw.slice(0, 200);
|
|
76
|
+
}
|
|
77
|
+
if (res.status === 401) {
|
|
78
|
+
throw new CliError(`${what} failed (401): credential not accepted — ask your tenant admin for a valid account key`, "unauthorized", 2, 401);
|
|
79
|
+
}
|
|
80
|
+
if (res.status === 403) {
|
|
81
|
+
throw new CliError(`${what} failed (403): this key may not reach that route${reason ? ` (${reason})` : ""} — a machine-gated route needs an account key (ctc_acct_…), not a workstation key`, "forbidden", 2, 403);
|
|
82
|
+
}
|
|
83
|
+
if (res.status === 429) {
|
|
84
|
+
const retryAfter = res.headers.get("retry-after");
|
|
85
|
+
const budget = /budget/i.test(raw) || /budget/i.test(reason);
|
|
86
|
+
throw new CliError(`${what} refused (429)${budget ? ": the per-host daily write budget is spent" : ": rate limited"}${reason ? ` — ${reason}` : ""}${retryAfter ? ` — retry after ${retryAfter}s` : ""}`, budget ? "budget" : "rate-limited", 2, 429);
|
|
87
|
+
}
|
|
88
|
+
throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function apiClient(cfg, ctx) {
|
|
92
|
+
return new ApiClient(cfg, ctx.fetch);
|
|
93
|
+
}
|
|
94
|
+
/** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
|
|
95
|
+
export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
96
|
+
const url = `${normalizeBaseUrl(baseUrl)}/api/v1/me`;
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetchImpl(url, {
|
|
100
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" },
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
106
|
+
}
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
let reason;
|
|
109
|
+
try {
|
|
110
|
+
const body = (await res.json());
|
|
111
|
+
reason = body.reason ?? body.error ?? "";
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
reason = "";
|
|
115
|
+
}
|
|
116
|
+
const detail = res.status === 401
|
|
117
|
+
? "credential not accepted — ask your tenant admin for a valid account key"
|
|
118
|
+
: reason || `HTTP ${res.status}`;
|
|
119
|
+
throw new MeError(`GET /me failed (${res.status}): ${detail}`, "http", res.status);
|
|
120
|
+
}
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = (await res.json());
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new MeError("GET /me returned a non-JSON body", "shape");
|
|
127
|
+
}
|
|
128
|
+
if (typeof body.account !== "string" ||
|
|
129
|
+
typeof body.slug !== "string" ||
|
|
130
|
+
typeof body.name !== "string" ||
|
|
131
|
+
(body.permissions !== null && !Array.isArray(body.permissions)) ||
|
|
132
|
+
(body.principal !== "service" && body.principal !== "session")) {
|
|
133
|
+
throw new MeError("GET /me returned an unexpected shape", "shape");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
account: body.account,
|
|
137
|
+
slug: body.slug,
|
|
138
|
+
name: body.name,
|
|
139
|
+
permissions: body.permissions,
|
|
140
|
+
principal: body.principal,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// prompt.ts — the one interactive read in the CLI: asking for the account key on a terminal without
|
|
2
|
+
// echoing it. The streams are parameters so the tests can drive it without a real TTY.
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
/** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
|
|
5
|
+
export function stdinIsTty() {
|
|
6
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Read one line without echoing it. Everything readline would write after the question itself goes
|
|
10
|
+
* to a muted stream, so the key never reaches the screen or a scrollback buffer; the caller still
|
|
11
|
+
* gets it back, trimmed.
|
|
12
|
+
*/
|
|
13
|
+
export async function promptSecret(question, io) {
|
|
14
|
+
const input = io?.input ?? process.stdin;
|
|
15
|
+
const target = io?.output ?? process.stdout;
|
|
16
|
+
let muted = false;
|
|
17
|
+
const output = Object.create(target);
|
|
18
|
+
output.write = (chunk) => {
|
|
19
|
+
if (!muted)
|
|
20
|
+
target.write(chunk);
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
24
|
+
try {
|
|
25
|
+
const answer = await new Promise((resolve) => {
|
|
26
|
+
rl.question(question, (value) => resolve(value));
|
|
27
|
+
muted = true;
|
|
28
|
+
});
|
|
29
|
+
target.write("\n");
|
|
30
|
+
return answer.trim();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rl.close();
|
|
34
|
+
}
|
|
35
|
+
}
|