@catalyst-cloud/cli 0.13.1 → 0.14.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/dist/args.js +291 -69
  4. package/dist/capabilities.js +2 -0
  5. package/dist/cli.js +109 -17
  6. package/dist/config.js +3 -1
  7. package/dist/env/draft.js +134 -0
  8. package/dist/env/legacy.js +54 -0
  9. package/dist/env/parse-env.js +50 -0
  10. package/dist/env/settings-toml.js +66 -0
  11. package/dist/env.js +126 -26
  12. package/dist/legacy.js +16 -17
  13. package/dist/onboard-consent.js +65 -0
  14. package/dist/onboard.js +836 -0
  15. package/dist/project.js +75 -13
  16. package/dist/ready.js +11 -1
  17. package/dist/secret.js +24 -3
  18. package/dist/var.js +118 -0
  19. package/package.json +3 -2
  20. package/skills/catalyst-github/SKILL.md +1 -1
  21. package/skills/catalyst-linear/SKILL.md +1 -1
  22. package/skills/catalyst-onboard/SKILL.md +1 -1
  23. package/skills/catalyst-onboard/references/declaring-a-repository.md +19 -12
  24. package/skills/catalyst-onboard/references/reading-ready.md +1 -1
  25. package/skills/catalyst-onboard/references/the-one-path.md +4 -4
  26. package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
  27. package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
  28. package/skills/catalyst-onboard/scripts/where-am-i.mjs +885 -196
  29. package/skills/run-this-project/SKILL.md +1 -1
  30. package/skills/unstick/SKILL.md +1 -1
  31. package/skills/what-needs-me/SKILL.md +1 -1
  32. package/skills/what-this-repo-needs/SKILL.md +4 -4
  33. package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
  34. package/skills/whats-happening/SKILL.md +19 -35
  35. package/skills/whats-happening/references/coding-accounts.md +6 -19
  36. package/skills/whats-happening/references/routing-work.md +4 -15
  37. package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
  38. package/skills/whats-happening/references/stages-and-mapping.md +12 -18
  39. package/skills/whats-happening/references/status-reply.md +11 -29
  40. package/skills/whats-happening/references/the-ladder.md +15 -25
  41. package/skills/whats-happening/references/what-runs-next.md +7 -26
  42. package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
  43. package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
  44. package/dist/env/declaration-rules.js +0 -163
@@ -7,14 +7,26 @@
7
7
  // owner's name on it, never as something the person at this keyboard can fix by running anything.
8
8
  //
9
9
  // Every count and every name below is read off what a verb printed. Nothing here is written down.
10
- import { cliTarget, CONNECT_LINE, parseFlags, printHelp, runCli, tryJson, tryLoadConfig } from "./lib/cli.mjs";
10
+ import {
11
+ cliTarget,
12
+ CONNECT_LINE,
13
+ parseFlags,
14
+ printHelp,
15
+ runCli,
16
+ tryJson,
17
+ tryLoadConfig,
18
+ } from "./lib/cli.mjs";
19
+ import { projectCoverageLines } from "./lib/project-coverage.mjs";
11
20
  import { spawnSync } from "node:child_process";
12
21
  import { runLocalSync } from "./local-sync.mjs";
13
22
 
14
23
  const SPEC = {
15
24
  next: { help: "print only the single next step" },
16
25
  json: { help: "one JSON document instead of the report" },
17
- repo: { value: true, help: "a checkout of a mapped repository on this machine: its agent setup (AGENTS.md block, portable layout) is read and reported, never changed" },
26
+ repo: {
27
+ value: true,
28
+ help: "a checkout of a mapped repository on this machine: its agent setup (AGENTS.md block, portable layout) is read and reported, never changed",
29
+ },
18
30
  };
19
31
  const NOTES = [
20
32
  "Reads, in this order: `status` (machine), `ready --json` (machine checks and project checks, kept apart),",
@@ -32,7 +44,11 @@ const NOTES = [
32
44
 
33
45
  const { help, flags, positionals } = parseFlags(process.argv.slice(2), SPEC);
34
46
  if (help) {
35
- printHelp("node scripts/where-am-i.mjs [--next] [--json] [--repo <path>]", SPEC, NOTES);
47
+ printHelp(
48
+ "node scripts/where-am-i.mjs [--next] [--json] [--repo <path>]",
49
+ SPEC,
50
+ NOTES,
51
+ );
36
52
  process.exit(0);
37
53
  }
38
54
  if (positionals.length > 0) {
@@ -55,13 +71,32 @@ let repositoryRegistered = false;
55
71
  let capabilities = null;
56
72
  let personRole = null;
57
73
  const canManage = () => personRole === "admin" || personRole === "owner";
58
- const capability = (verb) => (Array.isArray(capabilities) ? capabilities.find((c) => c && c.verb === verb) : undefined);
59
- const verbAvailable = (verb) => { const c = capability(verb); return c?.availability === "available" && (c.needs === "member" || canManage()); };
60
- const verbAdminOnly = (verb) => { const c = capability(verb); return c?.availability === "available" && c.needs === "admin" && !canManage(); };
74
+ const capability = (verb) =>
75
+ Array.isArray(capabilities)
76
+ ? capabilities.find((c) => c && c.verb === verb)
77
+ : undefined;
78
+ const verbAvailable = (verb) => {
79
+ const c = capability(verb);
80
+ return (
81
+ c?.availability === "available" && (c.needs === "member" || canManage())
82
+ );
83
+ };
84
+ const verbAdminOnly = (verb) => {
85
+ const c = capability(verb);
86
+ return c?.availability === "available" && c.needs === "admin" && !canManage();
87
+ };
61
88
  // `blocking` is false for a finding that is real and reportable but does not stop the next step —
62
89
  // an unmatched Linear identity is the one that matters: it must be said, and it must not become the
63
90
  // thing the person is told to go and do before they can map a project.
64
- const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) => {
91
+ const add = (
92
+ part,
93
+ instrument,
94
+ verdict,
95
+ lines,
96
+ owner = null,
97
+ where = null,
98
+ blocking = true,
99
+ ) => {
65
100
  const p = { part, instrument, verdict, lines, owner, where, blocking };
66
101
  parts.push(p);
67
102
  return p;
@@ -73,8 +108,12 @@ const add = (part, instrument, verdict, lines, owner = null, where = null, block
73
108
  let contractMismatch = null;
74
109
  const noteMismatch = (text) => {
75
110
  const m =
76
- (text ?? "").match(/serves contract version (\S+) but this bundle accepts (\S+)/) ??
77
- (text ?? "").match(/contract: version (\S+) is outside this bundle's range (\S+)/);
111
+ (text ?? "").match(
112
+ /serves contract version (\S+) but this bundle accepts (\S+)/,
113
+ ) ??
114
+ (text ?? "").match(
115
+ /contract: version (\S+) is outside this bundle's range (\S+)/,
116
+ );
78
117
  if (m !== null) contractMismatch ??= { served: m[1], accepts: m[2] };
79
118
  return m !== null;
80
119
  };
@@ -88,10 +127,15 @@ const readContract = (args) => {
88
127
  const status = runCli(["status"]);
89
128
  if (!status.ran) {
90
129
  process.stderr.write(`${status.stderr}\n`);
91
- process.stderr.write(`the catalyst CLI could not be started. Install it, then run this again.\n`);
130
+ process.stderr.write(
131
+ `the catalyst CLI could not be started. Install it, then run this again.\n`,
132
+ );
92
133
  process.exit(2);
93
134
  }
94
- const statusLines = status.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
135
+ const statusLines = status.stdout
136
+ .split("\n")
137
+ .map((l) => l.trim())
138
+ .filter(Boolean);
95
139
  const field = (name) => {
96
140
  const line = statusLines.find((l) => l.startsWith(`${name}:`));
97
141
  return line ? line.slice(name.length + 1).trim() : null;
@@ -102,9 +146,12 @@ const connected = tenant !== null && tryLoadConfig() !== null;
102
146
  // The app and the API share an origin; the base URL is whatever this machine was connected to, so
103
147
  // no page link in this report is a host anyone typed from memory.
104
148
  const cloud = api === null ? null : api.split(" ")[0].replace(/\/+$/, "");
105
- const link = (path) => (cloud === null ? `<your cloud>${path}` : `${cloud}${path}`);
149
+ const link = (path) =>
150
+ cloud === null ? `<your cloud>${path}` : `${cloud}${path}`;
106
151
 
107
- const machineLines = connected ? statusLines : [statusLines[0] ?? "status printed nothing"];
152
+ const machineLines = connected
153
+ ? statusLines
154
+ : [statusLines[0] ?? "status printed nothing"];
108
155
  let machineVerdict = connected ? "ok" : "unfinished";
109
156
 
110
157
  // `ready` is ONE verdict over two parts; split it by check id before anything is reported. A check
@@ -117,19 +164,30 @@ if (connected) {
117
164
  const report = tryJson(ready.stdout);
118
165
  const checks = Array.isArray(report?.checks) ? report.checks : null;
119
166
  if (checks === null) {
120
- machineLines.push(`ready: could not be read (${(ready.stderr || ready.stdout).trim().split("\n")[0] ?? "no output"})`);
167
+ machineLines.push(
168
+ `ready: could not be read (${(ready.stderr || ready.stdout).trim().split("\n")[0] ?? "no output"})`,
169
+ );
121
170
  machineVerdict = "unfinished";
122
171
  } else {
123
- projectChecks = checks.filter((c) => typeof c.id === "string" && c.id.startsWith("team:"));
124
- const machineChecks = checks.filter((c) => !(typeof c.id === "string" && c.id.startsWith("team:")));
172
+ projectChecks = checks.filter(
173
+ (c) => typeof c.id === "string" && c.id.startsWith("team:"),
174
+ );
175
+ const machineChecks = checks.filter(
176
+ (c) => !(typeof c.id === "string" && c.id.startsWith("team:")),
177
+ );
125
178
  const failed = machineChecks.filter((c) => !c.ok && !c.note);
126
179
  // A refused contract version is printed without the CLI's "update" fix: whether an update exists
127
180
  // is checked below, once, against what npm has published.
128
181
  const refused = (c) => !c.ok && !c.note && noteMismatch(c.line);
129
- for (const c of machineChecks) machineLines.push(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}${!c.ok && !c.note && c.fix && !refused(c) ? ` — fix: ${c.fix}` : ""}`);
182
+ for (const c of machineChecks)
183
+ machineLines.push(
184
+ `${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}${!c.ok && !c.note && c.fix && !refused(c) ? ` — fix: ${c.fix}` : ""}`,
185
+ );
130
186
  if (failed.length > 0) {
131
187
  machineVerdict = "unfinished";
132
- machineFix = failed.find((c) => typeof c.fix === "string" && !refused(c))?.fix ?? null;
188
+ machineFix =
189
+ failed.find((c) => typeof c.fix === "string" && !refused(c))?.fix ??
190
+ null;
133
191
  }
134
192
  }
135
193
  // Supplemental only: local caches are optional and do not change setup completion or --next.
@@ -139,7 +197,11 @@ if (connected) {
139
197
  );
140
198
  } else {
141
199
  localSync = {
142
- assessment: { verdict: "unknown", current: false, reason: "connect this machine before local freshness can be checked" },
200
+ assessment: {
201
+ verdict: "unknown",
202
+ current: false,
203
+ reason: "connect this machine before local freshness can be checked",
204
+ },
143
205
  started: false,
144
206
  recovery: "catalyst login",
145
207
  };
@@ -152,11 +214,17 @@ add(
152
214
  "you, on this machine",
153
215
  // The keyless login alone: a person who holds a personal key already knows the key form. A machine
154
216
  // that holds the CLI is told its own command; only one with no CLI at all gets the npx line.
155
- connected ? null : cliTarget().recorded ? "catalyst login" : CONNECT_LINE.split(" (or")[0],
217
+ connected
218
+ ? null
219
+ : cliTarget().recorded
220
+ ? "catalyst login"
221
+ : CONNECT_LINE.split(" (or")[0],
156
222
  );
157
223
  if (connected) {
158
224
  const capDoc = tryJson(runCli(["capabilities", "--json"]).stdout);
159
- capabilities = Array.isArray(capDoc?.capabilities) ? capDoc.capabilities : null;
225
+ capabilities = Array.isArray(capDoc?.capabilities)
226
+ ? capDoc.capabilities
227
+ : null;
160
228
  }
161
229
  // Leftovers of the old local Catalyst runtime (its plugin, jobs, commands, state), from the CLI's own
162
230
  // fixed list. Reported on the machine part and as a note; removal is the person's yes, never this script's.
@@ -167,38 +235,67 @@ if (verbAvailable("legacy")) {
167
235
  if (found.length > 0) {
168
236
  const machine = parts.find((p) => p.part === "machine");
169
237
  const names = found.map((f) => `${f.kind} ${f.name}`);
170
- machine?.lines.push(`note leftovers of the old local Catalyst runtime (${found.length}): ${names.slice(0, 6).join(", ")}${found.length > 6 ? ", …" : ""}; removing them is strongly recommended: catalyst legacy --remove (the data folders are a separate yes, --data)`);
171
- legacyNote = `this machine still carries ${found.length} piece${found.length === 1 ? "" : "s"} of the old local Catalyst runtime (${names.slice(0, 4).join(", ")}${found.length > 4 ? ", …" : ""}); offer catalyst legacy --remove once, strongly recommended, then --data as a separate question`;
238
+ machine?.lines.push(
239
+ `note leftovers of the old local Catalyst runtime (${found.length}): ${names.slice(0, 6).join(", ")}${found.length > 6 ? ", …" : ""}; removing them is strongly recommended: catalyst legacy --remove (all data folders are kept)`,
240
+ );
241
+ legacyNote = `this machine still carries ${found.length} piece${found.length === 1 ? "" : "s"} of the old local Catalyst runtime (${names.slice(0, 4).join(", ")}${found.length > 4 ? ", …" : ""}); offer catalyst legacy --remove once, strongly recommended, keep all shared data folders`;
172
242
  }
173
243
  }
174
244
 
175
245
  // ── person ────────────────────────────────────────────────────────────────────────────────────────
176
246
  if (!connected) {
177
- add("person", "catalyst me", "unreadable", ["not readable until this machine is connected"], null, null);
247
+ add(
248
+ "person",
249
+ "catalyst me",
250
+ "unreadable",
251
+ ["not readable until this machine is connected"],
252
+ null,
253
+ null,
254
+ );
178
255
  } else {
179
256
  const me = runCli(["me", "--json"]);
180
257
  const doc = tryJson(me.stdout);
181
- const user = doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
258
+ const user =
259
+ doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
182
260
  if (doc === null) {
183
- add("person", "catalyst me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
261
+ add(
262
+ "person",
263
+ "catalyst me",
264
+ "unreadable",
265
+ [
266
+ `me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`,
267
+ ],
268
+ null,
269
+ null,
270
+ ).next =
184
271
  "run this again; catalyst me could not be read, so no step after it can be named yet";
185
272
  } else if (user === null) {
186
273
  add(
187
274
  "person",
188
275
  "catalyst me",
189
276
  "unfinished",
190
- ["this credential names no person — it is a host credential, so nothing an agent writes will carry a name"],
277
+ [
278
+ "this credential names no person — it is a host credential, so nothing an agent writes will carry a name",
279
+ ],
191
280
  "you: connect again with your own login",
192
281
  CONNECT_LINE,
193
282
  );
194
283
  } else {
195
- const matched = typeof user.linearUserId === "string" && user.linearUserId !== "";
284
+ const matched =
285
+ typeof user.linearUserId === "string" && user.linearUserId !== "";
196
286
  personRole = typeof user.role === "string" ? user.role : null;
197
287
  const grantLines = [];
198
288
  for (const provider of ["linear", "github"]) {
199
- const read = runCli(["connections", "personal", provider, "status", "--json"]);
289
+ const read = runCli([
290
+ "connections",
291
+ "personal",
292
+ provider,
293
+ "status",
294
+ "--json",
295
+ ]);
200
296
  const result = tryJson(read.stdout);
201
- const outcome = typeof result?.outcome === "string" ? result.outcome : "unreadable";
297
+ const outcome =
298
+ typeof result?.outcome === "string" ? result.outcome : "unreadable";
202
299
  personalConnections[provider] = outcome;
203
300
  if (outcome === "connected") {
204
301
  grantLines.push(`personal ${provider}: connected`);
@@ -206,23 +303,43 @@ if (!connected) {
206
303
  personalGrantIncomplete = true;
207
304
  if (provider === "linear") personalLinearIncomplete = true;
208
305
  else personalGithubIncomplete = true;
209
- grantLines.push(`personal ${provider}: ${outcome === "absent" ? "not connected" : "expired"}`);
210
- if (provider === "linear") personalNext ??= "connect your personal linear account";
306
+ grantLines.push(
307
+ `personal ${provider}: ${outcome === "absent" ? "not connected" : "expired"}`,
308
+ );
309
+ if (provider === "linear")
310
+ personalNext ??= "connect your personal linear account";
211
311
  } else {
212
312
  personalGrantIncomplete = true;
213
313
  if (provider === "linear") personalLinearIncomplete = true;
214
314
  else personalGithubIncomplete = true;
215
- grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`);
216
- if (provider === "linear") personalNext ??= "re-check your personal linear connection";
315
+ grantLines.push(
316
+ `personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`,
317
+ );
318
+ if (provider === "linear")
319
+ personalNext ??= "re-check your personal linear connection";
217
320
  }
218
321
  }
219
322
  add(
220
323
  "person",
221
324
  "catalyst me, and catalyst connections personal <provider> status --json",
222
325
  matched && !personalGrantIncomplete ? "ok" : "unfinished",
223
- [`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
224
- personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
225
- personalGrantIncomplete ? "catalyst connections personal <provider> start or status" : matched ? null : link("/settings/account"),
326
+ [
327
+ `${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`,
328
+ matched
329
+ ? "Linear identity matched"
330
+ : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.",
331
+ ...grantLines,
332
+ ],
333
+ personalGrantIncomplete
334
+ ? "you"
335
+ : matched
336
+ ? null
337
+ : "a workspace owner or admin",
338
+ personalGrantIncomplete
339
+ ? "catalyst connections personal <provider> start or status"
340
+ : matched
341
+ ? null
342
+ : link("/settings/account"),
226
343
  false,
227
344
  );
228
345
  }
@@ -230,15 +347,38 @@ if (!connected) {
230
347
 
231
348
  // ── account ───────────────────────────────────────────────────────────────────────────────────────
232
349
  if (!connected) {
233
- add("account", "catalyst contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
350
+ add(
351
+ "account",
352
+ "catalyst contract --path account",
353
+ "unreadable",
354
+ ["not readable until this machine is connected"],
355
+ null,
356
+ null,
357
+ );
234
358
  } else {
235
359
  const acct = readContract(["contract", "--path", "account", "--json"]);
236
360
  const doc = tryJson(acct.stdout);
237
361
  if (doc === null) {
238
- add("account", "catalyst contract --path account", "unreadable", ["the account block could not be read — try: catalyst contract --refresh"], null, null).next =
362
+ add(
363
+ "account",
364
+ "catalyst contract --path account",
365
+ "unreadable",
366
+ [
367
+ "the account block could not be read — try: catalyst contract --refresh",
368
+ ],
369
+ null,
370
+ null,
371
+ ).next =
239
372
  "refresh the contract (catalyst contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
240
373
  } else {
241
- const workspace = typeof doc.linearWorkspaceSlug === "string" && doc.linearWorkspaceSlug !== "" ? doc.linearWorkspaceSlug : typeof doc.linearWorkspaceId === "string" && doc.linearWorkspaceId !== "" ? doc.linearWorkspaceId : null;
374
+ const workspace =
375
+ typeof doc.linearWorkspaceSlug === "string" &&
376
+ doc.linearWorkspaceSlug !== ""
377
+ ? doc.linearWorkspaceSlug
378
+ : typeof doc.linearWorkspaceId === "string" &&
379
+ doc.linearWorkspaceId !== ""
380
+ ? doc.linearWorkspaceId
381
+ : null;
242
382
  workspaceResolved = workspace !== null;
243
383
  // The declaration is the one part of the account a key can also READ — and it is the one part a
244
384
  // key can WRITE, so it is reported here rather than left to the settings page like the rest.
@@ -246,14 +386,29 @@ if (!connected) {
246
386
  const env = runCli(["environment", "read", "--json"]);
247
387
  const envDoc = tryJson(env.stdout);
248
388
  if (envDoc === null) {
249
- envLines.push(`environment: not readable (${(env.stderr || env.stdout).trim().split("\n")[0] ?? "no output"})`);
389
+ envLines.push(
390
+ `environment: not readable (${(env.stderr || env.stdout).trim().split("\n")[0] ?? "no output"})`,
391
+ );
250
392
  } else if (envDoc.current === null || envDoc.current === undefined) {
251
- envLines.push("environment: nothing declared yet — declare it with `catalyst environment propose --file <path> --approve`");
393
+ envLines.push(
394
+ "environment: nothing declared yet — declare it with `catalyst environment propose --file <path> --approve`",
395
+ );
252
396
  } else {
253
- envLines.push(`environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`);
254
- envLines.push(envDoc.delivered ? `environment delivered to phases: revision ${envDoc.delivered.revision}` : "environment delivered to phases: nothing yet");
255
- const unresolved = Array.isArray(envDoc.unresolvedReferences) ? envDoc.unresolvedReferences : [];
256
- if (unresolved.length > 0) envLines.push(`environment names values this tenant does not carry yet: ${unresolved.join(", ")}`);
397
+ envLines.push(
398
+ `environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`,
399
+ );
400
+ envLines.push(
401
+ envDoc.delivered
402
+ ? `environment delivered to phases: revision ${envDoc.delivered.revision}`
403
+ : "environment delivered to phases: nothing yet",
404
+ );
405
+ const unresolved = Array.isArray(envDoc.unresolvedReferences)
406
+ ? envDoc.unresolvedReferences
407
+ : [];
408
+ if (unresolved.length > 0)
409
+ envLines.push(
410
+ `environment names values this tenant does not carry yet: ${unresolved.join(", ")}`,
411
+ );
257
412
  }
258
413
  add(
259
414
  "account",
@@ -261,7 +416,9 @@ if (!connected) {
261
416
  workspace === null ? "unfinished" : "ok",
262
417
  [
263
418
  `${doc.name ?? "(unnamed tenant)"} (${doc.slug ?? "?"})`,
264
- workspace === null ? "no Linear workspace resolved on this contract" : `Linear workspace resolved: ${workspace}`,
419
+ workspace === null
420
+ ? "no Linear workspace resolved on this contract"
421
+ : `Linear workspace resolved: ${workspace}`,
265
422
  "the GitHub App install is NOT carried here — the connections page is the only place that shows it",
266
423
  ...envLines,
267
424
  ],
@@ -278,12 +435,14 @@ let teamRows = null;
278
435
  let readinessMetaCache;
279
436
  const readinessMeta = () => {
280
437
  if (readinessMetaCache === undefined) {
281
- const m = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
438
+ const m = tryJson(
439
+ readContract(["contract", "--path", "readinessChecks", "--json"]).stdout,
440
+ );
282
441
  readinessMetaCache = Array.isArray(m) ? m : [];
283
442
  }
284
443
  return readinessMetaCache;
285
444
  };
286
- // Linear's own Git automation rules, by check id, as the Settings → Workflow → Git automation screen
445
+ // Linear's own Git automation rules, by check id, as the Settings → Teams → <team> → Workflow → Workflows & automations screen
287
446
  // names them. A rule that moves a card is a conflict with the stage Catalyst manages, and no Catalyst
288
447
  // key can change a Linear automation rule: the fix is in Linear, by a Linear admin.
289
448
  const LINEAR_AUTOMATION_RULES = {
@@ -296,27 +455,49 @@ let blockedProjects = [];
296
455
 
297
456
  // ── projects (a project is one Linear team) ───────────────────────────────────────────────────────
298
457
  if (!connected) {
299
- add("projects", "catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
458
+ add(
459
+ "projects",
460
+ "catalyst contract --path teams",
461
+ "unreadable",
462
+ ["not readable until this machine is connected"],
463
+ null,
464
+ null,
465
+ );
300
466
  } else {
301
467
  // The mapping write updates the cloud before the cached contract's dispatchGate projection.
302
468
  // Revalidate this one read so a successful save does not appear unmapped on the next step.
303
- const teams = readContract(["contract", "--refresh", "--path", "teams", "--json"]);
469
+ const teams = readContract([
470
+ "contract",
471
+ "--refresh",
472
+ "--path",
473
+ "teams",
474
+ "--json",
475
+ ]);
304
476
  const doc = tryJson(teams.stdout);
305
477
  const rows = Array.isArray(doc) ? doc : null;
306
478
  teamRows = rows;
307
479
  if (rows === null) {
308
- add("projects", "catalyst contract --path teams", "unreadable", ["the project list could not be read — try: catalyst contract --refresh"], null, null).next =
480
+ add(
481
+ "projects",
482
+ "catalyst contract --path teams",
483
+ "unreadable",
484
+ ["the project list could not be read — try: catalyst contract --refresh"],
485
+ null,
486
+ null,
487
+ ).next =
309
488
  "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
310
489
  } else {
311
- const lines = [`${rows.length} mapped`];
312
- for (const t of rows) {
313
- const key = t.key ?? t.id ?? "(unkeyed)";
314
- const readiness = t.readiness ?? {};
315
- const bad = Array.isArray(readiness.checks) ? readiness.checks.filter((c) => c.state !== "pass") : [];
316
- lines.push(`${key}: ${readiness.status ?? "unknown"}${bad.length ? ` — ${bad.map((c) => `${c.id} ${c.state}`).join(", ")}` : ""}`);
317
- }
318
- for (const c of projectChecks) lines.push(`${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`);
319
- lines.push("MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects. Run catalyst team list to inspect the live list without checking readiness.");
490
+ const inventory = runCli(["project", "list", "--json"]);
491
+ const projects = tryJson(inventory.stdout);
492
+ const lines = Array.isArray(projects)
493
+ ? projectCoverageLines(projects, rows).lines
494
+ : [
495
+ `the full project inventory could not be read (${(inventory.stderr || inventory.stdout).trim().split("\n")[0] ?? "no output"})`,
496
+ ];
497
+ for (const c of projectChecks)
498
+ lines.push(
499
+ `${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`,
500
+ );
320
501
  // A project is set up when its dispatch gate is open (its stages are mapped), or when its
321
502
  // readiness reads ready. Readiness alone kept `--next` on "map its stages" for a tenant whose
322
503
  // gates were open: readiness stays "unchecked" until someone presses Re-check, and a new team
@@ -324,34 +505,77 @@ if (!connected) {
324
505
  // ⛔ A team whose readiness reads `blocked` is NOT set up, whatever its gate says: a blocking
325
506
  // check (a Linear automation that moves cards off Catalyst's stages, a missing OAuth scope) breaks
326
507
  // every run in it. The step is that check's own fix, by its own owner, then a re-check.
327
- const isBlocking = (c) => readinessMeta().length === 0 || readinessMeta().find((r) => r.id === c.id)?.severity === "blocking";
508
+ const isBlocking = (c) =>
509
+ readinessMeta().length === 0 ||
510
+ readinessMeta().find((r) => r.id === c.id)?.severity === "blocking";
328
511
  for (const t of rows.filter((r) => r.readiness?.status === "blocked")) {
329
512
  const key = t.key ?? t.id ?? "(unkeyed)";
330
- const fails = (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.state === "fail" && isBlocking(c));
513
+ const fails = (
514
+ Array.isArray(t.readiness?.checks) ? t.readiness.checks : []
515
+ ).filter((c) => c.state === "fail" && isBlocking(c));
331
516
  const ids = fails.map((c) => c.id);
332
- const rules = ids.filter((id) => id in LINEAR_AUTOMATION_RULES).map((id) => LINEAR_AUTOMATION_RULES[id]);
517
+ const rules = ids
518
+ .filter((id) => id in LINEAR_AUTOMATION_RULES)
519
+ .map((id) => LINEAR_AUTOMATION_RULES[id]);
333
520
  const others = ids.filter((id) => !(id in LINEAR_AUTOMATION_RULES));
334
- const ownerRow = fails.map((c) => readinessMeta().find((r) => r.id === c.id)).find((r) => r && typeof r.fixedByLine === "string");
335
- const owner = ownerRow ? ownerRow.fixedByLine : "a workspace owner or admin";
521
+ const ownerRow = fails
522
+ .map((c) => readinessMeta().find((r) => r.id === c.id))
523
+ .find((r) => r && typeof r.fixedByLine === "string");
524
+ const owner = ownerRow
525
+ ? ownerRow.fixedByLine
526
+ : "a workspace owner or admin";
336
527
  const how = [
337
- rules.length > 0 ? `in Linear, open Settings → Teams → ${key} → Workflow → Git automation and set ${rules.join(", ")} to No action (no Catalyst key can change a Linear automation rule)` : null,
528
+ rules.length > 0
529
+ ? `in Linear, open Settings → Teams → ${key} → Workflow → Workflows & automations → Pull request and commit automations and set ${rules.join(", ")} to No action (Catalyst does not yet offer to change these rules)`
530
+ : null,
338
531
  others.length > 0 ? `${others.join(", ")}: ${owner}` : null,
339
- ].filter(Boolean).join("; ");
340
- lines.push(`${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`);
341
- blockedProjects.push({ key, action: `fix ${key}'s blocking check${ids.length === 1 ? "" : "s"} (${ids.join(", ") || "see the projects lines"}): ${how || owner}; then run catalyst team check ${key} (or press Re-check) and run this again`, owner });
532
+ ]
533
+ .filter(Boolean)
534
+ .join("; ");
535
+ lines.push(
536
+ `${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`,
537
+ );
538
+ blockedProjects.push({
539
+ key,
540
+ action: `fix ${key}'s blocking check${ids.length === 1 ? "" : "s"} (${ids.join(", ") || "see the projects lines"}): ${how || owner}; then run catalyst team check ${key} (or press Re-check) and run this again`,
541
+ owner,
542
+ });
342
543
  }
343
- const setUp = (t) => t.readiness?.status !== "blocked" && (t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready");
544
+ const setUp = (t) =>
545
+ t.readiness?.status !== "blocked" &&
546
+ (t.dispatchGate?.status === "open" ||
547
+ (t.readiness?.status ?? "unchecked") === "ready");
344
548
  for (const t of rows) {
345
- if (t.dispatchGate?.status === "open" && (t.readiness?.status ?? "unchecked") === "unchecked") {
346
- lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: run catalyst team check ${t.key ?? t.id} to see the rest`);
549
+ if (
550
+ t.dispatchGate?.status === "open" &&
551
+ (t.readiness?.status ?? "unchecked") === "unchecked"
552
+ ) {
553
+ lines.push(
554
+ `${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: run catalyst team check ${t.key ?? t.id} to see the rest`,
555
+ );
347
556
  }
348
557
  }
349
- const ready = rows.length > 0 && rows.every(setUp);
350
- const mapOwner = verbAvailable("team map") ? "you, with your own login" : "a workspace owner or admin";
351
- const mapWhere = verbAvailable("team map") ? "catalyst team list" : link("/settings/linear-teams");
352
- const projectOwner = blockedProjects.length > 0 ? blockedProjects[0].owner : mapOwner;
558
+ const mappedRows = rows.filter(
559
+ (team) => team.stages && Object.keys(team.stages).length > 0,
560
+ );
561
+ const ready = mappedRows.length > 0 && mappedRows.every(setUp);
562
+ const mapOwner = verbAvailable("team map")
563
+ ? "you, with your own login"
564
+ : "a workspace owner or admin";
565
+ const mapWhere = verbAvailable("team map")
566
+ ? "catalyst team list"
567
+ : link("/settings/linear-teams");
568
+ const projectOwner =
569
+ blockedProjects.length > 0 ? blockedProjects[0].owner : mapOwner;
353
570
  const projectWhere = blockedProjects.length > 0 ? null : mapWhere;
354
- add("projects", "catalyst contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, projectOwner, projectWhere);
571
+ add(
572
+ "projects",
573
+ "catalyst contract --path teams, and the team: checks of ready",
574
+ ready ? "ok" : "unfinished",
575
+ lines,
576
+ projectOwner,
577
+ projectWhere,
578
+ );
355
579
  }
356
580
  }
357
581
 
@@ -361,27 +585,75 @@ if (!connected) {
361
585
  // reach it, so this note never claims that, and it never changes a verdict.
362
586
  function thoughtsNote(owner) {
363
587
  const repo = `${owner}/thoughts`;
364
- const res = spawnSync("gh", ["repo", "view", repo, "--json", "name"], { encoding: "utf8", timeout: 15_000, env: { ...process.env, GH_PROMPT_DISABLED: "1" } });
365
- if (res.error) return `note ${repo}: not checked (the gh command could not run here). Confirm it exists on GitHub (step 5a).`;
366
- if (res.status === 0) return `note ${repo}: exists; App access not verifiable from here. Confirm the GitHub App installation includes it (step 5a).`;
367
- const why = (res.stderr || res.stdout).trim().split("\n")[0] || `gh exited ${res.status}`;
588
+ const res = spawnSync("gh", ["repo", "view", repo, "--json", "name"], {
589
+ encoding: "utf8",
590
+ timeout: 15_000,
591
+ env: { ...process.env, GH_PROMPT_DISABLED: "1" },
592
+ });
593
+ if (res.error)
594
+ return `note ${repo}: not checked (the gh command could not run here). Confirm it exists on GitHub (step 5a).`;
595
+ if (res.status === 0)
596
+ return `note ${repo}: exists; App access not verifiable from here. Confirm the GitHub App installation includes it (step 5a).`;
597
+ const why =
598
+ (res.stderr || res.stdout).trim().split("\n")[0] ||
599
+ `gh exited ${res.status}`;
368
600
  return `note ${repo}: gh could not see it (${why}). It does not exist, or this GitHub login cannot see it. Create or confirm it (step 5a).`;
369
601
  }
370
602
  if (!connected) {
371
- add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
603
+ add(
604
+ "repositories",
605
+ "catalyst contract --path merge.repositories",
606
+ "unreadable",
607
+ ["not readable until this machine is connected"],
608
+ null,
609
+ null,
610
+ );
372
611
  } else {
373
- const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
612
+ const repos = readContract([
613
+ "contract",
614
+ "--path",
615
+ "merge.repositories",
616
+ "--json",
617
+ ]);
374
618
  const doc = tryJson(repos.stdout);
375
619
  const rows = Array.isArray(doc) ? doc : null;
376
620
  if (rows === null) {
377
- add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst contract --refresh"], null, null).next =
621
+ add(
622
+ "repositories",
623
+ "catalyst contract --path merge.repositories",
624
+ "unreadable",
625
+ [
626
+ "the repository list could not be read — try: catalyst contract --refresh",
627
+ ],
628
+ null,
629
+ null,
630
+ ).next =
378
631
  "refresh the contract (catalyst contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
379
632
  } else {
380
- const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
633
+ const lines = [
634
+ `${rows.length} registered`,
635
+ ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`),
636
+ ];
381
637
  repositoryRegistered = rows.length > 0;
382
- lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
383
- for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
384
- add("repositories", "catalyst contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
638
+ lines.push(
639
+ "⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.",
640
+ );
641
+ for (const owner of [
642
+ ...new Set(
643
+ rows
644
+ .map((r) => r.owner)
645
+ .filter((o) => typeof o === "string" && o !== ""),
646
+ ),
647
+ ])
648
+ lines.push(thoughtsNote(owner));
649
+ add(
650
+ "repositories",
651
+ "catalyst contract --path merge.repositories",
652
+ rows.length > 0 ? "ok" : "unfinished",
653
+ lines,
654
+ "a workspace owner or admin",
655
+ link("/settings/projects"),
656
+ );
385
657
  }
386
658
  }
387
659
 
@@ -404,19 +676,30 @@ const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential"]);
404
676
  const CREDENTIAL_FAILURE_STREAK = 3;
405
677
  // A cancelled subscription or an ended account is not a credential problem: no token brings it
406
678
  // back. It is kept for reporting, never re-credentialed, and it does not make setup unfinished.
407
- const isRetirable = (a) => a?.renewalStatus === "canceled" || a?.status === "ended" || a?.declaredState === "ended";
679
+ const isRetirable = (a) =>
680
+ a?.renewalStatus === "canceled" ||
681
+ a?.status === "ended" ||
682
+ a?.declaredState === "ended";
408
683
  const credentialProblem = (a) => {
409
684
  if (isRetirable(a)) return null;
410
685
  if (typeof a?.needsCredential === "boolean") {
411
686
  if (!a.needsCredential) return null;
412
- if (a.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
687
+ if (a.quarantined === true)
688
+ return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
413
689
  if (a.status === "expired-or-revoked") return "expired or revoked";
414
- return typeof a.lastPollErrorCode === "string" ? `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}` : "the cloud says it needs a new credential";
690
+ return typeof a.lastPollErrorCode === "string"
691
+ ? `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}`
692
+ : "the cloud says it needs a new credential";
415
693
  }
416
694
  if (a?.status === "ended" || (a?.revokedAtMs ?? null) !== null) return null;
417
- if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
695
+ if (a?.quarantined === true)
696
+ return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
418
697
  if (a?.status === "expired-or-revoked") return "expired or revoked";
419
- if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
698
+ if (
699
+ CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) &&
700
+ typeof a?.pollFailureCount === "number" &&
701
+ a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK
702
+ ) {
420
703
  return `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}`;
421
704
  }
422
705
  return null;
@@ -424,11 +707,28 @@ const credentialProblem = (a) => {
424
707
  const readAccountRows = () => {
425
708
  const acc = runCli(["accounts", "--json"]);
426
709
  const doc = tryJson(acc.stdout);
427
- const rows = Array.isArray(doc) ? doc : Array.isArray(doc?.accounts) ? doc.accounts : null;
428
- return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
710
+ const rows = Array.isArray(doc)
711
+ ? doc
712
+ : Array.isArray(doc?.accounts)
713
+ ? doc.accounts
714
+ : null;
715
+ return {
716
+ rows,
717
+ error:
718
+ rows === null
719
+ ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output"
720
+ : null,
721
+ };
429
722
  };
430
723
  if (!connected) {
431
- add("coding accounts", "codingAccounts in catalyst contract", "unreadable", ["not readable until this machine is connected"], null, null);
724
+ add(
725
+ "coding accounts",
726
+ "codingAccounts in catalyst contract",
727
+ "unreadable",
728
+ ["not readable until this machine is connected"],
729
+ null,
730
+ null,
731
+ );
432
732
  } else {
433
733
  const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
434
734
  const ca = tryJson(res.stdout);
@@ -436,76 +736,203 @@ if (!connected) {
436
736
  const INSTRUMENT = "codingAccounts in catalyst contract";
437
737
  if (ca !== null && typeof ca === "object" && typeof ca.state === "string") {
438
738
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
439
- const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
739
+ const owner =
740
+ typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
440
741
  const where = typeof ca.page === "string" ? link(ca.page) : null;
441
742
  if (ca.state === "enrolled" || ca.state === "needs_credential") {
442
743
  const DETAIL = `${INSTRUMENT}, and catalyst accounts`;
443
744
  const head = [line, `${ca.activeCount ?? "?"} active`];
444
745
  const { rows, error } = readAccountRows();
445
- const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
746
+ const dead =
747
+ rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
446
748
  const retirable = rows === null ? [] : rows.filter(isRetirable);
447
- const who = (a) => [a.label, a.email].find((v) => typeof v === "string" && v !== "");
448
- const retireLine = retirable.length === 0 ? [] : [`${retirable.length} account(s) cancelled or ended (${retirable.map((a) => `${who(a) ?? a.displayName ?? a.accountSlot ?? "?"} (${a.provider ?? "unknown provider"})`).join(", ")}): kept for reporting, not used, and not counted here. Never replace their credential; reactivate one on the AI accounts page only if its subscription is live again.`];
749
+ const who = (a) =>
750
+ [a.label, a.email].find((v) => typeof v === "string" && v !== "");
751
+ const retireLine =
752
+ retirable.length === 0
753
+ ? []
754
+ : [
755
+ `${retirable.length} account(s) cancelled or ended (${retirable.map((a) => `${who(a) ?? a.displayName ?? a.accountSlot ?? "?"} (${a.provider ?? "unknown provider"})`).join(", ")}): kept for reporting, not used, and not counted here. Never replace their credential; reactivate one on the AI accounts page only if its subscription is live again.`,
756
+ ];
449
757
  retireNote = retireLine[0] ?? null;
450
758
  if (rows === null) {
451
- accountsNext = "read the coding accounts again; each account's credential could not be checked";
452
- add("coding accounts", DETAIL, "unreadable", [...head, `coding accounts could not be checked in detail (${error}). Whether each one still has a working credential is unknown. Do not enroll one on this reading.`], null, null);
759
+ accountsNext =
760
+ "read the coding accounts again; each account's credential could not be checked";
761
+ add(
762
+ "coding accounts",
763
+ DETAIL,
764
+ "unreadable",
765
+ [
766
+ ...head,
767
+ `coding accounts could not be checked in detail (${error}). Whether each one still has a working credential is unknown. Do not enroll one on this reading.`,
768
+ ],
769
+ null,
770
+ null,
771
+ );
453
772
  } else if (dead.length > 0) {
454
- const name = (a) => `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}${who(a) ? ` (${who(a)})` : ""}`;
773
+ const name = (a) =>
774
+ `${a.provider ?? "unknown provider"} account ${a.accountSlot ?? "?"}${who(a) ? ` (${who(a)})` : ""}`;
455
775
  const more = dead.length > 1 ? ` (and ${dead.length - 1} more)` : "";
456
776
  accountsNext = `${name(dead[0])} needs a new credential${more}. Replace it on the AI accounts page (Settings → AI accounts → the account → Replace credential). Do not enroll another account.`;
457
777
  add(
458
778
  "coding accounts",
459
779
  DETAIL,
460
780
  "unfinished",
461
- [...head, ...dead.map((a) => `${name(a)} needs a new credential: ${credentialProblem(a)}`), "Replace its credential on its own page, with a credential minted from THAT account: check the login you mint from matches the account named here first. Do not enroll another account. The steps are in references/replacing-a-credential.md.", ...retireLine],
781
+ [
782
+ ...head,
783
+ ...dead.map(
784
+ (a) =>
785
+ `${name(a)} needs a new credential: ${credentialProblem(a)}`,
786
+ ),
787
+ "Replace its credential on its own page, with a credential minted from THAT account: check the login you mint from matches the account named here first. Do not enroll another account. The steps are in references/replacing-a-credential.md.",
788
+ ...retireLine,
789
+ ],
462
790
  owner,
463
791
  where,
464
792
  );
465
793
  } else if (ca.state === "needs_credential") {
466
- accountsNext = "replace the credential of the coding account the page marks; do not enroll another account";
467
- add("coding accounts", DETAIL, "unfinished", [...head, "The contract says an account needs a new credential, and the account list does not say which. Open the page and look for it."], owner, where);
794
+ accountsNext =
795
+ "replace the credential of the coding account the page marks; do not enroll another account";
796
+ add(
797
+ "coding accounts",
798
+ DETAIL,
799
+ "unfinished",
800
+ [
801
+ ...head,
802
+ "The contract says an account needs a new credential, and the account list does not say which. Open the page and look for it.",
803
+ ],
804
+ owner,
805
+ where,
806
+ );
468
807
  } else {
469
- add("coding accounts", DETAIL, "ok", [...head, `${rows.length} checked, none needs a new credential`, ...retireLine]);
808
+ add("coding accounts", DETAIL, "ok", [
809
+ ...head,
810
+ `${rows.length} checked, none needs a new credential`,
811
+ ...retireLine,
812
+ ]);
470
813
  }
471
814
  } else if (ca.state === "inactive") {
472
- accountsNext = "reactivate a coding account that is out of rotation; do not enroll another one";
473
- add("coding accounts", INSTRUMENT, "unfinished", [line, "Every account is out of rotation. Reactivate one. Do not enroll another account."], owner, where);
815
+ accountsNext =
816
+ "reactivate a coding account that is out of rotation; do not enroll another one";
817
+ add(
818
+ "coding accounts",
819
+ INSTRUMENT,
820
+ "unfinished",
821
+ [
822
+ line,
823
+ "Every account is out of rotation. Reactivate one. Do not enroll another account.",
824
+ ],
825
+ owner,
826
+ where,
827
+ );
474
828
  } else if (ca.state === "none_enrolled") {
475
- add("coding accounts", INSTRUMENT, "unfinished", [line, "no phase can start until one is enrolled"], owner, where);
829
+ add(
830
+ "coding accounts",
831
+ INSTRUMENT,
832
+ "unfinished",
833
+ [line, "no phase can start until one is enrolled"],
834
+ owner,
835
+ where,
836
+ );
476
837
  } else {
477
838
  // `unread`, or a state this bundle does not know: could not look is not the same as none there.
478
- accountsNext = "read the coding accounts again; the cloud could not read them this time";
479
- add("coding accounts", INSTRUMENT, "unreadable", [line, "This is not a missing account. Do not enroll one on this reading. Run this again later."], null, null);
839
+ accountsNext =
840
+ "read the coding accounts again; the cloud could not read them this time";
841
+ add(
842
+ "coding accounts",
843
+ INSTRUMENT,
844
+ "unreadable",
845
+ [
846
+ line,
847
+ "This is not a missing account. Do not enroll one on this reading. Run this again later.",
848
+ ],
849
+ null,
850
+ null,
851
+ );
480
852
  }
481
853
  } else if (older) {
482
854
  const { rows, error } = readAccountRows();
483
- const olderLine = "this cloud is older than the bundle: its contract does not say whether a coding account is enrolled, so the account list is read instead";
855
+ const olderLine =
856
+ "this cloud is older than the bundle: its contract does not say whether a coding account is enrolled, so the account list is read instead";
484
857
  if (rows === null) {
485
- accountsNext = "read the coding accounts again; the account list could not be read this time";
486
- add("coding accounts", "catalyst accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
858
+ accountsNext =
859
+ "read the coding accounts again; the account list could not be read this time";
860
+ add(
861
+ "coding accounts",
862
+ "catalyst accounts",
863
+ "unreadable",
864
+ [olderLine, `coding accounts could not be read (${error})`],
865
+ null,
866
+ null,
867
+ );
487
868
  } else {
488
869
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
489
- const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
490
- const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
491
- for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
492
- if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
493
- add("coding accounts", "catalyst accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
870
+ const usable = rows.filter(
871
+ (a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true,
872
+ );
873
+ const lines = [
874
+ olderLine,
875
+ `${rows.length} enrolled, ${usable.length} able to take work`,
876
+ ];
877
+ for (const a of rows)
878
+ lines.push(
879
+ `${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`,
880
+ );
881
+ if (usable.length === 0)
882
+ lines.push(
883
+ "no phase can start until one is enrolled and able to take work",
884
+ );
885
+ add(
886
+ "coding accounts",
887
+ "catalyst accounts",
888
+ usable.length > 0 ? "ok" : "unfinished",
889
+ lines,
890
+ "a workspace owner or admin",
891
+ link("/settings/coding-accounts"),
892
+ );
494
893
  }
495
894
  } else {
496
- accountsNext = "read the coding accounts again; the contract could not be read";
497
- add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst contract --refresh`], null, null);
895
+ accountsNext =
896
+ "read the coding accounts again; the contract could not be read";
897
+ add(
898
+ "coding accounts",
899
+ INSTRUMENT,
900
+ "unreadable",
901
+ [
902
+ `the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst contract --refresh`,
903
+ ],
904
+ null,
905
+ null,
906
+ );
498
907
  }
499
908
  }
500
909
 
501
910
  // the Re-check step, as a command when this person can run it, else as the page with the
502
911
  // person who can. `team check` is the CLI twin of the page's Re-check button (admin or owner).
503
- const mappedKeys = () => (teamRows ?? []).map((t) => t.key ?? t.id).filter((k) => typeof k === "string" && k !== "");
912
+ const mappedKeys = () =>
913
+ (teamRows ?? [])
914
+ .map((t) => t.key ?? t.id)
915
+ .filter((k) => typeof k === "string" && k !== "");
504
916
  const recheckStep = () => {
505
917
  const cmds = mappedKeys().map((k) => `catalyst team check ${k}`);
506
- if (cmds.length > 0 && verbAvailable("team check")) return { action: `run ${cmds.join(" and ")}, then run this again`, owner: "you, the assistant: run it now with the person's login, without asking", where: cmds[0] };
507
- if (cmds.length > 0 && verbAdminOnly("team check")) return { action: `a workspace owner or admin runs ${cmds.join(" and ")} (or presses Re-check on the projects page); then run this again`, owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
508
- return { action: "press Re-check on the projects page, then run this again", owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
918
+ if (cmds.length > 0 && verbAvailable("team check"))
919
+ return {
920
+ action: `run ${cmds.join(" and ")}, then run this again`,
921
+ owner:
922
+ "you, the assistant: run it now with the person's login, without asking",
923
+ where: cmds[0],
924
+ };
925
+ if (cmds.length > 0 && verbAdminOnly("team check"))
926
+ return {
927
+ action: `a workspace owner or admin runs ${cmds.join(" and ")} (or presses Re-check on the projects page); then run this again`,
928
+ owner: "a workspace owner or admin",
929
+ where: link("/settings/linear-teams"),
930
+ };
931
+ return {
932
+ action: "press Re-check on the projects page, then run this again",
933
+ owner: "a workspace owner or admin",
934
+ where: link("/settings/linear-teams"),
935
+ };
509
936
  };
510
937
 
511
938
  // ── host ──────────────────────────────────────────────────────────────────────────────────────────
@@ -513,34 +940,83 @@ const recheckStep = () => {
513
940
  // contract says who owns it. The check is account-wide, so any one project's reading is the answer.
514
941
  // A tenant that runs no host of its own reads `pass` here, and then nothing is asked of anyone.
515
942
  if (!connected) {
516
- add("host", "hosts_current in catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
943
+ add(
944
+ "host",
945
+ "hosts_current in catalyst contract --path teams",
946
+ "unreadable",
947
+ ["not readable until this machine is connected"],
948
+ null,
949
+ null,
950
+ );
517
951
  } else {
518
- const checks = (teamRows ?? []).flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "hosts_current").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
952
+ const checks = (teamRows ?? []).flatMap((t) =>
953
+ (Array.isArray(t.readiness?.checks) ? t.readiness.checks : [])
954
+ .filter((c) => c.id === "hosts_current")
955
+ .map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })),
956
+ );
519
957
  const row = readinessMeta().find((r) => r.id === "hosts_current") ?? null;
520
- const hc = checks.find((c) => c.state === "fail") ?? checks.find((c) => c.state === "unknown") ?? checks[0] ?? null;
958
+ const hc =
959
+ checks.find((c) => c.state === "fail") ??
960
+ checks.find((c) => c.state === "unknown") ??
961
+ checks[0] ??
962
+ null;
521
963
  // The contract's printed line for this owner is written for a host that is behind. When no host is
522
964
  // connected there is nothing behind, so the owner is named by its id instead of by that sentence.
523
- const owner = row?.fixedBy === undefined
524
- ? "the contract names no owner for hosts_current"
525
- : hc?.state === "fail" && typeof row.fixedByLine === "string"
526
- ? row.fixedByLine
527
- : `the host operator (the contract's owner for hosts_current: ${row.fixedBy})`;
965
+ const owner =
966
+ row?.fixedBy === undefined
967
+ ? "the contract names no owner for hosts_current"
968
+ : hc?.state === "fail" && typeof row.fixedByLine === "string"
969
+ ? row.fixedByLine
970
+ : `the host operator (the contract's owner for hosts_current: ${row.fixedBy})`;
528
971
  // Where the owner acts comes from the contract's `fixedWhere`. Null (every fixer today, and absent on
529
972
  // an older cloud) means no page exists, so the owner sentence is printed alone and no page is made up.
530
- const fw = row?.fixedWhere && typeof row.fixedWhere.page === "string" ? row.fixedWhere : null;
973
+ const fw =
974
+ row?.fixedWhere && typeof row.fixedWhere.page === "string"
975
+ ? row.fixedWhere
976
+ : null;
531
977
  const how = fw === null ? null : link(fw.page);
532
978
  const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
533
979
  if (hc === null) {
534
980
  const step = recheckStep();
535
- add("host", "hosts_current in catalyst contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read."], teamRows === null ? "a workspace owner or admin" : step.owner, teamRows === null ? link("/settings/linear-teams") : step.where).next =
981
+ add(
982
+ "host",
983
+ "hosts_current in catalyst contract --path teams",
984
+ "unreadable",
985
+ [
986
+ teamRows === null
987
+ ? "the project list could not be read, so the host check cannot be either"
988
+ : "no project has a readiness check yet, so whether a host is connected cannot be read.",
989
+ ],
990
+ teamRows === null ? "a workspace owner or admin" : step.owner,
991
+ teamRows === null ? link("/settings/linear-teams") : step.where,
992
+ ).next =
536
993
  teamRows === null
537
994
  ? "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
538
995
  : step.action;
539
996
  } else if (hc.state === "pass") {
540
- add("host", "hosts_current in catalyst contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
997
+ add("host", "hosts_current in catalyst contract --path teams", "ok", [
998
+ `hosts_current pass (read on ${hc.team})`,
999
+ ]);
541
1000
  } else {
542
- const detail = hc.reason === "no_host_connected" ? "no Catalyst host is connected" : hc.reason === "hosts_behind" ? "a connected host runs an older mapping" : hc.reason === "hosts_unreported" ? "a host is connected but has not reported what it loaded" : `state ${hc.state}`;
543
- add("host", "hosts_current in catalyst contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
1001
+ const detail =
1002
+ hc.reason === "no_host_connected"
1003
+ ? "no Catalyst host is connected"
1004
+ : hc.reason === "hosts_behind"
1005
+ ? "a connected host runs an older mapping"
1006
+ : hc.reason === "hosts_unreported"
1007
+ ? "a host is connected but has not reported what it loaded"
1008
+ : `state ${hc.state}`;
1009
+ add(
1010
+ "host",
1011
+ "hosts_current in catalyst contract --path teams",
1012
+ "unfinished",
1013
+ [
1014
+ `hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`,
1015
+ ...howLines,
1016
+ ],
1017
+ owner,
1018
+ how,
1019
+ );
544
1020
  }
545
1021
  }
546
1022
 
@@ -549,37 +1025,107 @@ if (!connected) {
549
1025
  // default repository has one in effect is the contract's `environment_declared` check; the project's
550
1026
  // other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
551
1027
  // nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
552
- const DECL_INSTRUMENT = "environment_declared in catalyst contract --path teams";
1028
+ const DECL_INSTRUMENT =
1029
+ "environment_declared in catalyst contract --path teams";
553
1030
  const DECL_REASONS = {
554
- no_team_repo_default: { text: "no repository is the project's default yet: register one and make it the default", who: "a workspace owner or admin", page: "/settings/repositories" },
555
- no_environment_declaration: { text: "no .catalyst/catalyst.toml on its default branch yet: write it with the person (names only, never a value), open a pull request, merge it", who: "the person, in the repository, with your help", page: null },
556
- declaration_invalid: { text: "the committed .catalyst/catalyst.toml did not validate: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
557
- declaration_read_failed: { text: "the committed .catalyst/catalyst.toml could not be read: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
558
- declaration_awaiting_approval: { text: "the declaration is proposed and waits for approval: Settings → Repositories → the repository → Environment → Setup declaration → Approve this revision", who: "a workspace owner or admin", page: "/settings/repositories" },
1031
+ no_team_repo_default: {
1032
+ text: "no repository is the project's default yet: register one and make it the default",
1033
+ who: "a workspace owner or admin",
1034
+ page: "/settings/projects",
1035
+ },
1036
+ no_environment_declaration: {
1037
+ text: "no .catalyst/catalyst.toml on its default branch yet: write it with the person (names only, never a value), open a pull request, merge it",
1038
+ who: "the person, in the repository, with your help",
1039
+ page: null,
1040
+ },
1041
+ declaration_invalid: {
1042
+ text: "the committed .catalyst/catalyst.toml did not validate: fix the file (the ingest names the error) and merge the fix",
1043
+ who: "the person, in the repository, with your help",
1044
+ page: null,
1045
+ },
1046
+ declaration_read_failed: {
1047
+ text: "the committed .catalyst/catalyst.toml could not be read: fix the file (the ingest names the error) and merge the fix",
1048
+ who: "the person, in the repository, with your help",
1049
+ page: null,
1050
+ },
1051
+ declaration_awaiting_approval: {
1052
+ text: "the declaration is proposed and waits for approval: Settings → Your projects → the project → Repositories → the repository → Environment → Setup declaration → Approve this revision",
1053
+ who: "a workspace owner or admin",
1054
+ page: "/settings/projects",
1055
+ },
559
1056
  };
560
- const DECL_DO = "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
561
- let declNext = "commit .catalyst/catalyst.toml to the repository and have an owner or admin approve it";
1057
+ const DECL_DO =
1058
+ "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
1059
+ let declNext =
1060
+ "commit .catalyst/catalyst.toml to the repository and have an owner or admin approve it";
562
1061
  if (!connected) {
563
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null);
1062
+ add(
1063
+ "repository declarations",
1064
+ DECL_INSTRUMENT,
1065
+ "unreadable",
1066
+ ["not readable until this machine is connected"],
1067
+ null,
1068
+ null,
1069
+ );
564
1070
  } else if (teamRows === null) {
565
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["the project list could not be read, so no repository declaration can be either"], null, null).next =
1071
+ add(
1072
+ "repository declarations",
1073
+ DECL_INSTRUMENT,
1074
+ "unreadable",
1075
+ [
1076
+ "the project list could not be read, so no repository declaration can be either",
1077
+ ],
1078
+ null,
1079
+ null,
1080
+ ).next =
566
1081
  "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
567
1082
  } else if (teamRows.length === 0) {
568
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
569
- "map a project first; a repository declaration is read per project";
570
- } else if (teamRows.every((t) => !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0)) {
1083
+ add(
1084
+ "repository declarations",
1085
+ DECL_INSTRUMENT,
1086
+ "unreadable",
1087
+ [
1088
+ "no project is mapped yet, so there is no repository whose declaration could be read",
1089
+ ],
1090
+ null,
1091
+ null,
1092
+ ).next = "map a project first; a repository declaration is read per project";
1093
+ } else if (
1094
+ teamRows.every(
1095
+ (t) =>
1096
+ !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0,
1097
+ )
1098
+ ) {
571
1099
  // Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
572
1100
  // that silence is not "nothing to declare".
573
1101
  const step = recheckStep();
574
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read."], step.owner, step.where).next = step.action;
1102
+ add(
1103
+ "repository declarations",
1104
+ DECL_INSTRUMENT,
1105
+ "unreadable",
1106
+ [
1107
+ "no project has a readiness check yet, so whether its repository is declared cannot be read.",
1108
+ ],
1109
+ step.owner,
1110
+ step.where,
1111
+ ).next = step.action;
575
1112
  } else {
576
- const found = teamRows.flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "environment_declared").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
1113
+ const found = teamRows.flatMap((t) =>
1114
+ (Array.isArray(t.readiness?.checks) ? t.readiness.checks : [])
1115
+ .filter((c) => c.id === "environment_declared")
1116
+ .map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })),
1117
+ );
577
1118
  const lines = [];
578
1119
  let verdict = "ok";
579
1120
  let owner = null;
580
1121
  let where = null;
581
1122
  let unknown = false;
582
- const describe = (reason) => DECL_REASONS[reason] ?? { text: `environment_declared ${reason ?? "failed"} (a reason this bundle does not know; read it on the page)`, who: "a workspace owner or admin", page: "/settings/repositories" };
1123
+ const describe = (reason) =>
1124
+ DECL_REASONS[reason] ?? {
1125
+ text: `environment_declared ${reason ?? "failed"} (a reason this bundle does not know; read it on the page)`,
1126
+ who: "a workspace owner or admin",
1127
+ page: "/settings/projects",
1128
+ };
583
1129
  const flag = (team, repo, reason) => {
584
1130
  const r = describe(reason);
585
1131
  lines.push(`${team}${repo ? `, ${repo}` : ""}: ${r.text}`);
@@ -589,23 +1135,49 @@ if (!connected) {
589
1135
  where ??= r.page === null ? DECL_DO : link(r.page);
590
1136
  };
591
1137
  if (found.length === 0) {
592
- lines.push("this cloud reports no repository declaration check for the mapped projects, so there is nothing to read here; the repository's Environment page is where one would show");
1138
+ lines.push(
1139
+ "this cloud reports no repository declaration check for the mapped projects, so there is nothing to read here; the repository's Environment page is where one would show",
1140
+ );
593
1141
  }
594
1142
  for (const c of found) {
595
- if (c.state === "pass") lines.push(`${c.team}: a declaration is in effect for the project's default repository`);
1143
+ if (c.state === "pass")
1144
+ lines.push(
1145
+ `${c.team}: a declaration is in effect for the project's default repository`,
1146
+ );
596
1147
  else if (c.state === "fail") flag(c.team, null, c.reason);
597
1148
  else {
598
1149
  unknown = true;
599
- lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; a re-check is needed`);
1150
+ lines.push(
1151
+ `${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; a re-check is needed`,
1152
+ );
600
1153
  }
601
- for (const note of Array.isArray(c.repos) ? c.repos : []) if (typeof note?.repo === "string" && note.reason) flag(c.team, note.repo, note.reason);
1154
+ for (const note of Array.isArray(c.repos) ? c.repos : [])
1155
+ if (typeof note?.repo === "string" && note.reason)
1156
+ flag(c.team, note.repo, note.reason);
602
1157
  }
603
1158
  if (unknown && verdict === "ok") {
604
1159
  const step = recheckStep();
605
- add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, step.owner, step.where).next = step.action;
1160
+ add(
1161
+ "repository declarations",
1162
+ DECL_INSTRUMENT,
1163
+ "unreadable",
1164
+ lines,
1165
+ step.owner,
1166
+ step.where,
1167
+ ).next = step.action;
606
1168
  } else {
607
- if (verdict !== "ok") lines.push("Names only: a value never passes through this script or the file. The person enters values on the repository's Environment page.");
608
- add("repository declarations", DECL_INSTRUMENT, verdict, lines, verdict === "ok" ? null : owner, verdict === "ok" ? null : where);
1169
+ if (verdict !== "ok")
1170
+ lines.push(
1171
+ "Names only: a value never passes through this script or the file. The person enters values on the repository's Environment page.",
1172
+ );
1173
+ add(
1174
+ "repository declarations",
1175
+ DECL_INSTRUMENT,
1176
+ verdict,
1177
+ lines,
1178
+ verdict === "ok" ? null : owner,
1179
+ verdict === "ok" ? null : where,
1180
+ );
609
1181
  }
610
1182
  }
611
1183
 
@@ -618,15 +1190,43 @@ let repoNext = null;
618
1190
  if (typeof flags.repo === "string" && flags.repo !== "") {
619
1191
  const REPO_INSTRUMENT = `repo agent-setup ${flags.repo} --json`;
620
1192
  if (!connected) {
621
- add("repository agent setup", REPO_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null, false);
1193
+ add(
1194
+ "repository agent setup",
1195
+ REPO_INSTRUMENT,
1196
+ "unreadable",
1197
+ ["not readable until this machine is connected"],
1198
+ null,
1199
+ null,
1200
+ false,
1201
+ );
622
1202
  } else if (!verbAvailable("repo agent-setup")) {
623
- add("repository agent setup", REPO_INSTRUMENT, "unreadable", ["this CLI has no repo agent-setup verb; update the CLI to read a checkout's agent setup"], null, null, false).next =
1203
+ add(
1204
+ "repository agent setup",
1205
+ REPO_INSTRUMENT,
1206
+ "unreadable",
1207
+ [
1208
+ "this CLI has no repo agent-setup verb; update the CLI to read a checkout's agent setup",
1209
+ ],
1210
+ null,
1211
+ null,
1212
+ false,
1213
+ ).next =
624
1214
  "update the CLI (it lacks repo agent-setup); the checkout's agent setup cannot be read until then (does not block anything)";
625
1215
  } else {
626
1216
  const res = runCli(["repo", "agent-setup", flags.repo, "--json"]);
627
1217
  const doc = tryJson(res.stdout);
628
1218
  if (doc === null) {
629
- add("repository agent setup", REPO_INSTRUMENT, "unreadable", [`the checkout could not be read (${(res.stderr || res.stdout).trim().split("\n")[0] || "no output"})`], null, null, false).next =
1219
+ add(
1220
+ "repository agent setup",
1221
+ REPO_INSTRUMENT,
1222
+ "unreadable",
1223
+ [
1224
+ `the checkout could not be read (${(res.stderr || res.stdout).trim().split("\n")[0] || "no output"})`,
1225
+ ],
1226
+ null,
1227
+ null,
1228
+ false,
1229
+ ).next =
630
1230
  `run this again with the checkout's real path; ${flags.repo} could not be read`;
631
1231
  } else {
632
1232
  const block = doc.agentsMd?.block ?? "unknown";
@@ -638,12 +1238,32 @@ if (typeof flags.repo === "string" && flags.repo !== "") {
638
1238
  `layout: ${verdict}${Array.isArray(doc.plan) && doc.plan.length ? ` — ${doc.plan.join("; ")}` : ""}${Array.isArray(doc.blockers) && doc.blockers.length ? ` — by hand: ${doc.blockers.join("; ")}` : ""}`,
639
1239
  ];
640
1240
  const offers = [];
641
- if (block !== "current") offers.push(`the Catalyst block for AGENTS.md (catalyst repo agents-block ${flags.repo} --write)`);
642
- if (verdict === "convertible") offers.push(`the portable layout (catalyst repo agent-setup ${flags.repo} --apply)`);
643
- if (verdict === "needs_hand_merge") offers.push("a hand merge first (the layout line names what)");
644
- repoNext = ok ? null : `say in one clause what the checkout holds, then offer ${offers.join(" and ")}, each after a yes, on a branch for the pull request the person opens`;
645
- add("repository agent setup", REPO_INSTRUMENT, ok ? "ok" : "unfinished", lines, ok ? null : "you, after the person's yes, in their checkout; the pull request is theirs", ok ? null : `catalyst repo agent-setup ${flags.repo}`, false);
646
- if (!ok) repoNote = `repository ${flags.repo}: Catalyst block ${block}, layout ${verdict} — offer ${offers.join(" and ")} after a yes (references/repository-agent-setup.md)`;
1241
+ if (block !== "current")
1242
+ offers.push(
1243
+ `the Catalyst block for AGENTS.md (catalyst repo agents-block ${flags.repo} --write)`,
1244
+ );
1245
+ if (verdict === "convertible")
1246
+ offers.push(
1247
+ `the portable layout (catalyst repo agent-setup ${flags.repo} --apply)`,
1248
+ );
1249
+ if (verdict === "needs_hand_merge")
1250
+ offers.push("a hand merge first (the layout line names what)");
1251
+ repoNext = ok
1252
+ ? null
1253
+ : `say in one clause what the checkout holds, then offer ${offers.join(" and ")}, each after a yes, on a branch for the pull request the person opens`;
1254
+ add(
1255
+ "repository agent setup",
1256
+ REPO_INSTRUMENT,
1257
+ ok ? "ok" : "unfinished",
1258
+ lines,
1259
+ ok
1260
+ ? null
1261
+ : "you, after the person's yes, in their checkout; the pull request is theirs",
1262
+ ok ? null : `catalyst repo agent-setup ${flags.repo}`,
1263
+ false,
1264
+ );
1265
+ if (!ok)
1266
+ repoNote = `repository ${flags.repo}: Catalyst block ${block}, layout ${verdict} — offer ${offers.join(" and ")} after a yes (references/repository-agent-setup.md)`;
647
1267
  }
648
1268
  }
649
1269
  }
@@ -664,15 +1284,18 @@ const NEXT = {
664
1284
  : personalGithubIncomplete
665
1285
  ? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
666
1286
  : "get this person's seat and Linear identity sorted",
667
- account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
668
- projects: blockedProjects.length > 0
669
- ? blockedProjects.map((b) => b.action).join("; and ")
670
- : verbAvailable("team map")
671
- ? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
672
- : verbAdminOnly("team map")
673
- ? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
674
- : "pick ONE project and map its stages (or adopt the Catalyst workflow)",
675
- repositories: "install the GitHub App on the Connections page, granting it the repository you want worked; registering that repository on the Repositories page, attached to the project, is the step right after",
1287
+ account:
1288
+ "connect the Linear integration on the Integrations page (the GitHub App comes later, with the repository)",
1289
+ projects:
1290
+ blockedProjects.length > 0
1291
+ ? blockedProjects.map((b) => b.action).join("; and ")
1292
+ : verbAvailable("team map")
1293
+ ? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
1294
+ : verbAdminOnly("team map")
1295
+ ? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
1296
+ : "pick ONE project and map its stages (or adopt the Catalyst workflow)",
1297
+ repositories:
1298
+ "install the GitHub App on the Integrations page, granting it the repository you want worked; register it on Your projects and attach it to the project before continuing",
676
1299
  "coding accounts": accountsNext,
677
1300
  "repository declarations": declNext,
678
1301
  "repository agent setup": repoNext ?? "read the checkout's agent setup again",
@@ -683,7 +1306,17 @@ const NEXT = {
683
1306
  // workspace's Linear integration, the project, the GitHub App with its repository, the person's own
684
1307
  // connected accounts (personal GitHub after the repository proves the App), the repository's
685
1308
  // declaration, and last the host. `--next` is the first unfinished part in this order.
686
- const ORDER = ["machine", "coding accounts", "account", "projects", "repositories", "person", "repository declarations", "repository agent setup", "host"];
1309
+ const ORDER = [
1310
+ "machine",
1311
+ "coding accounts",
1312
+ "account",
1313
+ "projects",
1314
+ "repositories",
1315
+ "person",
1316
+ "repository declarations",
1317
+ "repository agent setup",
1318
+ "host",
1319
+ ];
687
1320
  parts.sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part));
688
1321
  // A personal Linear grant cannot start until the tenant's Linear workspace exists. Once that account
689
1322
  // connection is present, a missing personal grant becomes the next member step before project setup.
@@ -692,7 +1325,9 @@ const personPart = parts.find((p) => p.part === "person");
692
1325
  // GitHub grant is sequenced after the tenant GitHub App: successful repository registration is the
693
1326
  // onboarding path's existing proof that the App installation is usable.
694
1327
  if (personPart && personalGrantIncomplete && workspaceResolved) {
695
- personPart.blocking = personalLinearIncomplete || (personalGithubIncomplete && repositoryRegistered);
1328
+ personPart.blocking =
1329
+ personalLinearIncomplete ||
1330
+ (personalGithubIncomplete && repositoryRegistered);
696
1331
  }
697
1332
  const blocked = parts.filter((p) => p.verdict !== "ok" && p.blocking);
698
1333
  const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
@@ -702,45 +1337,92 @@ const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
702
1337
  // their own unreadable step in `accountsNext`.
703
1338
  const actionFor = (p) =>
704
1339
  p.verdict === "unreadable" && p.part !== "coding accounts"
705
- ? (p.next ?? `run this again; the ${p.part} part could not be read, so no step after it can be named yet`)
1340
+ ? (p.next ??
1341
+ `run this again; the ${p.part} part could not be read, so no step after it can be named yet`)
706
1342
  : NEXT[p.part];
707
1343
  let next =
708
1344
  stuck === null
709
1345
  ? null
710
- : { part: stuck.part, action: actionFor(stuck), owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
1346
+ : {
1347
+ part: stuck.part,
1348
+ action: actionFor(stuck),
1349
+ owner: stuck.owner,
1350
+ where: stuck.where,
1351
+ blocking: stuck.blocking,
1352
+ };
711
1353
 
712
1354
  // A refused contract version outranks every step above: each of them was read from, or waits on, a
713
1355
  // contract this CLI would not accept. Whether an update exists is asked of npm, once, and only here.
714
1356
  function versionAdvice() {
715
1357
  const semver = (t) => (t ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
716
1358
  const installed = semver(field("Bundle"));
717
- const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], { encoding: "utf8", timeout: 10_000, shell: process.platform === "win32" });
1359
+ const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], {
1360
+ encoding: "utf8",
1361
+ timeout: 10_000,
1362
+ shell: process.platform === "win32",
1363
+ });
718
1364
  const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
719
1365
  // The installer is the update path, not `catalyst install`. Its command is on the app's
720
1366
  // setup page; this names the script it fetches rather than composing the request here.
721
1367
  const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
722
1368
  if (installed === null || latest === null) {
723
- return { text: `Whether a newer Catalyst CLI is published could not be checked. You can ${reinstall}; if this still appears afterwards, tell the Catalyst team.`, owner: null };
1369
+ return {
1370
+ text: `Whether a newer Catalyst CLI is published could not be checked. You can ${reinstall}; if this still appears afterwards, tell the Catalyst team.`,
1371
+ owner: null,
1372
+ };
724
1373
  }
725
- const order = [1, 2, 3].map((i) => Math.sign(Number(installed[i]) - Number(latest[i]))).find((d) => d !== 0) ?? 0;
1374
+ const order =
1375
+ [1, 2, 3]
1376
+ .map((i) => Math.sign(Number(installed[i]) - Number(latest[i])))
1377
+ .find((d) => d !== 0) ?? 0;
726
1378
  return order >= 0
727
- ? { text: "A newer Catalyst CLI isn't published yet. Tell the Catalyst team; nothing on this machine needs to change.", owner: "the Catalyst team" }
1379
+ ? {
1380
+ text: "A newer Catalyst CLI isn't published yet. Tell the Catalyst team; nothing on this machine needs to change.",
1381
+ owner: "the Catalyst team",
1382
+ }
728
1383
  : { text: `Update the CLI: ${reinstall}.`, owner: "you, on this machine" };
729
1384
  }
730
1385
  if (contractMismatch !== null && stuck !== null) {
731
1386
  const advice = versionAdvice();
732
1387
  const line = `the tenant serves contract ${contractMismatch.served} and this CLI accepts ${contractMismatch.accepts}. ${advice.text}`;
733
1388
  stuck.lines.push(line);
734
- next = { part: stuck.part, action: line, owner: advice.owner, where: null, blocking: true };
1389
+ next = {
1390
+ part: stuck.part,
1391
+ action: line,
1392
+ owner: advice.owner,
1393
+ where: null,
1394
+ blocking: true,
1395
+ };
735
1396
  }
736
1397
  const finished = parts.every((p) => p.verdict === "ok");
737
1398
 
738
1399
  if (flags.json) {
739
- console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished, notes: [retireNote, repoNote, legacyNote].filter((n) => n !== null) }));
1400
+ console.log(
1401
+ JSON.stringify({
1402
+ cli: via,
1403
+ connected,
1404
+ cloud,
1405
+ personalConnections,
1406
+ parts,
1407
+ localSync,
1408
+ next,
1409
+ finished,
1410
+ notes: [retireNote, repoNote, legacyNote].filter((n) => n !== null),
1411
+ }),
1412
+ );
740
1413
  } else if (flags.next) {
741
- process.on("exit", () => { for (const n of [retireNote, repoNote, legacyNote]) if (n !== null) console.log(`note: ${n}`); });
742
- if (next === null) console.log("nothing left: every part is finished, a coding account is enrolled and the host check passes. Move one card into the project's start stage (usually Todo).");
743
- else console.log(`${next.part}: ${next.action}${next.blocking ? "" : " (does not block the steps below)"}${next.owner ? ` — who: ${next.owner}` : ""}${next.where ? ` — ${next.where.startsWith("http") ? "where" : "do"}: ${next.where}` : ""}`);
1414
+ process.on("exit", () => {
1415
+ for (const n of [retireNote, repoNote, legacyNote])
1416
+ if (n !== null) console.log(`note: ${n}`);
1417
+ });
1418
+ if (next === null)
1419
+ console.log(
1420
+ "nothing left: every part is finished, a coding account is enrolled and the host check passes. Move one card into the project's start stage (usually Todo).",
1421
+ );
1422
+ else
1423
+ console.log(
1424
+ `${next.part}: ${next.action}${next.blocking ? "" : " (does not block the steps below)"}${next.owner ? ` — who: ${next.owner}` : ""}${next.where ? ` — ${next.where.startsWith("http") ? "where" : "do"}: ${next.where}` : ""}`,
1425
+ );
744
1426
  } else {
745
1427
  for (const p of parts) {
746
1428
  console.log(`${p.part} [${p.verdict}]`);
@@ -749,9 +1431,16 @@ if (flags.json) {
749
1431
  if (p.verdict !== "ok" && p.owner) console.log(` who: ${p.owner}`);
750
1432
  // A page gets "where"; a command gets "do". Labelling a command "where" is how a person ends up
751
1433
  // looking for a settings page that is actually a line to run.
752
- if (p.verdict !== "ok" && p.where) console.log(` ${p.where.startsWith("http") ? "where" : "do"}: ${p.where}`);
1434
+ if (p.verdict !== "ok" && p.where)
1435
+ console.log(
1436
+ ` ${p.where.startsWith("http") ? "where" : "do"}: ${p.where}`,
1437
+ );
753
1438
  console.log("");
754
1439
  }
755
- console.log(next === null ? "next: nothing left — move one card into the project's start stage (usually Todo) and watch." : `next: ${next.part} — ${next.action}${next.owner ? ` (${next.owner})` : ""}${next.where ? ` — ${next.where}` : ""}`);
1440
+ console.log(
1441
+ next === null
1442
+ ? "next: nothing left — move one card into the project's start stage (usually Todo) and watch."
1443
+ : `next: ${next.part} — ${next.action}${next.owner ? ` (${next.owner})` : ""}${next.where ? ` — ${next.where}` : ""}`,
1444
+ );
756
1445
  }
757
1446
  process.exit(finished ? 0 : 1);