@catalyst-cloud/cli 0.13.0 → 0.14.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.
Files changed (149) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +27 -30
  3. package/dist/args.js +299 -61
  4. package/dist/ask.js +30 -18
  5. package/dist/capabilities.js +7 -0
  6. package/dist/cli.js +125 -20
  7. package/dist/config.js +3 -1
  8. package/dist/env/draft.js +134 -0
  9. package/dist/env/legacy.js +54 -0
  10. package/dist/env/parse-env.js +50 -0
  11. package/dist/env/settings-toml.js +66 -0
  12. package/dist/env.js +126 -26
  13. package/dist/environment.js +1 -1
  14. package/dist/legacy.js +224 -0
  15. package/dist/onboard-consent.js +65 -0
  16. package/dist/onboard.js +836 -0
  17. package/dist/project.js +191 -0
  18. package/dist/ready.js +11 -1
  19. package/dist/release.js +1 -1
  20. package/dist/repo.js +297 -0
  21. package/dist/runtime-verb.js +1 -1
  22. package/dist/secret.js +25 -4
  23. package/dist/skill-shape.js +21 -13
  24. package/dist/skills.js +44 -4
  25. package/dist/transport.js +26 -1
  26. package/dist/var.js +118 -0
  27. package/package.json +6 -4
  28. package/skills/catalyst-github/SKILL.md +7 -7
  29. package/skills/catalyst-github/references/is-it-mergeable.md +8 -2
  30. package/skills/catalyst-github/references/what-a-pr-accumulates.md +3 -3
  31. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +3 -3
  32. package/skills/catalyst-github/scripts/lib/cli.mjs +9 -7
  33. package/skills/catalyst-github/scripts/lib/credential.mjs +2 -2
  34. package/skills/catalyst-github/scripts/read-pr.mjs +2 -2
  35. package/skills/catalyst-linear/SKILL.md +17 -19
  36. package/skills/catalyst-linear/agents/openai.yaml +1 -1
  37. package/skills/catalyst-linear/agents/portability.yaml +1 -1
  38. package/skills/catalyst-linear/references/reading-a-ticket.md +9 -26
  39. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +2 -2
  40. package/skills/catalyst-linear/references/writing-to-linear.md +7 -9
  41. package/skills/catalyst-linear/scripts/comment.mjs +1 -1
  42. package/skills/catalyst-linear/scripts/create-ticket.mjs +1 -1
  43. package/skills/catalyst-linear/scripts/label.mjs +1 -1
  44. package/skills/catalyst-linear/scripts/lib/cli.mjs +9 -7
  45. package/skills/catalyst-linear/scripts/lib/credential.mjs +2 -2
  46. package/skills/catalyst-linear/scripts/move.mjs +1 -1
  47. package/skills/catalyst-linear/scripts/read-ticket.mjs +1 -1
  48. package/skills/catalyst-linear/scripts/search.mjs +1 -1
  49. package/skills/catalyst-onboard/SKILL.md +37 -57
  50. package/skills/catalyst-onboard/agents/openai.yaml +2 -2
  51. package/skills/catalyst-onboard/agents/portability.yaml +1 -1
  52. package/skills/catalyst-onboard/references/connecting-this-machine.md +35 -0
  53. package/skills/catalyst-onboard/references/declaring-a-repository.md +18 -21
  54. package/skills/catalyst-onboard/references/local-sync.md +19 -7
  55. package/skills/catalyst-onboard/references/reading-ready.md +79 -0
  56. package/skills/catalyst-onboard/references/replacing-a-credential.md +8 -33
  57. package/skills/catalyst-onboard/references/repository-agent-setup.md +23 -0
  58. package/skills/catalyst-onboard/references/skill-sources.md +13 -19
  59. package/skills/catalyst-onboard/references/the-one-path.md +18 -74
  60. package/skills/catalyst-onboard/references/what-a-phase-needs.md +21 -28
  61. package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -37
  62. package/skills/{catalyst-setup → catalyst-onboard}/scripts/check.mjs +10 -9
  63. package/skills/catalyst-onboard/scripts/lib/cli.mjs +6 -4
  64. package/skills/catalyst-onboard/scripts/lib/credential.mjs +2 -2
  65. package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
  66. package/skills/catalyst-onboard/scripts/where-am-i.mjs +929 -184
  67. package/skills/run-this-project/SKILL.md +23 -25
  68. package/skills/run-this-project/agents/openai.yaml +1 -1
  69. package/skills/run-this-project/agents/portability.yaml +1 -1
  70. package/skills/run-this-project/references/making-work-ready.md +20 -37
  71. package/skills/run-this-project/references/reacting-to-events.md +25 -48
  72. package/skills/run-this-project/references/stalls-and-escalation.md +20 -42
  73. package/skills/run-this-project/scripts/lib/cli.mjs +7 -5
  74. package/skills/run-this-project/scripts/lib/credential.mjs +2 -2
  75. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  76. package/skills/run-this-project/scripts/watch-scope.mjs +1 -1
  77. package/skills/unstick/SKILL.md +12 -12
  78. package/skills/unstick/agents/openai.yaml +1 -1
  79. package/skills/unstick/agents/portability.yaml +1 -1
  80. package/skills/unstick/references/playbook.md +13 -13
  81. package/skills/unstick/scripts/lib/cli.mjs +6 -4
  82. package/skills/unstick/scripts/lib/credential.mjs +2 -2
  83. package/skills/unstick/scripts/unstick.mjs +1 -1
  84. package/skills/what-needs-me/SKILL.md +12 -14
  85. package/skills/what-needs-me/agents/openai.yaml +2 -2
  86. package/skills/what-needs-me/agents/portability.yaml +1 -1
  87. package/skills/what-needs-me/references/raising-a-decision.md +8 -16
  88. package/skills/what-needs-me/references/reading-the-inbox.md +6 -13
  89. package/skills/what-needs-me/references/settling-an-answer.md +6 -12
  90. package/skills/what-needs-me/scripts/inbox.mjs +14 -13
  91. package/skills/what-needs-me/scripts/lib/cli.mjs +6 -4
  92. package/skills/what-needs-me/scripts/lib/credential.mjs +2 -2
  93. package/skills/what-needs-me/scripts/raise.mjs +1 -1
  94. package/skills/what-needs-me/scripts/settle.mjs +1 -1
  95. package/skills/what-this-repo-needs/SKILL.md +10 -19
  96. package/skills/what-this-repo-needs/references/what-each-group-means.md +14 -44
  97. package/skills/what-this-repo-needs/scripts/check.mjs +13 -14
  98. package/skills/what-this-repo-needs/scripts/inventory.mjs +2 -2
  99. package/skills/what-this-repo-needs/scripts/lib/cli.mjs +11 -9
  100. package/skills/what-this-repo-needs/scripts/lib/credential.mjs +2 -2
  101. package/skills/whats-happening/SKILL.md +20 -30
  102. package/skills/whats-happening/agents/openai.yaml +1 -1
  103. package/skills/whats-happening/references/coding-accounts.md +11 -0
  104. package/skills/whats-happening/references/routing-work.md +4 -33
  105. package/skills/whats-happening/references/settings-and-where-they-live.md +17 -0
  106. package/skills/whats-happening/references/stages-and-mapping.md +19 -0
  107. package/skills/whats-happening/references/status-reply.md +11 -31
  108. package/skills/whats-happening/references/the-ladder.md +20 -0
  109. package/skills/whats-happening/references/what-runs-next.md +13 -0
  110. package/skills/whats-happening/references/when-a-phase-fails.md +21 -0
  111. package/skills/whats-happening/references/why-is-it-stuck.md +38 -62
  112. package/skills/whats-happening/scripts/explain.mjs +1 -1
  113. package/skills/whats-happening/scripts/lib/cli.mjs +7 -5
  114. package/skills/whats-happening/scripts/lib/credential.mjs +2 -2
  115. package/skills/{how-catalyst-works → whats-happening}/scripts/show-my-map.mjs +18 -32
  116. package/skills/whats-happening/scripts/snapshot.mjs +10 -6
  117. package/dist/env/declaration-rules.js +0 -163
  118. package/skills/catalyst-onboard/references/choosing-a-coding-account.md +0 -52
  119. package/skills/catalyst-onboard/references/who-fixes-what.md +0 -64
  120. package/skills/catalyst-setup/SKILL.md +0 -36
  121. package/skills/catalyst-setup/agents/openai.yaml +0 -6
  122. package/skills/catalyst-setup/agents/portability.yaml +0 -4
  123. package/skills/catalyst-setup/references/what-each-check-means.md +0 -88
  124. package/skills/catalyst-setup/scripts/lib/cli.mjs +0 -103
  125. package/skills/catalyst-setup/scripts/lib/credential.mjs +0 -29
  126. package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
  127. package/skills/connect-me/SKILL.md +0 -63
  128. package/skills/connect-me/agents/openai.yaml +0 -6
  129. package/skills/connect-me/agents/portability.yaml +0 -5
  130. package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
  131. package/skills/connect-me/scripts/lib/cli.mjs +0 -185
  132. package/skills/connect-me/scripts/lib/credential.mjs +0 -29
  133. package/skills/connect-me/scripts/verify-connection.mjs +0 -68
  134. package/skills/how-catalyst-works/SKILL.md +0 -45
  135. package/skills/how-catalyst-works/agents/openai.yaml +0 -6
  136. package/skills/how-catalyst-works/agents/portability.yaml +0 -4
  137. package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
  138. package/skills/how-catalyst-works/references/settings-and-where-they-live.md +0 -20
  139. package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -56
  140. package/skills/how-catalyst-works/references/the-ladder.md +0 -41
  141. package/skills/how-catalyst-works/references/what-catalyst-is.md +0 -30
  142. package/skills/how-catalyst-works/references/what-runs-next.md +0 -95
  143. package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
  144. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
  145. package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
  146. package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
  147. package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
  148. package/skills/whats-happening/references/reading-the-board.md +0 -43
  149. package/skills/whats-happening/references/reprioritising.md +0 -37
@@ -7,13 +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" },
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
+ },
17
30
  };
18
31
  const NOTES = [
19
32
  "Reads, in this order: `status` (machine), `ready --json` (machine checks and project checks, kept apart),",
@@ -22,6 +35,8 @@ const NOTES = [
22
35
  "`contract --path codingAccounts` and `accounts --json` (coding accounts, and which one needs a new credential), and each",
23
36
  "project's hosts_current check with its fixedWhere (host), and each project's environment_declared check with its per-repository notes (repository declarations). The teams read is refreshed, so a mapping just saved reads back mapped.",
24
37
  "Reads `capabilities --json` once: when the installed CLI can run a step for this person's role (`team check`, `team map`), the next step is that command; otherwise the page, with who can.",
38
+ "Reads `legacy --json` when the CLI has the verb: leftovers of the old local Catalyst runtime become a machine line and a note; removing them is the person's yes.",
39
+ "With --repo <path>, reads `repo agent-setup <path> --json` (a read; nothing changes) and adds a `repository agent setup` part that never blocks: the Catalyst block in AGENTS.md and the portable layout, with the writes named for the person to approve.",
25
40
  "Also runs `gh repo view <owner>/thoughts` for each registered repository's owner, as a note: it shows the repository exists, never that the GitHub App can reach it.",
26
41
  "When the tenant serves a contract version this CLI refuses, runs `npm view @catalyst-cloud/cli version` once to say whether a newer CLI is published.",
27
42
  "Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
@@ -29,7 +44,11 @@ const NOTES = [
29
44
 
30
45
  const { help, flags, positionals } = parseFlags(process.argv.slice(2), SPEC);
31
46
  if (help) {
32
- printHelp("node scripts/where-am-i.mjs [--next] [--json]", SPEC, NOTES);
47
+ printHelp(
48
+ "node scripts/where-am-i.mjs [--next] [--json] [--repo <path>]",
49
+ SPEC,
50
+ NOTES,
51
+ );
33
52
  process.exit(0);
34
53
  }
35
54
  if (positionals.length > 0) {
@@ -52,13 +71,32 @@ let repositoryRegistered = false;
52
71
  let capabilities = null;
53
72
  let personRole = null;
54
73
  const canManage = () => personRole === "admin" || personRole === "owner";
55
- const capability = (verb) => (Array.isArray(capabilities) ? capabilities.find((c) => c && c.verb === verb) : undefined);
56
- const verbAvailable = (verb) => { const c = capability(verb); return c?.availability === "available" && (c.needs === "member" || canManage()); };
57
- 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
+ };
58
88
  // `blocking` is false for a finding that is real and reportable but does not stop the next step —
59
89
  // an unmatched Linear identity is the one that matters: it must be said, and it must not become the
60
90
  // thing the person is told to go and do before they can map a project.
61
- 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
+ ) => {
62
100
  const p = { part, instrument, verdict, lines, owner, where, blocking };
63
101
  parts.push(p);
64
102
  return p;
@@ -70,8 +108,12 @@ const add = (part, instrument, verdict, lines, owner = null, where = null, block
70
108
  let contractMismatch = null;
71
109
  const noteMismatch = (text) => {
72
110
  const m =
73
- (text ?? "").match(/serves contract version (\S+) but this bundle accepts (\S+)/) ??
74
- (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
+ );
75
117
  if (m !== null) contractMismatch ??= { served: m[1], accepts: m[2] };
76
118
  return m !== null;
77
119
  };
@@ -85,10 +127,15 @@ const readContract = (args) => {
85
127
  const status = runCli(["status"]);
86
128
  if (!status.ran) {
87
129
  process.stderr.write(`${status.stderr}\n`);
88
- 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
+ );
89
133
  process.exit(2);
90
134
  }
91
- 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);
92
139
  const field = (name) => {
93
140
  const line = statusLines.find((l) => l.startsWith(`${name}:`));
94
141
  return line ? line.slice(name.length + 1).trim() : null;
@@ -99,9 +146,12 @@ const connected = tenant !== null && tryLoadConfig() !== null;
99
146
  // The app and the API share an origin; the base URL is whatever this machine was connected to, so
100
147
  // no page link in this report is a host anyone typed from memory.
101
148
  const cloud = api === null ? null : api.split(" ")[0].replace(/\/+$/, "");
102
- const link = (path) => (cloud === null ? `<your cloud>${path}` : `${cloud}${path}`);
149
+ const link = (path) =>
150
+ cloud === null ? `<your cloud>${path}` : `${cloud}${path}`;
103
151
 
104
- const machineLines = connected ? statusLines : [statusLines[0] ?? "status printed nothing"];
152
+ const machineLines = connected
153
+ ? statusLines
154
+ : [statusLines[0] ?? "status printed nothing"];
105
155
  let machineVerdict = connected ? "ok" : "unfinished";
106
156
 
107
157
  // `ready` is ONE verdict over two parts; split it by check id before anything is reported. A check
@@ -114,19 +164,30 @@ if (connected) {
114
164
  const report = tryJson(ready.stdout);
115
165
  const checks = Array.isArray(report?.checks) ? report.checks : null;
116
166
  if (checks === null) {
117
- 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
+ );
118
170
  machineVerdict = "unfinished";
119
171
  } else {
120
- projectChecks = checks.filter((c) => typeof c.id === "string" && c.id.startsWith("team:"));
121
- 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
+ );
122
178
  const failed = machineChecks.filter((c) => !c.ok && !c.note);
123
179
  // A refused contract version is printed without the CLI's "update" fix: whether an update exists
124
180
  // is checked below, once, against what npm has published.
125
181
  const refused = (c) => !c.ok && !c.note && noteMismatch(c.line);
126
- 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
+ );
127
186
  if (failed.length > 0) {
128
187
  machineVerdict = "unfinished";
129
- 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;
130
191
  }
131
192
  }
132
193
  // Supplemental only: local caches are optional and do not change setup completion or --next.
@@ -136,7 +197,11 @@ if (connected) {
136
197
  );
137
198
  } else {
138
199
  localSync = {
139
- 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
+ },
140
205
  started: false,
141
206
  recovery: "catalyst login",
142
207
  };
@@ -149,40 +214,88 @@ add(
149
214
  "you, on this machine",
150
215
  // The keyless login alone: a person who holds a personal key already knows the key form. A machine
151
216
  // that holds the CLI is told its own command; only one with no CLI at all gets the npx line.
152
- 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],
153
222
  );
154
223
  if (connected) {
155
224
  const capDoc = tryJson(runCli(["capabilities", "--json"]).stdout);
156
- capabilities = Array.isArray(capDoc?.capabilities) ? capDoc.capabilities : null;
225
+ capabilities = Array.isArray(capDoc?.capabilities)
226
+ ? capDoc.capabilities
227
+ : null;
228
+ }
229
+ // Leftovers of the old local Catalyst runtime (its plugin, jobs, commands, state), from the CLI's own
230
+ // fixed list. Reported on the machine part and as a note; removal is the person's yes, never this script's.
231
+ let legacyNote = null;
232
+ if (verbAvailable("legacy")) {
233
+ const legacyDoc = tryJson(runCli(["legacy", "--json"]).stdout);
234
+ const found = Array.isArray(legacyDoc?.found) ? legacyDoc.found : [];
235
+ if (found.length > 0) {
236
+ const machine = parts.find((p) => p.part === "machine");
237
+ const names = found.map((f) => `${f.kind} ${f.name}`);
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 (the data folders are a separate yes, --data)`,
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, then --data as a separate question`;
242
+ }
157
243
  }
158
244
 
159
245
  // ── person ────────────────────────────────────────────────────────────────────────────────────────
160
246
  if (!connected) {
161
- 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
+ );
162
255
  } else {
163
256
  const me = runCli(["me", "--json"]);
164
257
  const doc = tryJson(me.stdout);
165
- 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;
166
260
  if (doc === null) {
167
- 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 =
168
271
  "run this again; catalyst me could not be read, so no step after it can be named yet";
169
272
  } else if (user === null) {
170
273
  add(
171
274
  "person",
172
275
  "catalyst me",
173
276
  "unfinished",
174
- ["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
+ ],
175
280
  "you: connect again with your own login",
176
281
  CONNECT_LINE,
177
282
  );
178
283
  } else {
179
- const matched = typeof user.linearUserId === "string" && user.linearUserId !== "";
284
+ const matched =
285
+ typeof user.linearUserId === "string" && user.linearUserId !== "";
180
286
  personRole = typeof user.role === "string" ? user.role : null;
181
287
  const grantLines = [];
182
288
  for (const provider of ["linear", "github"]) {
183
- const read = runCli(["connections", "personal", provider, "status", "--json"]);
289
+ const read = runCli([
290
+ "connections",
291
+ "personal",
292
+ provider,
293
+ "status",
294
+ "--json",
295
+ ]);
184
296
  const result = tryJson(read.stdout);
185
- const outcome = typeof result?.outcome === "string" ? result.outcome : "unreadable";
297
+ const outcome =
298
+ typeof result?.outcome === "string" ? result.outcome : "unreadable";
186
299
  personalConnections[provider] = outcome;
187
300
  if (outcome === "connected") {
188
301
  grantLines.push(`personal ${provider}: connected`);
@@ -190,23 +303,43 @@ if (!connected) {
190
303
  personalGrantIncomplete = true;
191
304
  if (provider === "linear") personalLinearIncomplete = true;
192
305
  else personalGithubIncomplete = true;
193
- grantLines.push(`personal ${provider}: ${outcome === "absent" ? "not connected" : "expired"}`);
194
- 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";
195
311
  } else {
196
312
  personalGrantIncomplete = true;
197
313
  if (provider === "linear") personalLinearIncomplete = true;
198
314
  else personalGithubIncomplete = true;
199
- grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`);
200
- 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";
201
320
  }
202
321
  }
203
322
  add(
204
323
  "person",
205
324
  "catalyst me, and catalyst connections personal <provider> status --json",
206
325
  matched && !personalGrantIncomplete ? "ok" : "unfinished",
207
- [`${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],
208
- personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
209
- 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"),
210
343
  false,
211
344
  );
212
345
  }
@@ -214,15 +347,38 @@ if (!connected) {
214
347
 
215
348
  // ── account ───────────────────────────────────────────────────────────────────────────────────────
216
349
  if (!connected) {
217
- 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
+ );
218
358
  } else {
219
359
  const acct = readContract(["contract", "--path", "account", "--json"]);
220
360
  const doc = tryJson(acct.stdout);
221
361
  if (doc === null) {
222
- 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 =
223
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";
224
373
  } else {
225
- 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;
226
382
  workspaceResolved = workspace !== null;
227
383
  // The declaration is the one part of the account a key can also READ — and it is the one part a
228
384
  // key can WRITE, so it is reported here rather than left to the settings page like the rest.
@@ -230,14 +386,29 @@ if (!connected) {
230
386
  const env = runCli(["environment", "read", "--json"]);
231
387
  const envDoc = tryJson(env.stdout);
232
388
  if (envDoc === null) {
233
- 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
+ );
234
392
  } else if (envDoc.current === null || envDoc.current === undefined) {
235
- 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
+ );
236
396
  } else {
237
- envLines.push(`environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`);
238
- envLines.push(envDoc.delivered ? `environment delivered to phases: revision ${envDoc.delivered.revision}` : "environment delivered to phases: nothing yet");
239
- const unresolved = Array.isArray(envDoc.unresolvedReferences) ? envDoc.unresolvedReferences : [];
240
- 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
+ );
241
412
  }
242
413
  add(
243
414
  "account",
@@ -245,7 +416,9 @@ if (!connected) {
245
416
  workspace === null ? "unfinished" : "ok",
246
417
  [
247
418
  `${doc.name ?? "(unnamed tenant)"} (${doc.slug ?? "?"})`,
248
- 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}`,
249
422
  "the GitHub App install is NOT carried here — the connections page is the only place that shows it",
250
423
  ...envLines,
251
424
  ],
@@ -262,12 +435,14 @@ let teamRows = null;
262
435
  let readinessMetaCache;
263
436
  const readinessMeta = () => {
264
437
  if (readinessMetaCache === undefined) {
265
- const m = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
438
+ const m = tryJson(
439
+ readContract(["contract", "--path", "readinessChecks", "--json"]).stdout,
440
+ );
266
441
  readinessMetaCache = Array.isArray(m) ? m : [];
267
442
  }
268
443
  return readinessMetaCache;
269
444
  };
270
- // 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
271
446
  // names them. A rule that moves a card is a conflict with the stage Catalyst manages, and no Catalyst
272
447
  // key can change a Linear automation rule: the fix is in Linear, by a Linear admin.
273
448
  const LINEAR_AUTOMATION_RULES = {
@@ -280,27 +455,49 @@ let blockedProjects = [];
280
455
 
281
456
  // ── projects (a project is one Linear team) ───────────────────────────────────────────────────────
282
457
  if (!connected) {
283
- 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
+ );
284
466
  } else {
285
467
  // The mapping write updates the cloud before the cached contract's dispatchGate projection.
286
468
  // Revalidate this one read so a successful save does not appear unmapped on the next step.
287
- const teams = readContract(["contract", "--refresh", "--path", "teams", "--json"]);
469
+ const teams = readContract([
470
+ "contract",
471
+ "--refresh",
472
+ "--path",
473
+ "teams",
474
+ "--json",
475
+ ]);
288
476
  const doc = tryJson(teams.stdout);
289
477
  const rows = Array.isArray(doc) ? doc : null;
290
478
  teamRows = rows;
291
479
  if (rows === null) {
292
- 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 =
293
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";
294
489
  } else {
295
- const lines = [`${rows.length} mapped`];
296
- for (const t of rows) {
297
- const key = t.key ?? t.id ?? "(unkeyed)";
298
- const readiness = t.readiness ?? {};
299
- const bad = Array.isArray(readiness.checks) ? readiness.checks.filter((c) => c.state !== "pass") : [];
300
- lines.push(`${key}: ${readiness.status ?? "unknown"}${bad.length ? ` — ${bad.map((c) => `${c.id} ${c.state}`).join(", ")}` : ""}`);
301
- }
302
- for (const c of projectChecks) lines.push(`${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`);
303
- 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
+ );
304
501
  // A project is set up when its dispatch gate is open (its stages are mapped), or when its
305
502
  // readiness reads ready. Readiness alone kept `--next` on "map its stages" for a tenant whose
306
503
  // gates were open: readiness stays "unchecked" until someone presses Re-check, and a new team
@@ -308,34 +505,77 @@ if (!connected) {
308
505
  // ⛔ A team whose readiness reads `blocked` is NOT set up, whatever its gate says: a blocking
309
506
  // check (a Linear automation that moves cards off Catalyst's stages, a missing OAuth scope) breaks
310
507
  // every run in it. The step is that check's own fix, by its own owner, then a re-check.
311
- 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";
312
511
  for (const t of rows.filter((r) => r.readiness?.status === "blocked")) {
313
512
  const key = t.key ?? t.id ?? "(unkeyed)";
314
- 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));
315
516
  const ids = fails.map((c) => c.id);
316
- 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]);
317
520
  const others = ids.filter((id) => !(id in LINEAR_AUTOMATION_RULES));
318
- const ownerRow = fails.map((c) => readinessMeta().find((r) => r.id === c.id)).find((r) => r && typeof r.fixedByLine === "string");
319
- 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";
320
527
  const how = [
321
- 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,
322
531
  others.length > 0 ? `${others.join(", ")}: ${owner}` : null,
323
- ].filter(Boolean).join("; ");
324
- lines.push(`${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`);
325
- 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
+ });
326
543
  }
327
- 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");
328
548
  for (const t of rows) {
329
- if (t.dispatchGate?.status === "open" && (t.readiness?.status ?? "unchecked") === "unchecked") {
330
- 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
+ );
331
556
  }
332
557
  }
333
- const ready = rows.length > 0 && rows.every(setUp);
334
- const mapOwner = verbAvailable("team map") ? "you, with your own login" : "a workspace owner or admin";
335
- const mapWhere = verbAvailable("team map") ? "catalyst team list" : link("/settings/linear-teams");
336
- 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;
337
570
  const projectWhere = blockedProjects.length > 0 ? null : mapWhere;
338
- 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
+ );
339
579
  }
340
580
  }
341
581
 
@@ -345,27 +585,75 @@ if (!connected) {
345
585
  // reach it, so this note never claims that, and it never changes a verdict.
346
586
  function thoughtsNote(owner) {
347
587
  const repo = `${owner}/thoughts`;
348
- const res = spawnSync("gh", ["repo", "view", repo, "--json", "name"], { encoding: "utf8", timeout: 15_000, env: { ...process.env, GH_PROMPT_DISABLED: "1" } });
349
- if (res.error) return `note ${repo}: not checked (the gh command could not run here). Confirm it exists on GitHub (step 5a).`;
350
- if (res.status === 0) return `note ${repo}: exists; App access not verifiable from here. Confirm the GitHub App installation includes it (step 5a).`;
351
- 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}`;
352
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).`;
353
601
  }
354
602
  if (!connected) {
355
- 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
+ );
356
611
  } else {
357
- const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
612
+ const repos = readContract([
613
+ "contract",
614
+ "--path",
615
+ "merge.repositories",
616
+ "--json",
617
+ ]);
358
618
  const doc = tryJson(repos.stdout);
359
619
  const rows = Array.isArray(doc) ? doc : null;
360
620
  if (rows === null) {
361
- 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 =
362
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";
363
632
  } else {
364
- 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
+ ];
365
637
  repositoryRegistered = rows.length > 0;
366
- lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
367
- for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
368
- 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
+ );
369
657
  }
370
658
  }
371
659
 
@@ -388,19 +676,30 @@ const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential"]);
388
676
  const CREDENTIAL_FAILURE_STREAK = 3;
389
677
  // A cancelled subscription or an ended account is not a credential problem: no token brings it
390
678
  // back. It is kept for reporting, never re-credentialed, and it does not make setup unfinished.
391
- 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";
392
683
  const credentialProblem = (a) => {
393
684
  if (isRetirable(a)) return null;
394
685
  if (typeof a?.needsCredential === "boolean") {
395
686
  if (!a.needsCredential) return null;
396
- 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}` : ""}`;
397
689
  if (a.status === "expired-or-revoked") return "expired or revoked";
398
- 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";
399
693
  }
400
694
  if (a?.status === "ended" || (a?.revokedAtMs ?? null) !== null) return null;
401
- 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}` : ""}`;
402
697
  if (a?.status === "expired-or-revoked") return "expired or revoked";
403
- 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
+ ) {
404
703
  return `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}`;
405
704
  }
406
705
  return null;
@@ -408,11 +707,28 @@ const credentialProblem = (a) => {
408
707
  const readAccountRows = () => {
409
708
  const acc = runCli(["accounts", "--json"]);
410
709
  const doc = tryJson(acc.stdout);
411
- const rows = Array.isArray(doc) ? doc : Array.isArray(doc?.accounts) ? doc.accounts : null;
412
- 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
+ };
413
722
  };
414
723
  if (!connected) {
415
- 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
+ );
416
732
  } else {
417
733
  const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
418
734
  const ca = tryJson(res.stdout);
@@ -420,76 +736,203 @@ if (!connected) {
420
736
  const INSTRUMENT = "codingAccounts in catalyst contract";
421
737
  if (ca !== null && typeof ca === "object" && typeof ca.state === "string") {
422
738
  const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
423
- const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
739
+ const owner =
740
+ typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
424
741
  const where = typeof ca.page === "string" ? link(ca.page) : null;
425
742
  if (ca.state === "enrolled" || ca.state === "needs_credential") {
426
743
  const DETAIL = `${INSTRUMENT}, and catalyst accounts`;
427
744
  const head = [line, `${ca.activeCount ?? "?"} active`];
428
745
  const { rows, error } = readAccountRows();
429
- const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
746
+ const dead =
747
+ rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
430
748
  const retirable = rows === null ? [] : rows.filter(isRetirable);
431
- const who = (a) => [a.label, a.email].find((v) => typeof v === "string" && v !== "");
432
- 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
+ ];
433
757
  retireNote = retireLine[0] ?? null;
434
758
  if (rows === null) {
435
- accountsNext = "read the coding accounts again; each account's credential could not be checked";
436
- 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
+ );
437
772
  } else if (dead.length > 0) {
438
- 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)})` : ""}`;
439
775
  const more = dead.length > 1 ? ` (and ${dead.length - 1} more)` : "";
440
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.`;
441
777
  add(
442
778
  "coding accounts",
443
779
  DETAIL,
444
780
  "unfinished",
445
- [...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
+ ],
446
790
  owner,
447
791
  where,
448
792
  );
449
793
  } else if (ca.state === "needs_credential") {
450
- accountsNext = "replace the credential of the coding account the page marks; do not enroll another account";
451
- 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
+ );
452
807
  } else {
453
- 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
+ ]);
454
813
  }
455
814
  } else if (ca.state === "inactive") {
456
- accountsNext = "reactivate a coding account that is out of rotation; do not enroll another one";
457
- 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
+ );
458
828
  } else if (ca.state === "none_enrolled") {
459
- 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
+ );
460
837
  } else {
461
838
  // `unread`, or a state this bundle does not know: could not look is not the same as none there.
462
- accountsNext = "read the coding accounts again; the cloud could not read them this time";
463
- 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
+ );
464
852
  }
465
853
  } else if (older) {
466
854
  const { rows, error } = readAccountRows();
467
- 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";
468
857
  if (rows === null) {
469
- accountsNext = "read the coding accounts again; the account list could not be read this time";
470
- 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
+ );
471
868
  } else {
472
869
  // An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
473
- const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
474
- const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
475
- for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
476
- if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
477
- 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
+ );
478
893
  }
479
894
  } else {
480
- accountsNext = "read the coding accounts again; the contract could not be read";
481
- 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
+ );
482
907
  }
483
908
  }
484
909
 
485
910
  // the Re-check step, as a command when this person can run it, else as the page with the
486
911
  // person who can. `team check` is the CLI twin of the page's Re-check button (admin or owner).
487
- 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 !== "");
488
916
  const recheckStep = () => {
489
917
  const cmds = mappedKeys().map((k) => `catalyst team check ${k}`);
490
- 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] };
491
- 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") };
492
- 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
+ };
493
936
  };
494
937
 
495
938
  // ── host ──────────────────────────────────────────────────────────────────────────────────────────
@@ -497,34 +940,83 @@ const recheckStep = () => {
497
940
  // contract says who owns it. The check is account-wide, so any one project's reading is the answer.
498
941
  // A tenant that runs no host of its own reads `pass` here, and then nothing is asked of anyone.
499
942
  if (!connected) {
500
- 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
+ );
501
951
  } else {
502
- 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
+ );
503
957
  const row = readinessMeta().find((r) => r.id === "hosts_current") ?? null;
504
- 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;
505
963
  // The contract's printed line for this owner is written for a host that is behind. When no host is
506
964
  // connected there is nothing behind, so the owner is named by its id instead of by that sentence.
507
- const owner = row?.fixedBy === undefined
508
- ? "the contract names no owner for hosts_current"
509
- : hc?.state === "fail" && typeof row.fixedByLine === "string"
510
- ? row.fixedByLine
511
- : `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})`;
512
971
  // Where the owner acts comes from the contract's `fixedWhere`. Null (every fixer today, and absent on
513
972
  // an older cloud) means no page exists, so the owner sentence is printed alone and no page is made up.
514
- 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;
515
977
  const how = fw === null ? null : link(fw.page);
516
978
  const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
517
979
  if (hc === null) {
518
980
  const step = recheckStep();
519
- 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 =
520
993
  teamRows === null
521
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"
522
995
  : step.action;
523
996
  } else if (hc.state === "pass") {
524
- 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
+ ]);
525
1000
  } else {
526
- 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}`;
527
- 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
+ );
528
1020
  }
529
1021
  }
530
1022
 
@@ -533,37 +1025,107 @@ if (!connected) {
533
1025
  // default repository has one in effect is the contract's `environment_declared` check; the project's
534
1026
  // other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
535
1027
  // nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
536
- const DECL_INSTRUMENT = "environment_declared in catalyst contract --path teams";
1028
+ const DECL_INSTRUMENT =
1029
+ "environment_declared in catalyst contract --path teams";
537
1030
  const DECL_REASONS = {
538
- 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" },
539
- 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 },
540
- 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 },
541
- 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 },
542
- 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
+ },
543
1056
  };
544
- const DECL_DO = "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
545
- 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";
546
1061
  if (!connected) {
547
- 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
+ );
548
1070
  } else if (teamRows === null) {
549
- 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 =
550
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";
551
1082
  } else if (teamRows.length === 0) {
552
- add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
553
- "map a project first; a repository declaration is read per project";
554
- } 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
+ ) {
555
1099
  // Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
556
1100
  // that silence is not "nothing to declare".
557
1101
  const step = recheckStep();
558
- 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;
559
1112
  } else {
560
- 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
+ );
561
1118
  const lines = [];
562
1119
  let verdict = "ok";
563
1120
  let owner = null;
564
1121
  let where = null;
565
1122
  let unknown = false;
566
- 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
+ };
567
1129
  const flag = (team, repo, reason) => {
568
1130
  const r = describe(reason);
569
1131
  lines.push(`${team}${repo ? `, ${repo}` : ""}: ${r.text}`);
@@ -573,23 +1135,136 @@ if (!connected) {
573
1135
  where ??= r.page === null ? DECL_DO : link(r.page);
574
1136
  };
575
1137
  if (found.length === 0) {
576
- 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
+ );
577
1141
  }
578
1142
  for (const c of found) {
579
- 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
+ );
580
1147
  else if (c.state === "fail") flag(c.team, null, c.reason);
581
1148
  else {
582
1149
  unknown = true;
583
- 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
+ );
584
1153
  }
585
- 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);
586
1157
  }
587
1158
  if (unknown && verdict === "ok") {
588
1159
  const step = recheckStep();
589
- 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;
590
1168
  } else {
591
- 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.");
592
- 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
+ );
1181
+ }
1182
+ }
1183
+
1184
+ // ── repository agent setup (only with --repo) ─────────────────────────────────────────────────────
1185
+ // A checkout the person named. Read through the CLI's own verb, never by this script opening files:
1186
+ // the verb is the one place that knows the block's words and the portable layout. Never blocking:
1187
+ // a repository that is not portable still runs; this is an offer, and the writes wait for a yes.
1188
+ let repoNote = null;
1189
+ let repoNext = null;
1190
+ if (typeof flags.repo === "string" && flags.repo !== "") {
1191
+ const REPO_INSTRUMENT = `repo agent-setup ${flags.repo} --json`;
1192
+ if (!connected) {
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
+ );
1202
+ } else if (!verbAvailable("repo agent-setup")) {
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 =
1214
+ "update the CLI (it lacks repo agent-setup); the checkout's agent setup cannot be read until then (does not block anything)";
1215
+ } else {
1216
+ const res = runCli(["repo", "agent-setup", flags.repo, "--json"]);
1217
+ const doc = tryJson(res.stdout);
1218
+ if (doc === null) {
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 =
1230
+ `run this again with the checkout's real path; ${flags.repo} could not be read`;
1231
+ } else {
1232
+ const block = doc.agentsMd?.block ?? "unknown";
1233
+ const verdict = doc.verdict ?? "unknown";
1234
+ const ok = verdict === "portable" && block === "current";
1235
+ const lines = [
1236
+ `AGENTS.md: ${doc.agentsMd?.present ? `present; Catalyst block ${block}` : "absent"}`,
1237
+ `CLAUDE.md: ${doc.claudeMd?.present ? (doc.claudeMd.importsAgentsMd ? "imports AGENTS.md" : `${doc.claudeMd.otherLines ?? "?"} lines of its own guidance, no @AGENTS.md import`) : "absent"}`,
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("; ")}` : ""}`,
1239
+ ];
1240
+ const offers = [];
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)`;
1267
+ }
593
1268
  }
594
1269
  }
595
1270
 
@@ -609,17 +1284,21 @@ const NEXT = {
609
1284
  : personalGithubIncomplete
610
1285
  ? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
611
1286
  : "get this person's seat and Linear identity sorted",
612
- account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
613
- projects: blockedProjects.length > 0
614
- ? blockedProjects.map((b) => b.action).join("; and ")
615
- : verbAvailable("team map")
616
- ? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
617
- : verbAdminOnly("team map")
618
- ? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
619
- : "pick ONE project and map its stages (or adopt the Catalyst workflow)",
620
- 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",
621
1299
  "coding accounts": accountsNext,
622
1300
  "repository declarations": declNext,
1301
+ "repository agent setup": repoNext ?? "read the checkout's agent setup again",
623
1302
  host: "connect a Catalyst host",
624
1303
  };
625
1304
  // The parts are read in the order their data allows and reported in the order a person can act on
@@ -627,7 +1306,17 @@ const NEXT = {
627
1306
  // workspace's Linear integration, the project, the GitHub App with its repository, the person's own
628
1307
  // connected accounts (personal GitHub after the repository proves the App), the repository's
629
1308
  // declaration, and last the host. `--next` is the first unfinished part in this order.
630
- const ORDER = ["machine", "coding accounts", "account", "projects", "repositories", "person", "repository declarations", "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
+ ];
631
1320
  parts.sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part));
632
1321
  // A personal Linear grant cannot start until the tenant's Linear workspace exists. Once that account
633
1322
  // connection is present, a missing personal grant becomes the next member step before project setup.
@@ -636,7 +1325,9 @@ const personPart = parts.find((p) => p.part === "person");
636
1325
  // GitHub grant is sequenced after the tenant GitHub App: successful repository registration is the
637
1326
  // onboarding path's existing proof that the App installation is usable.
638
1327
  if (personPart && personalGrantIncomplete && workspaceResolved) {
639
- personPart.blocking = personalLinearIncomplete || (personalGithubIncomplete && repositoryRegistered);
1328
+ personPart.blocking =
1329
+ personalLinearIncomplete ||
1330
+ (personalGithubIncomplete && repositoryRegistered);
640
1331
  }
641
1332
  const blocked = parts.filter((p) => p.verdict !== "ok" && p.blocking);
642
1333
  const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
@@ -646,45 +1337,92 @@ const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
646
1337
  // their own unreadable step in `accountsNext`.
647
1338
  const actionFor = (p) =>
648
1339
  p.verdict === "unreadable" && p.part !== "coding accounts"
649
- ? (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`)
650
1342
  : NEXT[p.part];
651
1343
  let next =
652
1344
  stuck === null
653
1345
  ? null
654
- : { 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
+ };
655
1353
 
656
1354
  // A refused contract version outranks every step above: each of them was read from, or waits on, a
657
1355
  // contract this CLI would not accept. Whether an update exists is asked of npm, once, and only here.
658
1356
  function versionAdvice() {
659
1357
  const semver = (t) => (t ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
660
1358
  const installed = semver(field("Bundle"));
661
- 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
+ });
662
1364
  const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
663
1365
  // The installer is the update path, not `catalyst install`. Its command is on the app's
664
1366
  // setup page; this names the script it fetches rather than composing the request here.
665
1367
  const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
666
1368
  if (installed === null || latest === null) {
667
- 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
+ };
668
1373
  }
669
- 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;
670
1378
  return order >= 0
671
- ? { 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
+ }
672
1383
  : { text: `Update the CLI: ${reinstall}.`, owner: "you, on this machine" };
673
1384
  }
674
1385
  if (contractMismatch !== null && stuck !== null) {
675
1386
  const advice = versionAdvice();
676
1387
  const line = `the tenant serves contract ${contractMismatch.served} and this CLI accepts ${contractMismatch.accepts}. ${advice.text}`;
677
1388
  stuck.lines.push(line);
678
- 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
+ };
679
1396
  }
680
1397
  const finished = parts.every((p) => p.verdict === "ok");
681
1398
 
682
1399
  if (flags.json) {
683
- console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished, notes: retireNote === null ? [] : [retireNote] }));
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
+ );
684
1413
  } else if (flags.next) {
685
- process.on("exit", () => { if (retireNote !== null) console.log(`note: ${retireNote}`); });
686
- 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).");
687
- 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
+ );
688
1426
  } else {
689
1427
  for (const p of parts) {
690
1428
  console.log(`${p.part} [${p.verdict}]`);
@@ -693,9 +1431,16 @@ if (flags.json) {
693
1431
  if (p.verdict !== "ok" && p.owner) console.log(` who: ${p.owner}`);
694
1432
  // A page gets "where"; a command gets "do". Labelling a command "where" is how a person ends up
695
1433
  // looking for a settings page that is actually a line to run.
696
- 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
+ );
697
1438
  console.log("");
698
1439
  }
699
- 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
+ );
700
1445
  }
701
1446
  process.exit(finished ? 0 : 1);