projectstore-claude 0.29.2 → 0.29.4

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 (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -0,0 +1,987 @@
1
+ // projectstore — setup.mjs: the `setup` verb, the one documented way in (the
2
+ // story "projectstore setup asks, shows one plan and installs every harness it
3
+ // finds, with the vault", PS-HARNESS; the front-door ADR's decisions 1, 2, 6
4
+ // and 7).
5
+ //
6
+ // It composes; it re-implements nothing. In rule 3's order:
7
+ // 1. contract 19's refusal of a project that does not exist;
8
+ // 2. the git top (binding.mjs gitTop) and whether the current folder is its
9
+ // own project (rule 4's names, from the manifests and LAYOUT);
10
+ // 3. question 1, the project — or --project, or the current folder;
11
+ // 4. the facts, read for the answered project: detection with the state
12
+ // directories, each host's presence, the binding, the copies of ours
13
+ // from another marketplace and the versions they run, the folder beside
14
+ // the project, a worktree's main checkout, the status-line slot;
15
+ // 5. one pure decide* per question, answered on the question rail
16
+ // (term.mjs openRail) at a terminal, or by the flags without one — and
17
+ // the refusals of those flags;
18
+ // 6. the vault plan: binding.mjs planBind with `init`, or planInherit for a
19
+ // worktree — pure, its refusals stop the run here;
20
+ // 7. runVerb("setup", …), the installer's own run: the registry read, the
21
+ // stop, the keep's read, the fetch, plan(), PLAN, the one question,
22
+ // apply() and DONE — with setup's DONE lines, built here.
23
+ //
24
+ // The rail closes (`└`) before any network work, in a `finally`, and what was
25
+ // typed ahead is drained before the line-mode question that follows it.
26
+ // Nothing is written before that question: before it this reads files, spawns
27
+ // `git rev-parse` (and, in a linked worktree, worktree.mjs's own git reads),
28
+ // and may read a kept copy's own plan, read the registry and fetch a shell
29
+ // into the user-level scratch — exactly what install does.
30
+ //
31
+ // Loaded lazily by cli.mjs, as the install family is, so the MCP server's
32
+ // module graph gains nothing. Every home, environment, spawn and stream is a
33
+ // parameter. No harness id is spelled here: harnesses are found by their
34
+ // manifests' surfaces, and every command a harness runs comes from
35
+ // invocation(). Pure node, no external deps.
36
+
37
+ import { existsSync, statSync, readdirSync, accessSync, realpathSync, constants } from "node:fs";
38
+ import { join, dirname, basename, resolve } from "node:path";
39
+ import { homedir } from "node:os";
40
+ import { spawnSync } from "node:child_process";
41
+ import {
42
+ loadHarness,
43
+ loadHarnesses,
44
+ harnessIds,
45
+ detectHarnesses,
46
+ hostPresence,
47
+ agentsBlockRow,
48
+ insideHostSession,
49
+ registrationDeferred,
50
+ invocation,
51
+ projectRootDeclared,
52
+ harnessNameLines,
53
+ LAYOUT,
54
+ } from "./harness.mjs";
55
+ import { readConfigAt, projectDirRefusal, selectCopy, channelKeep, cmpPrecedence } from "./lib.mjs";
56
+ import {
57
+ planBind,
58
+ planInherit,
59
+ normaliseVaultPath,
60
+ gitTop,
61
+ shellQuote,
62
+ bindResult,
63
+ layoutNames,
64
+ languageNames,
65
+ } from "./binding.mjs";
66
+ import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
67
+ import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
68
+ import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
69
+ import { resolveBinding } from "./worktree.mjs";
70
+ import * as term from "./term.mjs";
71
+
72
+ // The questions' titles (rules 4–9), each the answered line's too.
73
+ export const TITLES = Object.freeze({
74
+ project: "Which folder is the project?",
75
+ harnesses: "Install projectstore for",
76
+ channel: "Channel",
77
+ vault: "Where should this project's notes live?",
78
+ path: "Which folder holds the vault?",
79
+ statusline: (display) => `Show projectstore in ${display}'s status line?`,
80
+ });
81
+ // The channel question's text, the ADR's (rule 6), as the note under its title.
82
+ export const CHANNEL_TEXT =
83
+ "This project loads projectstore from the git marketplace (updated by /plugin). Switch this project to the npm install, or keep the git marketplace?";
84
+ export const CHANNEL_ANSWERS = Object.freeze({ npm: "Switch to npm", git: "Keep the git marketplace" });
85
+ const UNRECORDED = "nothing is recorded: this project has no binding; setup asks again once it is bound";
86
+
87
+ const real = (p) => {
88
+ try {
89
+ return realpathSync(p);
90
+ } catch {
91
+ return resolve(p);
92
+ }
93
+ };
94
+ const tilde = (p, home) =>
95
+ home && (p === home || String(p).startsWith(home + "/")) ? "~" + String(p).slice(home.length) : String(p);
96
+ const writableDir = (dir) => {
97
+ try {
98
+ accessSync(dir, constants.W_OK);
99
+ return statSync(dir).isDirectory();
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ const surfaceOf = (m, test) =>
105
+ Object.entries(m?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s && test(s)) || null;
106
+ const registrationOf = (m) => surfaceOf(m, (s) => s.kind === "registration");
107
+ // The harness whose project can switch channels: the one with a host plugin
108
+ // registration (cli.mjs's switchHarness reads it the same way).
109
+ const hostRegistrationOf = (m) => surfaceOf(m, (s) => s.format === "host-plugin-registration");
110
+ // The harness with a status-line slot of ours (install-harness.mjs's flagHarness).
111
+ const statuslineOf = (m) => {
112
+ const s = m?.surfaces?.statusline;
113
+ return s && s.supported !== false && s.format === "json-entry" ? s : null;
114
+ };
115
+
116
+ // ─── Rule 4: which folder is the project ───────────────────────────────
117
+
118
+ // The names that make a folder its own project (rule 4): every manifest's
119
+ // agents-block files and harness directory, and our own directory — derived,
120
+ // never a literal list.
121
+ export function ownProjectNames(manifests = [...loadHarnesses().values()]) {
122
+ const names = new Set();
123
+ for (const m of manifests) {
124
+ for (const f of agentsBlockRow(m)?.[1].files || []) names.add(f);
125
+ if (m.runtime?.harness_dir) names.add(m.runtime.harness_dir);
126
+ }
127
+ names.add(LAYOUT.root);
128
+ return [...names];
129
+ }
130
+
131
+ // Question 1 (rule 4). Not asked with a project given, outside a work tree or
132
+ // at its top; asked in a subfolder of one, at a terminal, its default the top
133
+ // — unless the folder holds one of the names above, then itself.
134
+ export function decideProject({ cwd, top, given = false, own = false, home = null }) {
135
+ if (given || !top || real(cwd) === real(top)) return { asked: false, project: cwd, top: given ? null : top };
136
+ const choices = [
137
+ { value: top, label: tilde(top, home), reason: "the top of this git work tree" },
138
+ { value: cwd, label: tilde(cwd, home), reason: "the current folder" },
139
+ ];
140
+ if (own) choices.reverse();
141
+ return { asked: true, project: cwd, top, choices, default: choices[0].value };
142
+ }
143
+
144
+ // ─── Rule 5: the checklist ──────────────────────────────────────────────
145
+
146
+ // Who is listed (rule 5): a harness whose registration is not conditioned on
147
+ // a distribution root (the core is its plugin root), or whose manifest names
148
+ // its published shell.
149
+ export function listedHarnesses(manifests = [...loadHarnesses().values()]) {
150
+ return manifests.filter((m) => {
151
+ const r = registrationOf(m);
152
+ return !r || r[1].condition !== "distribution_root" || Boolean(m.install?.shell);
153
+ });
154
+ }
155
+
156
+ // Each listed harness's row (rule 5's table, top to bottom): pre-ticked when
157
+ // used in this project — by its directory or our state directory — or, when
158
+ // none is, when its host's CLI is on PATH and its home exists.
159
+ export function harnessRows({ listed, detected, presence, home = null }) {
160
+ const used = new Map(detected.map((d) => [d.id, d]));
161
+ const anyUsed = listed.some((m) => used.has(m.id));
162
+ return listed.map((m) => {
163
+ const d = used.get(m.id);
164
+ const p = presence.get(m.id) || {};
165
+ const at = p.homeDir ? tilde(p.homeDir, home) : null;
166
+ const row = (ticked, why) => ({
167
+ id: m.id,
168
+ display: m.display_name,
169
+ ticked,
170
+ why,
171
+ cli: p.cli || null,
172
+ bin: p.bin || null,
173
+ });
174
+ if (d) return row(true, `used in this project (${d.evidence}/)`);
175
+ if (p.cli && anyUsed) return row(false, `not used in this project yet; ${p.bin} is on PATH`);
176
+ if (p.cli && p.home) return row(true, `${p.bin} is on PATH`);
177
+ if (p.cli) return row(false, `${p.bin} is on PATH but has not run on this machine yet (no ${at})`);
178
+ if (p.home && p.bin)
179
+ return row(false, `${at} exists, but ${p.bin} is not on PATH — install ${m.display_name} first`);
180
+ return row(false, "not found on this machine");
181
+ });
182
+ }
183
+
184
+ // ─── Rule 6: the channel ────────────────────────────────────────────────
185
+
186
+ // The value plan() receives, or the question, or the refusal (rule 6's
187
+ // table). `selected`: the copy contract 12's selection keeps, from another
188
+ // marketplace, with the version it runs; `kept`: the binding's channel_keep;
189
+ // `sessionCopy`: the copy the selection finds at all, for "inside a session
190
+ // running the git-marketplace copy".
191
+ export function decideChannel({
192
+ inSet,
193
+ others = [],
194
+ flag = null,
195
+ interactive = false,
196
+ running = null,
197
+ selected = null,
198
+ kept = null,
199
+ inSession = false,
200
+ sessionCopy = null,
201
+ }) {
202
+ if (flag) return { asked: false, value: flag, why: "flag" };
203
+ if (!inSet || !others.length) return { asked: false, value: null, why: "none" };
204
+ if (interactive) {
205
+ const newer = Boolean(selected?.version && running && cmpPrecedence(selected.version, running) > 0);
206
+ const keepReason = kept
207
+ ? `kept on ${String(kept.at).slice(0, 10)}`
208
+ : "/plugin keeps updating it; setup refreshes this project's files through that copy";
209
+ const switchReason = newer
210
+ ? `switching runs ${running} in place of ${selected.version}; if ${selected.version} is published, setup stops after these questions and names it`
211
+ : "setup and upgrade update it from now on; /plugin update no longer reaches this project";
212
+ return {
213
+ asked: true,
214
+ default: newer || kept ? "git" : "npm",
215
+ newer,
216
+ choices: [
217
+ { value: "npm", label: CHANNEL_ANSWERS.npm, reason: switchReason },
218
+ { value: "git", label: CHANNEL_ANSWERS.git, reason: keepReason },
219
+ ],
220
+ };
221
+ }
222
+ if (inSession && sessionCopy?.channel === "marketplace") return { asked: false, value: "git", why: "session" };
223
+ if (kept) return { asked: false, value: "git", why: "recorded" };
224
+ return { asked: false, value: null, refuse: true };
225
+ }
226
+
227
+ // ─── Rule 7: the vault ──────────────────────────────────────────────────
228
+
229
+ // The folder beside the project, `<name>-vault`: missing, empty, full or a
230
+ // file, and whether it — or its parent, when it is missing — can be written.
231
+ export function siblingOf(project) {
232
+ const path = join(dirname(project), `${basename(project)}-vault`);
233
+ let state = "missing";
234
+ try {
235
+ const st = statSync(path);
236
+ state = !st.isDirectory() ? "file" : readdirSync(path).length ? "full" : "empty";
237
+ } catch {}
238
+ return {
239
+ path,
240
+ state,
241
+ writable: state === "missing" ? writableDir(dirname(path)) : state === "empty" ? writableDir(path) : null,
242
+ };
243
+ }
244
+
245
+ // The vault question's choices (rule 7's table, read top to bottom): the
246
+ // first from the worktree's main checkout, else from the folder beside the
247
+ // project; then "Another folder…" (unless it is the first) and "Not now".
248
+ export function vaultChoices({ sibling, worktree = null, layout, home = null }) {
249
+ const other = {
250
+ value: "other",
251
+ label: "Another folder…",
252
+ reason: "a missing or empty one is created; one with files is bound",
253
+ };
254
+ const first = worktree
255
+ ? {
256
+ value: "inherit",
257
+ label: `Use ${tilde(worktree.vault, home)}`,
258
+ reason: `the vault of ${tilde(worktree.main, home)}, with its settings`,
259
+ }
260
+ : ["missing", "empty"].includes(sibling.state) && sibling.writable
261
+ ? {
262
+ value: "create",
263
+ label: `Create ${tilde(sibling.path, home)}`,
264
+ reason: `a new vault beside the project: its own git repository, with the ${layout} folders`,
265
+ }
266
+ : sibling.state === "full"
267
+ ? { value: "use", label: `Use ${tilde(sibling.path, home)}`, reason: "it already exists; it is bound as it is" }
268
+ : {
269
+ ...other,
270
+ reason:
271
+ sibling.state === "file"
272
+ ? `${tilde(sibling.path, home)} is a file`
273
+ : `${tilde(dirname(sibling.path), home)} is not writable`,
274
+ };
275
+ return [
276
+ first,
277
+ ...(first.value === "other" ? [] : [other]),
278
+ { value: "none", label: "Not now", reason: "installed unbound" },
279
+ ];
280
+ }
281
+
282
+ // Why a typed vault path is refused, inline (rule 7), or null: a ~user path,
283
+ // a file, a missing folder whose parent is not a writable folder. Relative to
284
+ // the project, `~` expanded, as bind and init read it.
285
+ export function vaultPathRefusal(typed, project, home) {
286
+ const n = normaliseVaultPath(typed, project, home);
287
+ if (!n) return "type a path";
288
+ if (typeof n !== "string") return n.error;
289
+ try {
290
+ if (!statSync(n).isDirectory()) return `${tilde(n, home)} is a file, not a folder`;
291
+ return null;
292
+ } catch {}
293
+ return writableDir(dirname(n)) ? null : `${tilde(dirname(n), home)} is not a writable folder`;
294
+ }
295
+
296
+ // ─── Rule 9: the status line ────────────────────────────────────────────
297
+
298
+ // Asked when the harness with a status-line slot is in the set, the run
299
+ // leaves a binding that does not say yet, the slot is not someone else's and
300
+ // the channel is not the keep — under which DONE names the copy's own command.
301
+ export function decideStatusline({ inSet, leaves, stored, slot, channel }) {
302
+ if (!inSet || !leaves || typeof stored === "boolean" || slot === "theirs") return { asked: false };
303
+ if (channel === "git") return { asked: false, underKeep: true };
304
+ return { asked: true };
305
+ }
306
+
307
+ // ─── The run ───────────────────────────────────────────────────────────
308
+
309
+ // What the run knows of the project it answered for (rule 3, step 4).
310
+ function factsOf(project, { env, home, root, version, interactive }) {
311
+ const manifests = [...loadHarnesses().values()];
312
+ const listed = listedHarnesses(manifests);
313
+ const detected = detectHarnesses(project, { state: true });
314
+ const presence = new Map(manifests.map((m) => [m.id, hostPresence(m, { env, home })]));
315
+ const binding = readConfigAt(project);
316
+ const channelHarness = manifests.find((m) => hostRegistrationOf(m)) || null;
317
+ let others = [],
318
+ selected = null,
319
+ sessionCopy = null;
320
+ if (channelHarness) {
321
+ const [, s] = hostRegistrationOf(channelHarness);
322
+ others = analyseRegistration(project, s, { root, home, harness: channelHarness, env }).others;
323
+ const pick = selectCopy(home, project, { harness: channelHarness, only: "marketplace" });
324
+ // The version it runs, as the convergence story reads it (runningCopies,
325
+ // under no channel answer: local and read-only).
326
+ const copies = runningCopies(project, { harnesses: [channelHarness.id], root, home, env, channel: null, version });
327
+ selected = pick ? { ...pick, version: copies.find((c) => c.key === pick.key)?.version ?? pick.version } : null;
328
+ sessionCopy = selectCopy(home, project, { harness: channelHarness });
329
+ }
330
+ const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
331
+ const slot = slotHarness ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home }).state : null;
332
+ // A worktree's main checkout (worktree.mjs): only where it can be offered —
333
+ // at a terminal, unbound, in a linked worktree. Read for the project
334
+ // answered, never from the git top, which --project leaves unread.
335
+ let worktree = null;
336
+ if (interactive && !binding && inLinkedWorktree(project)) {
337
+ const w = resolveBinding(project);
338
+ if (w.state === "inheritable" && w.mainCheckout) {
339
+ const n = normaliseVaultPath(w.vaultPath, w.mainCheckout, home);
340
+ if (typeof n === "string") worktree = { main: w.mainCheckout, vault: n };
341
+ }
342
+ }
343
+ return {
344
+ manifests,
345
+ listed,
346
+ detected,
347
+ presence,
348
+ binding,
349
+ channelHarness,
350
+ others,
351
+ selected,
352
+ sessionCopy,
353
+ slotHarness,
354
+ slot,
355
+ sibling: siblingOf(project),
356
+ worktree,
357
+ };
358
+ }
359
+
360
+ // Whether `dir` may sit in a linked git worktree, from the files alone: the
361
+ // nearest `.git` at or above it is a file (a gitdir pointer), not a folder.
362
+ // Only then does worktree.mjs spend its git reads to confirm it and find the
363
+ // main checkout, so an unbound run anywhere else spawns nothing more.
364
+ function inLinkedWorktree(dir) {
365
+ for (let at = resolve(dir); ; at = dirname(at)) {
366
+ try {
367
+ return statSync(join(at, ".git")).isFile();
368
+ } catch {}
369
+ if (dirname(at) === at) return false;
370
+ }
371
+ }
372
+
373
+ // The arguments a bare run's refusal repeats after one --harness: what named
374
+ // its vault, channel, layout and language — the channel only for the harness
375
+ // whose channel it is (`channel`), as setup refuses it in a run without that
376
+ // harness. Never --project (the ADR's decision 5).
377
+ function repeatArgs(values, vaultPath, { channel = false } = {}) {
378
+ return [
379
+ vaultPath ? `--vault ${shellQuote(vaultPath)}` : "",
380
+ channel && values.channel ? `--channel ${values.channel}` : "",
381
+ values.layout ? `--layout ${values.layout}` : "",
382
+ values.language ? `--language ${values.language}` : "",
383
+ ]
384
+ .filter(Boolean)
385
+ .join(" ");
386
+ }
387
+
388
+ // Questions 1 to 6 (rule 3, steps 3 to 5): on the rail when one is open,
389
+ // else from the flags and the rules for a run without a terminal. Writes
390
+ // nothing. Returns the decisions, or { cancelled } at a Ctrl+C or the end of
391
+ // input, or { early } — a refusal or a usage error — before any plan.
392
+ async function questions(rail, s) {
393
+ const {
394
+ start,
395
+ top,
396
+ given,
397
+ own,
398
+ values,
399
+ named,
400
+ flagChannel,
401
+ layout,
402
+ env,
403
+ home,
404
+ root,
405
+ version,
406
+ c,
407
+ refused,
408
+ usage,
409
+ summary,
410
+ } = s;
411
+ const glyphs = (t) => term.glyphText(c, t);
412
+ const cancelled = (a) => a === term.CANCELLED;
413
+ const CANCEL = { cancelled: true };
414
+
415
+ // Question 1, the project (rule 4).
416
+ const q1 = decideProject({ cwd: start, top, given, own, home });
417
+ let project = q1.project;
418
+ if (rail && q1.asked) {
419
+ const a = await rail.select(
420
+ TITLES.project,
421
+ q1.choices.map((x) => ({ ...x, reason: glyphs(x.reason) })),
422
+ { default: q1.default },
423
+ );
424
+ if (cancelled(a)) return CANCEL;
425
+ project = a;
426
+ } else if (rail) rail.answered(TITLES.project, tilde(project, home));
427
+ summary.project = project;
428
+ // Without a terminal, in a subfolder of a work tree: PLAN's header names the top.
429
+ const header =
430
+ !rail && q1.asked
431
+ ? [glyphs(`inside the git work tree ${tilde(top, home)} — this run sets up this folder only`)]
432
+ : [];
433
+
434
+ // The facts, for the project answered (rule 3, step 4).
435
+ const runEnv = s.ownEnv ? s.ownEnv(project) : env;
436
+ const f = factsOf(project, { env: runEnv, home, root, version, interactive: Boolean(rail) });
437
+ const rows = harnessRows({ listed: f.listed, detected: f.detected, presence: f.presence, home });
438
+ summary.harnesses = rows.map(({ id, ticked, why }) => ({ id, ticked, why }));
439
+
440
+ // Question 2, the harnesses (rule 5), or --harness.
441
+ let set;
442
+ if (named.length) {
443
+ set = named;
444
+ if (rail) rail.answered(TITLES.harnesses, set.map((id) => loadHarness(id).display_name).join(", "));
445
+ } else if (rail) {
446
+ const a = await rail.multi(
447
+ TITLES.harnesses,
448
+ rows.map((r) => ({ value: r.id, label: r.display, reason: glyphs(r.why), on: r.ticked })),
449
+ );
450
+ if (cancelled(a)) return CANCEL;
451
+ set = a;
452
+ } else {
453
+ set = rows.filter((r) => r.ticked).map((r) => r.id);
454
+ // Contract 8's refusal, with rule 5's reasons: install's rows (harnessNameLines).
455
+ if (!set.length)
456
+ return {
457
+ early: refused(
458
+ project,
459
+ [
460
+ `No harness is used in ${project} or ready on this machine, and none named. Name one:`,
461
+ ...harnessNameLines(rows),
462
+ ].join("\n"),
463
+ ),
464
+ };
465
+ }
466
+ summary.harnesses = rows.map(({ id, why }) => ({ id, ticked: set.includes(id), why }));
467
+
468
+ // Question 3, the channel (rule 6), or --channel, or rule 6's table.
469
+ const ch = f.channelHarness;
470
+ const chInSet = Boolean(ch && set.includes(ch.id));
471
+ if (flagChannel && !chInSet)
472
+ return {
473
+ early: usage(
474
+ `--channel chooses ${ch ? ch.display_name : "the host plugin"}'s channel; this run does not install ${ch ? ch.display_name : "it"}`,
475
+ ),
476
+ };
477
+ const dc = decideChannel({
478
+ inSet: chInSet,
479
+ others: f.others,
480
+ flag: flagChannel,
481
+ interactive: Boolean(rail),
482
+ running: version,
483
+ selected: f.selected,
484
+ kept: channelKeep(f.binding),
485
+ inSession: ch ? insideHostSession(runEnv, ch) : false,
486
+ sessionCopy: f.sessionCopy,
487
+ });
488
+ if (dc.refuse) {
489
+ const blocks = channelChoices({
490
+ gate: { args: { harnesses: named }, keys: f.others.map((o) => o.key), version: pinFor(root, version) },
491
+ });
492
+ return {
493
+ early: refused(
494
+ project,
495
+ blocks
496
+ .flatMap((b) => [b.lead, b.command])
497
+ .filter(Boolean)
498
+ .join("\n"),
499
+ channelText(blocks, c) + "\n",
500
+ ),
501
+ };
502
+ }
503
+ let channel = dc.value ?? null;
504
+ if (rail && dc.asked) {
505
+ const a = await rail.select(
506
+ TITLES.channel,
507
+ dc.choices.map((x) => ({ ...x, reason: glyphs(x.reason) })),
508
+ { default: dc.default, note: CHANNEL_TEXT },
509
+ );
510
+ if (cancelled(a)) return CANCEL;
511
+ channel = a;
512
+ } else if (rail && dc.why === "flag") rail.answered(TITLES.channel, CHANNEL_ANSWERS[channel]);
513
+ summary.channel = channel;
514
+
515
+ // Question 4, the vault (rule 7), or --vault.
516
+ let vault = null; // { kind: "inherit", from } | { kind: "path", path, scaffold }
517
+ if (f.binding) {
518
+ const n = normaliseVaultPath(f.binding.vault_path, project, home);
519
+ const boundPath = typeof n === "string" ? n : String(f.binding.vault_path);
520
+ const want = values.vault ? normaliseVaultPath(values.vault, project, home) : null;
521
+ // A --vault that names no folder (a ~user path) is refused as an unbound
522
+ // run refuses it: it is never compared with the bound vault, nor dropped.
523
+ if (want && typeof want !== "string") return { early: refused(project, want.error) };
524
+ if (typeof want === "string" && real(want) !== real(boundPath)) {
525
+ const command = coreCommand("bind", { args: `${shellQuote(want)} --rebind` });
526
+ const why = `this project is bound to ${boundPath}; setup never rebinds`;
527
+ return {
528
+ early: refused(
529
+ project,
530
+ `${why}\n${command}`,
531
+ [
532
+ `${why}. To point it at ${want}, from a terminal in this project:`,
533
+ ...term.commandBlock(c, [command], { indent: 4 }),
534
+ ].join("\n") + "\n",
535
+ ),
536
+ };
537
+ }
538
+ const ignored = [
539
+ values.layout && values.layout !== (f.binding.layout || "engineering") ? "--layout" : null,
540
+ values.language && values.language !== (f.binding.language || "en") ? "--language" : null,
541
+ ].filter(Boolean);
542
+ if (rail)
543
+ rail.answered(
544
+ TITLES.vault,
545
+ [
546
+ tilde(boundPath, home),
547
+ f.binding.layout || "engineering",
548
+ f.binding.language || "en",
549
+ ...(ignored.length ? [`${ignored.join(" and ")} ignored: a bound project keeps its own`] : []),
550
+ ].join(` ${term.icon(c, "dot")} `),
551
+ );
552
+ } else if (values.vault) {
553
+ const n = normaliseVaultPath(values.vault, project, home);
554
+ const why = vaultPathRefusal(values.vault, project, home);
555
+ if (why) return { early: refused(project, why) };
556
+ vault = { kind: "path", path: n };
557
+ if (rail) rail.answered(TITLES.vault, tilde(n, home));
558
+ } else if (rail) {
559
+ const choices = vaultChoices({ sibling: f.sibling, worktree: f.worktree, layout, home }).map((x) => ({
560
+ ...x,
561
+ label: glyphs(x.label),
562
+ reason: glyphs(x.reason),
563
+ }));
564
+ for (;;) {
565
+ const a = await rail.select(TITLES.vault, choices, { default: choices[0].value });
566
+ if (cancelled(a)) return CANCEL;
567
+ if (a === "none") break;
568
+ if (a === "inherit") {
569
+ vault = { kind: "inherit", from: f.worktree.main };
570
+ break;
571
+ }
572
+ if (a === "create" || a === "use") {
573
+ vault = { kind: "path", path: f.sibling.path };
574
+ break;
575
+ }
576
+ // Another folder…: a path, or Esc back to the choice (the rail erased it).
577
+ const typed = await rail.path(TITLES.path, {
578
+ default: tilde(f.sibling.path, home),
579
+ check: (x) => vaultPathRefusal(x, project, home),
580
+ });
581
+ if (cancelled(typed)) return CANCEL;
582
+ if (typed === term.BACK) continue;
583
+ vault = { kind: "path", path: normaliseVaultPath(typed, project, home) };
584
+ break;
585
+ }
586
+ }
587
+ // An existing vault whose layout folders are missing: one more confirm at a
588
+ // terminal (rule 7). Without one it is bound only, and DONE says how.
589
+ if (vault?.kind === "path") {
590
+ const probe = planBind(project, {
591
+ vault: vault.path,
592
+ layout: values.layout ?? null,
593
+ language: values.language ?? null,
594
+ init: true,
595
+ scaffoldExisting: true,
596
+ env: runEnv,
597
+ root,
598
+ home,
599
+ });
600
+ vault.missing = probe.ok && probe.scaffoldsExisting ? probe.scaffold.creates : 0;
601
+ vault.scaffold = false;
602
+ if (rail && vault.missing) {
603
+ const a = await rail.confirm(
604
+ glyphs(
605
+ `${tilde(vault.path, home)} has no ${probe.layout} layout yet. Add its ${vault.missing} folders and indexes?`,
606
+ ),
607
+ { note: "Existing files are never changed." },
608
+ );
609
+ if (cancelled(a)) return CANCEL;
610
+ vault.scaffold = a;
611
+ }
612
+ }
613
+ // The binding the run leaves, and whether it already says the status line.
614
+ const leaves = Boolean(f.binding || vault);
615
+ const stored = (f.binding || (vault?.kind === "inherit" ? readConfigAt(vault.from) : null))?.statusline?.enabled;
616
+ // A keep with no binding to record it in says so (rule 6), once the vault's
617
+ // answer has settled that there is none.
618
+ if (rail && channel === "git" && !leaves)
619
+ rail.answered(TITLES.channel, `${CHANNEL_ANSWERS.git} ${term.icon(c, "dot")} ${UNRECORDED}`);
620
+
621
+ // Question 6, the status line (rule 9).
622
+ const sh = f.slotHarness;
623
+ const ds = decideStatusline({ inSet: Boolean(sh && set.includes(sh.id)), leaves, stored, slot: f.slot, channel });
624
+ let statusline = null;
625
+ if (rail && ds.asked) {
626
+ const a = await rail.confirm(TITLES.statusline(sh.display_name), {
627
+ note: `your current epic and story, above any status line you already use; restart ${sh.display_name} to see it`,
628
+ });
629
+ if (cancelled(a)) return CANCEL;
630
+ statusline = a;
631
+ }
632
+ return {
633
+ project,
634
+ header,
635
+ runEnv,
636
+ f,
637
+ set,
638
+ namedRun: Boolean(rail) || named.length > 0,
639
+ channel,
640
+ vault,
641
+ statusline,
642
+ underKeep: rail && ds.underKeep ? sh : null,
643
+ };
644
+ }
645
+
646
+ // Setup's DONE lines (rules 10, 11 and 13), from the run's result: the ▲ row
647
+ // of a failed git init; per harness, what a missing host or a host session
648
+ // left to do, each with its terminal command; the status line under the
649
+ // keep; an existing vault left without its layout; the bind lines when the
650
+ // project is still unbound; and the tip. Commands in the core form, pinned to
651
+ // the running version, never --project (rule 11); a harness's own commands
652
+ // from invocation().
653
+ function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, underKeep, verbose }) {
654
+ const p = r.plan;
655
+ const blocks = [];
656
+ for (const id of p.harnesses) {
657
+ const m = loadHarness(id);
658
+ if (!m) continue;
659
+ const pr = hostPresence(m, { env: runEnv, home });
660
+ // A missing host only when this run needed it (rule 5): its registration
661
+ // row is a skip whose reason says the host's CLI is not on PATH, as every
662
+ // registration planner words it. A row that needed no host (one already
663
+ // current) says nothing, whatever PATH holds.
664
+ const needed =
665
+ pr.bin &&
666
+ !pr.cli &&
667
+ p.items.some(
668
+ (i) =>
669
+ i.harness === id &&
670
+ i.kind === "registration" &&
671
+ i.action === "skip" &&
672
+ String(i.reason || "").includes(`\`${pr.bin}\` is not on PATH`),
673
+ );
674
+ if (needed) {
675
+ blocks.push({
676
+ lead: `${m.display_name} is not installed on this machine (${pr.bin} was not found on PATH). Install it, then run this from a terminal in this project:`,
677
+ commands: [coreCommand("setup", { version, args: `--harness ${id}` })],
678
+ });
679
+ continue;
680
+ }
681
+ const mine = p.items.filter((i) => i.harness === id && i.deferred);
682
+ const left = [];
683
+ if (
684
+ registrationDeferred(runEnv, m) &&
685
+ mine.some((i) => i.kind === "registration" && !String(i.surface).endsWith("_others"))
686
+ )
687
+ left.push("the plugin registration");
688
+ if (insideHostSession(runEnv, m) && mine.some((i) => i.kind === "channel"))
689
+ left.push("the kept copy's own refresh");
690
+ if (insideHostSession(runEnv, m) && p.items.some((i) => i.kind === "layout" && i.deferred && i.harness === id))
691
+ left.push("the layout move");
692
+ // Rule 13: `--channel npm` when that was the answer, and only for the
693
+ // harness whose channel it is — setup refuses --channel in a run without
694
+ // that harness (exit 2). A keep is not repeated: once recorded, it is the
695
+ // default already (rule 6).
696
+ const again = channel === "npm" && hostRegistrationOf(m) ? "--channel npm" : "";
697
+ if (left.length)
698
+ blocks.push({
699
+ lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
700
+ commands: [coreCommand("setup", { version, args: [`--harness ${id}`, again].filter(Boolean).join(" ") })],
701
+ after: `then restart ${m.display_name}.`,
702
+ });
703
+ }
704
+ if (underKeep)
705
+ blocks.push({
706
+ lead: `To show projectstore in the status line, run this in ${underKeep.display_name}:`,
707
+ commands: [invocation(underKeep, "statusline", { args: "on" })],
708
+ });
709
+ const bp = vaultPlan;
710
+ if (
711
+ bp &&
712
+ bp.ok &&
713
+ !bp.inherit &&
714
+ !bp.buildsVault &&
715
+ !bp.scaffoldsExisting &&
716
+ bp.scaffold?.ok &&
717
+ bp.scaffold.creates > 0
718
+ ) {
719
+ blocks.push({
720
+ lead: `${tilde(bp.vault, home)} has no ${bp.layout} layout yet. To add its missing folders and indexes, from a terminal in this project:`,
721
+ commands: [coreCommand("scaffold", { version, args: "--write" })],
722
+ });
723
+ }
724
+ if (!readConfigAt(project)) {
725
+ p.harnesses.forEach((id, k) => {
726
+ const m = loadHarness(id);
727
+ if (m)
728
+ blocks.push({
729
+ lead: `${k ? "" : "This project has no vault yet. "}Bind a vault, in ${m.display_name}:`,
730
+ commands: [invocation(m, "bind", { args: "<vault-path>" })],
731
+ });
732
+ });
733
+ }
734
+ // Doctor's fix in each run harness's own form, as the bind lines are.
735
+ const fixes = [
736
+ ...new Set(
737
+ p.harnesses
738
+ .map((id) => loadHarness(id))
739
+ .filter(Boolean)
740
+ .map((m) => invocation(m, "doctor", { args: "--fix" })),
741
+ ),
742
+ ];
743
+ const warnings = (r.warnings || []).map((w) => ({
744
+ text: `the vault has no git repository: git init failed (${String(w.message || "").split("\n")[0]}). Run git init in it from a terminal, or doctor's fix in a session, which offers it:`,
745
+ commands: [`git -C ${shellQuote(w.path)} init`, ...fixes],
746
+ }));
747
+ const tip = {
748
+ lead: verbose
749
+ ? "To preview without writing:"
750
+ : "To preview without writing (--verbose adds every row's reasoning):",
751
+ commands: [coreCommand("setup", { args: "--dry-run" })],
752
+ };
753
+ return { warnings, blocks, tip };
754
+ }
755
+
756
+ // DONE's Next lines without a DONE: a run that had nothing to write still
757
+ // says what a missing host or a session left, and how to bind.
758
+ function nextText(blocks, c) {
759
+ if (!blocks.length) return "";
760
+ const width = c.live ? c.width : 0;
761
+ const lines = ["", term.heading(c, "Next")];
762
+ blocks.forEach((b, k) => {
763
+ if (k) lines.push("");
764
+ lines.push(
765
+ ...term
766
+ .wrap(b.lead, width, " ")
767
+ .split("\n")
768
+ .map((l, n) => (n ? l : ` ${l}`)),
769
+ );
770
+ if ((b.commands || []).length) lines.push(...term.commandBlock(c, b.commands, { indent: 4 }));
771
+ if (b.after) lines.push("", ` ${b.after}`);
772
+ });
773
+ return lines.join("\n") + "\n";
774
+ }
775
+
776
+ // The `setup` verb. `ctx` is cli.mjs's run context — values, the resolved
777
+ // project, env, cwd, the three streams and the tests' `ask` — plus the bin's
778
+ // own: `root` (the package root), `ownEnv(project)` (the environment the
779
+ // installer runs in) and `author(project)` (a fresh binding's
780
+ // default_author). The tests' hooks: `home`, `proc` (the rail's process),
781
+ // `escapeMs` (its Esc timeout), `gitSpawn`, `drainTicks`, and runVerb's own
782
+ // `spawn`, `fetchSpawn`, `fetch`, `now` and `timers`. Returns { code,
783
+ // project, ok, result } — `result` the envelope's, which cli.mjs writes under
784
+ // --json — or { code: 2, error } for a usage error. Exit codes (rule 1): 0 applied, nothing to do or a clean dry
785
+ // run; 1 a refusal, a declined question, an incomplete plan, a failed step or
786
+ // a failed git init; 2 usage; 130 cancelled at a question.
787
+ export async function runSetup(ctx) {
788
+ const { values = {}, env = process.env, stdin = null, stdout = null, ask = null } = ctx;
789
+ const root = ctx.root;
790
+ const home = ctx.home || homedir();
791
+ // The running version, as plan reads it for a shell-rooted row (planOptions).
792
+ const version = planOptions({ root }).version;
793
+ const json = Boolean(values.json);
794
+ const dryRun = Boolean(values["dry-run"]);
795
+ const verbose = Boolean(values.verbose);
796
+ const named = (values.harness || []).filter(Boolean);
797
+ const write = (text) => {
798
+ if (!json && stdout && text) stdout.write(text);
799
+ };
800
+ const usage = (error) => ({ code: 2, error });
801
+
802
+ // Usage, before anything is read (exit 2).
803
+ const unknown = named.filter((id) => !harnessIds().includes(id));
804
+ if (unknown.length) return usage(`unknown harness: ${unknown.join(", ")} — known: ${harnessIds().join(", ")}`);
805
+ const flagChannel = values.channel ?? null;
806
+ if (flagChannel !== null && !["npm", "git"].includes(flagChannel))
807
+ return usage(`--channel takes npm or git, not ${JSON.stringify(flagChannel)}`);
808
+ const layouts = layoutNames(root),
809
+ languages = languageNames(root);
810
+ if (values.layout && layouts.length && !layouts.includes(values.layout))
811
+ return usage(`unknown layout "${values.layout}" — one of: ${layouts.join(", ")}`);
812
+ if (values.language && languages.length && !languages.includes(values.language))
813
+ return usage(`unknown language "${values.language}" — one of: ${languages.join(", ")}`);
814
+
815
+ // 1. Contract 19: the project given (--project, or a variable that names
816
+ // it), else the current folder, refused when it is not a directory.
817
+ const given = Boolean(values.project || env.PROJECTSTORE_PROJECT_DIR || projectRootDeclared(env));
818
+ const start = given ? resolve(ctx.project) : resolve(ctx.cwd || process.cwd());
819
+ const summary = { project: start, git_top: null, harnesses: [], channel: null, vault: null, statusline: null };
820
+ // A refusal before any plan: its text is written by whoever returns it,
821
+ // once the rail (if one is open) has closed beneath the answers.
822
+ const refused = (project, message, text = `${message}\n`) => ({
823
+ code: 1,
824
+ project,
825
+ ok: false,
826
+ text,
827
+ result: {
828
+ gate: { confirmed: false, why: "refused" },
829
+ applied: [],
830
+ failed: null,
831
+ incomplete: false,
832
+ plannedAgainst: {},
833
+ items: [],
834
+ refusals: [message],
835
+ reports: [],
836
+ fetched: [],
837
+ setup: summary,
838
+ },
839
+ });
840
+ const say = ({ text, ...r }) => {
841
+ write(text);
842
+ return r;
843
+ };
844
+ const missing = projectDirRefusal(start);
845
+ if (missing) return say(refused(start, missing));
846
+
847
+ // 2. Only the git top, and whether this folder is its own project (rule 4).
848
+ const top = given ? null : gitTop(start, { env, spawn: ctx.gitSpawn || spawnSync });
849
+ summary.git_top = top;
850
+ const own = top ? ownProjectNames().some((n) => existsSync(join(start, n))) : false;
851
+
852
+ // The rail, only where a person can answer it (rule 2): an interactive run
853
+ // (TTYs, no CI, no --json, no session marker) on a terminal that takes raw
854
+ // mode. Anywhere else the run is one without a terminal: no question, and
855
+ // the confirm is --harness — the gate gets no streams to ask on.
856
+ const c = stdout ? term.caps(stdout, env) : term.PLAIN;
857
+ const interactive = !json && isInteractive({ stdin, stdout, env, json }) && term.railAvailable(stdin, c);
858
+ let rail = null,
859
+ q = null;
860
+ try {
861
+ if (interactive)
862
+ rail = term.openRail({
863
+ stdin,
864
+ stdout,
865
+ caps: c,
866
+ proc: ctx.proc || process,
867
+ head: "projectstore setup",
868
+ ...(ctx.escapeMs ? { escapeMs: ctx.escapeMs } : {}),
869
+ });
870
+ q = await questions(rail, {
871
+ start,
872
+ top,
873
+ given,
874
+ own,
875
+ values,
876
+ named,
877
+ flagChannel,
878
+ layout: values.layout || "engineering",
879
+ env,
880
+ home,
881
+ root,
882
+ version,
883
+ c,
884
+ refused,
885
+ usage,
886
+ summary,
887
+ ownEnv: ctx.ownEnv,
888
+ });
889
+ } finally {
890
+ // `└` before any network work, whatever ended the questions.
891
+ if (rail) rail.close(q?.cancelled ? `Cancelled ${term.icon(c, "dash")} nothing was written` : "");
892
+ }
893
+ if (q.cancelled) return { code: 130, project: summary.project, ok: false, result: null };
894
+ if (q.early) return q.early.error ? q.early : say(q.early);
895
+ const { project, runEnv, set, channel, vault, statusline } = q;
896
+
897
+ // 6. The vault plan, pure; its refusals stop the run here. One spelling of
898
+ // the project for it and for plan(), and the run's environment for both.
899
+ let vaultPlan = null;
900
+ if (vault?.kind === "inherit") vaultPlan = planInherit(project, { from: vault.from, env: runEnv, root, home });
901
+ else if (vault?.kind === "path")
902
+ vaultPlan = planBind(project, {
903
+ vault: vault.path,
904
+ layout: values.layout ?? null,
905
+ language: values.language ?? null,
906
+ init: true,
907
+ scaffoldExisting: Boolean(vault.scaffold),
908
+ author: dryRun || !ctx.author ? "" : ctx.author(project),
909
+ env: runEnv,
910
+ root,
911
+ home,
912
+ });
913
+ if (vaultPlan && !vaultPlan.ok) return say(refused(project, vaultPlan.refusals.map((x) => x.message).join("\n")));
914
+ summary.vault = vaultPlan ? bindResult(vaultPlan, null) : null;
915
+
916
+ // 7. The installer's own run, with setup's inputs.
917
+ const done = (r) =>
918
+ doneLines(r, { project, runEnv, home, version, channel, vaultPlan, underKeep: q.underKeep, verbose });
919
+ const r = await runVerb("setup", project, {
920
+ harnesses: set,
921
+ named: q.namedRun,
922
+ channel,
923
+ vault: vaultPlan,
924
+ statusline,
925
+ layout: vaultPlan ? vaultPlan.layout : null,
926
+ root,
927
+ env: runEnv,
928
+ home,
929
+ cwd: ctx.cwd,
930
+ json,
931
+ verbose,
932
+ dryRun,
933
+ out: json ? null : stdout,
934
+ badged: Boolean(rail),
935
+ header: q.header,
936
+ stdin: rail ? stdin : null,
937
+ stdout: rail ? stdout : null,
938
+ ask: rail ? ask : null,
939
+ drain: rail ? () => term.drainInput(stdin, { ticks: ctx.drainTicks ?? 2 }) : null,
940
+ done,
941
+ spawn: ctx.spawn,
942
+ fetchSpawn: ctx.fetchSpawn,
943
+ fetch: ctx.fetch,
944
+ now: ctx.now,
945
+ timers: ctx.timers,
946
+ });
947
+ const after = readConfigAt(project);
948
+ summary.statusline = typeof after?.statusline?.enabled === "boolean" ? after.statusline.enabled : null;
949
+ const applied = r.applied.find((a) => a && a.surface === "vault" && a.bind);
950
+ if (applied) summary.vault = applied.bind;
951
+ // A failed git init is no stop, but the run is not ok (rule 10).
952
+ const warned = (r.warnings || []).length > 0;
953
+ const ok =
954
+ r.plan.ok &&
955
+ !r.plan.incomplete &&
956
+ !r.failed &&
957
+ !warned &&
958
+ (r.gate.confirmed || ["nothing-to-do", "dry-run"].includes(r.gate.why));
959
+ if (!r.gate.confirmed) {
960
+ write(
961
+ gateText(r, {
962
+ verb: "setup",
963
+ harnesses: named,
964
+ caps: c,
965
+ args: (id) => repeatArgs(values, vault?.path || null, { channel: id === q.f.channelHarness?.id }),
966
+ }),
967
+ );
968
+ if (r.gate.why === "nothing-to-do") write(nextText(done(r).blocks, c));
969
+ }
970
+ return {
971
+ code: ok ? 0 : 1,
972
+ project,
973
+ ok,
974
+ result: {
975
+ gate: r.gate,
976
+ applied: r.applied,
977
+ failed: r.failed,
978
+ incomplete: r.plan.incomplete,
979
+ plannedAgainst: r.plan.plannedAgainst,
980
+ items: r.plan.items.map(publicItem),
981
+ refusals: r.plan.refusals,
982
+ reports: r.plan.reports,
983
+ fetched: r.fetched || [],
984
+ setup: summary,
985
+ },
986
+ };
987
+ }