projectstore-claude 0.29.3 → 0.30.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 (57) hide show
  1. package/README.md +13 -12
  2. package/bin/projectstore-claude.mjs +2 -2
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +153 -77
  6. package/node_modules/projectstore/commands/bind.md +6 -17
  7. package/node_modules/projectstore/commands/doctor.md +12 -7
  8. package/node_modules/projectstore/commands/statusline.md +1 -1
  9. package/node_modules/projectstore/commands/story.md +15 -2
  10. package/node_modules/projectstore/docs/extending.md +11 -6
  11. package/node_modules/projectstore/docs/getting-started.md +21 -1
  12. package/node_modules/projectstore/docs/harnesses.md +13 -10
  13. package/node_modules/projectstore/docs/how-it-works.md +6 -4
  14. package/node_modules/projectstore/harnesses/claude-code.json +16 -15
  15. package/node_modules/projectstore/harnesses/codex.json +12 -13
  16. package/node_modules/projectstore/hooks/band.tsx +48 -20
  17. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  18. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  19. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  20. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  21. package/node_modules/projectstore/package.json +11 -2
  22. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  23. package/node_modules/projectstore/scripts/band.mjs +72 -21
  24. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  25. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  26. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  27. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  28. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  29. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  30. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  31. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  32. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  33. package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
  34. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  35. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  36. package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
  37. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  38. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  39. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  40. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  41. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  42. package/node_modules/projectstore/scripts/query.mjs +378 -80
  43. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  44. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  45. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  46. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  47. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  48. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  49. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  50. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  51. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  52. package/node_modules/projectstore/scripts/term.mjs +382 -97
  53. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  54. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  55. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  56. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  57. package/package.json +3 -3
@@ -16,8 +16,8 @@
16
16
  // another vault. Neither asks on a terminal, unlike install: there is no
17
17
  // preview to show — the whole write is the three values the caller just
18
18
  // typed, and the refusal texts name the second word. A rebind keeps every
19
- // other key of the config (statusline, agents, autoupdate_asked …): those are
20
- // the project's decisions, not the vault's.
19
+ // other key of the config (statusline, agents, channel_keep …): those are the
20
+ // project's decisions, not the vault's.
21
21
  //
22
22
  // Never reads ambient cwd or ambient env: the caller resolves the project
23
23
  // (cli.mjs's resolveProject) and supplies the author. Normative: the CLI
@@ -35,30 +35,70 @@
35
35
  // touched: no `git init` inside someone's files, no scaffold. For `bind`, and
36
36
  // for that `init`, the scaffold plan only decides the "Next" line.
37
37
 
38
+ //
39
+ // Setup composes these too (the story "projectstore setup asks, shows one
40
+ // plan and installs every harness it finds, with the vault", rules 7 and
41
+ // 10): planInherit() is a worktree's binding copied from its main checkout;
42
+ // `scaffoldExisting` adds the scaffold to a bind of an existing vault whose
43
+ // layout folders are missing; bindPlanSteps() is the one list of steps the
44
+ // installer's PLAN shows for a vault and applyBind() reports through
45
+ // `onStep`; recheckBind() is the apply-time check that the preview still
46
+ // holds; gitTop() is the git top setup's first question reads.
47
+
38
48
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
39
49
  import { spawnSync } from "node:child_process";
40
50
  import { join, resolve, isAbsolute, dirname } from "node:path";
41
51
  import { homedir } from "node:os";
42
- import { writeFileAtomic, pluginRoot, ensureRuntimeDir, layoutPaths, commandForm } from "./lib.mjs";
43
- import { configPath as harnessConfigPath, invokedAs } from "./harness.mjs";
52
+ import {
53
+ writeFileAtomic,
54
+ pluginRoot,
55
+ ensureRuntimeDir,
56
+ layoutPaths,
57
+ commandForm,
58
+ readConfigAt,
59
+ fromTerminalIn,
60
+ CHANNEL_SWITCH_KEY,
61
+ CHANNEL_KEEP_KEY,
62
+ } from "./lib.mjs";
63
+ import { configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
44
64
  import { planScaffold, applyScaffold } from "./scaffold.mjs";
45
65
  import { icon, kv, commandBlock, wrap, PLAIN } from "./term.mjs";
46
66
 
47
67
  export const DEFAULT_LAYOUT = "engineering";
48
68
  export const DEFAULT_LANGUAGE = "en";
49
69
  // commands/bind.md step 3, in its order.
50
- export const FRESH_CONFIG_KEYS = Object.freeze(["vault_path", "layout", "auto_inject", "language", "tags", "default_author", "active_skills", "approval_mode"]);
70
+ export const FRESH_CONFIG_KEYS = Object.freeze([
71
+ "vault_path",
72
+ "layout",
73
+ "auto_inject",
74
+ "language",
75
+ "tags",
76
+ "default_author",
77
+ "active_skills",
78
+ "approval_mode",
79
+ ]);
51
80
 
52
81
  export function layoutNames(root = pluginRoot()) {
53
82
  const dir = join(root, "scaffold", "layouts");
54
83
  if (!existsSync(dir)) return [];
55
- return readdirSync(dir).filter((n) => n.endsWith(".json")).map((n) => n.replace(/\.json$/, "")).sort();
84
+ return readdirSync(dir)
85
+ .filter((n) => n.endsWith(".json"))
86
+ .map((n) => n.replace(/\.json$/, ""))
87
+ .sort();
56
88
  }
57
89
 
58
90
  export function languageNames(root = pluginRoot()) {
59
91
  const dir = join(root, "templates");
60
92
  if (!existsSync(dir)) return [];
61
- return readdirSync(dir).filter((n) => { try { return statSync(join(dir, n)).isDirectory(); } catch { return false; } }).sort();
93
+ return readdirSync(dir)
94
+ .filter((n) => {
95
+ try {
96
+ return statSync(join(dir, n)).isDirectory();
97
+ } catch {
98
+ return false;
99
+ }
100
+ })
101
+ .sort();
62
102
  }
63
103
 
64
104
  // `~` expanded, made absolute against the project, trailing slash dropped.
@@ -75,22 +115,36 @@ export function normaliseVaultPath(p, projectDir, home = homedir()) {
75
115
  return s;
76
116
  }
77
117
 
78
- const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
118
+ const realOr = (p) => {
119
+ try {
120
+ return realpathSync(p);
121
+ } catch {
122
+ return p;
123
+ }
124
+ };
79
125
 
80
126
  // The existing config as {cfg, corrupt}: a file that exists but does not parse
81
127
  // is not "unbound" here — treating it so would overwrite the keys a rebind
82
128
  // promises to keep.
83
129
  function readExisting(configPath) {
84
130
  if (!existsSync(configPath)) return { cfg: null, corrupt: false };
85
- try { return { cfg: JSON.parse(readFileSync(configPath, "utf8")), corrupt: false }; } catch { return { cfg: null, corrupt: true }; }
131
+ try {
132
+ return { cfg: JSON.parse(readFileSync(configPath, "utf8")), corrupt: false };
133
+ } catch {
134
+ return { cfg: null, corrupt: true };
135
+ }
86
136
  }
87
137
 
88
- export const shellQuote = (s) => (/^[A-Za-z0-9_\/.~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
138
+ export const shellQuote = (s) => (/^[A-Za-z0-9_/.~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
89
139
 
90
140
  // A directory with no entries at all. A `.DS_Store` makes it not empty: that
91
141
  // edge stays visible rather than guessed at. Unreadable is not empty either.
92
142
  function isEmptyDir(p) {
93
- try { return statSync(p).isDirectory() && readdirSync(p).length === 0; } catch { return false; }
143
+ try {
144
+ return statSync(p).isDirectory() && readdirSync(p).length === 0;
145
+ } catch {
146
+ return false;
147
+ }
94
148
  }
95
149
 
96
150
  // What `bind` would write. `state`: "unbound" (fresh bind), "same" (already
@@ -101,10 +155,32 @@ function isEmptyDir(p) {
101
155
  // (exit 1). `buildsVault`: `init` on a missing or empty directory — mkdir
102
156
  // (createsVault, missing only), git and the scaffold follow the binding.
103
157
  // `scaffold` is the scaffold plan for the vault, on every path that has one.
104
- export function planBind(projectDir, { vault, layout = null, language = null, rebind = false, init = false, author = "", env = process.env, root = pluginRoot(), home = homedir() } = {}) {
158
+ // `scaffoldExisting` (setup's "bind existing + scaffold", its rule 7): an
159
+ // existing, non-empty vault whose scaffold plan has a `create` row is bound
160
+ // AND scaffolded — never `git init`ed, never mkdir'd — `scaffoldsExisting`;
161
+ // the scaffold's refusals are then the plan's, refused as one.
162
+ export function planBind(
163
+ projectDir,
164
+ {
165
+ vault,
166
+ layout = null,
167
+ language = null,
168
+ rebind = false,
169
+ init = false,
170
+ scaffoldExisting = false,
171
+ author = "",
172
+ env = process.env,
173
+ root = pluginRoot(),
174
+ home = homedir(),
175
+ } = {},
176
+ ) {
105
177
  const refusals = [];
106
178
  const configPath = harnessConfigPath(projectDir, env);
107
- if (!existsSync(projectDir)) refusals.push({ code: "NO_PROJECT", message: `${projectDir} does not exist — a binding belongs to an existing project directory` });
179
+ if (!existsSync(projectDir))
180
+ refusals.push({
181
+ code: "NO_PROJECT",
182
+ message: `${projectDir} does not exist — a binding belongs to an existing project directory`,
183
+ });
108
184
  const norm = normaliseVaultPath(vault, projectDir, home);
109
185
  const vaultPath = typeof norm === "string" ? norm : null;
110
186
  if (!norm) refusals.push({ code: "USAGE", message: "a vault path is required" });
@@ -112,46 +188,319 @@ export function planBind(projectDir, { vault, layout = null, language = null, re
112
188
  const layouts = layoutNames(root);
113
189
  const languages = languageNames(root);
114
190
  const { cfg: before, corrupt } = readExisting(configPath);
115
- if (corrupt) refusals.push({ code: "UNREADABLE", message: `${configPath} exists but is not valid JSON — fix or remove it before binding; nothing is overwritten` });
191
+ if (corrupt)
192
+ refusals.push({
193
+ code: "UNREADABLE",
194
+ message: `${configPath} exists but is not valid JSON — fix or remove it before binding; nothing is overwritten`,
195
+ });
116
196
  const storedVault = before ? normaliseVaultPath(before.vault_path, projectDir, home) : null;
117
- const sameVault = Boolean(vaultPath && typeof storedVault === "string" && (storedVault === vaultPath || (existsSync(storedVault) && existsSync(vaultPath) && realOr(storedVault) === realOr(vaultPath))));
197
+ const sameVault = Boolean(
198
+ vaultPath &&
199
+ typeof storedVault === "string" &&
200
+ (storedVault === vaultPath ||
201
+ (existsSync(storedVault) && existsSync(vaultPath) && realOr(storedVault) === realOr(vaultPath))),
202
+ );
118
203
  const state = !before ? "unbound" : sameVault ? "same" : "different";
119
- const chosenLayout = state === "same" ? (before.layout || DEFAULT_LAYOUT) : layout ?? (before ? before.layout : null) ?? DEFAULT_LAYOUT;
120
- const chosenLanguage = state === "same" ? (before.language || DEFAULT_LANGUAGE) : language ?? (before ? before.language : null) ?? DEFAULT_LANGUAGE;
121
- const ignored = state === "same" ? [layout !== null && layout !== chosenLayout ? "layout" : null, language !== null && language !== chosenLanguage ? "language" : null].filter(Boolean) : [];
204
+ const chosenLayout =
205
+ state === "same" ? before.layout || DEFAULT_LAYOUT : (layout ?? (before ? before.layout : null) ?? DEFAULT_LAYOUT);
206
+ const chosenLanguage =
207
+ state === "same"
208
+ ? before.language || DEFAULT_LANGUAGE
209
+ : (language ?? (before ? before.language : null) ?? DEFAULT_LANGUAGE);
210
+ const ignored =
211
+ state === "same"
212
+ ? [
213
+ layout !== null && layout !== chosenLayout ? "layout" : null,
214
+ language !== null && language !== chosenLanguage ? "language" : null,
215
+ ].filter(Boolean)
216
+ : [];
122
217
  if (state !== "same") {
123
- if (layouts.length && !layouts.includes(chosenLayout)) refusals.push({ code: "USAGE", message: `unknown layout "${chosenLayout}" — one of: ${layouts.join(", ")}` });
124
- if (languages.length && !languages.includes(chosenLanguage)) refusals.push({ code: "USAGE", message: `unknown language "${chosenLanguage}" — one of: ${languages.join(", ")}` });
218
+ if (layouts.length && !layouts.includes(chosenLayout))
219
+ refusals.push({ code: "USAGE", message: `unknown layout "${chosenLayout}" — one of: ${layouts.join(", ")}` });
220
+ if (languages.length && !languages.includes(chosenLanguage))
221
+ refusals.push({
222
+ code: "USAGE",
223
+ message: `unknown language "${chosenLanguage}" — one of: ${languages.join(", ")}`,
224
+ });
125
225
  }
126
226
  const vaultExists = vaultPath ? existsSync(vaultPath) : false;
127
- if (vaultPath && vaultExists && !statSync(vaultPath).isDirectory()) refusals.push({ code: "USAGE", message: `${vaultPath} is not a directory` });
128
- if (vaultPath && !vaultExists && !init) refusals.push({ code: "MISSING", message: `${vaultPath} does not exist — create it first, or run \`projectstore init ${shellQuote(String(vault))}${rebind ? " --rebind" : ""}\` to create and bind in one step` });
129
- if (state === "different" && !rebind) refusals.push({ code: "REBIND", message: `${projectDir} is bound to ${before.vault_path}; pass --rebind to point it at ${vaultPath} instead (every other setting is kept)` });
227
+ if (vaultPath && vaultExists && !statSync(vaultPath).isDirectory())
228
+ refusals.push({ code: "USAGE", message: `${vaultPath} is not a directory` });
229
+ // init in the core's form, alone on the last line, the directory in the
230
+ // lead (the story "User-facing text says where to run…", rules 1, 4 and 6).
231
+ if (vaultPath && !vaultExists && !init)
232
+ refusals.push({
233
+ code: "MISSING",
234
+ message: `${vaultPath} does not exist. Create it first, or create and bind it in one step ${fromTerminalIn(projectDir)}:\n${packageCommand([], "init", { args: `${shellQuote(String(vault))}${rebind ? " --rebind" : ""}` })}`,
235
+ });
236
+ if (state === "different" && !rebind)
237
+ refusals.push({
238
+ code: "REBIND",
239
+ message: `${projectDir} is bound to ${before.vault_path}; pass --rebind to point it at ${vaultPath} instead (every other setting is kept)`,
240
+ });
130
241
  const scaffold = vaultPath ? planScaffold(vaultPath, { layout: chosenLayout, language: chosenLanguage, root }) : null;
131
242
  const missingFolders = Boolean(scaffold && scaffold.ok && scaffold.creates > 0);
132
243
  // Only while the vault is there. A binding whose vault directory was deleted
133
244
  // made `init` refuse as BOUND, so "run init" — the scaffold verb's own
134
245
  // advice for a missing vault — led nowhere; such an `init` rebuilds it
135
246
  // under the binding it already has, writing no config.
136
- if (state === "same" && init && vaultExists) refusals.push({ code: "BOUND", message: `${projectDir} is already bound to ${vaultPath}${missingFolders ? ` — ${commandForm("scaffold", { env })} in a session, or \`projectstore scaffold --write\`, creates the layout's missing folders and READMEs` : ""}` });
247
+ // The scaffold that completes it: prose, then each form alone on its line,
248
+ // the terminal's last (the story "User-facing text says where to run and
249
+ // names no project path and no shell", rules 4 and 6).
250
+ if (state === "same" && init && vaultExists)
251
+ refusals.push({
252
+ code: "BOUND",
253
+ message: missingFolders
254
+ ? [
255
+ `${projectDir} is already bound to ${vaultPath}. Create the layout's missing folders and READMEs, in a session or from a terminal:`,
256
+ commandForm("scaffold", { env }),
257
+ packageCommand([], "scaffold", { args: "--write" }),
258
+ ].join("\n")
259
+ : `${projectDir} is already bound to ${vaultPath}`,
260
+ });
137
261
  const buildable = Boolean(init && vaultPath && refusals.length === 0 && (!vaultExists || isEmptyDir(vaultPath)));
138
262
  // The whole-vault path is refused as one plan: a missing template or folder
139
263
  // string stops the mkdir too. Elsewhere the scaffold plan's refusals are not
140
264
  // the binding's — a bind into a vault this language cannot scaffold still
141
265
  // binds, and its Next line names `status`.
142
266
  if (buildable && scaffold) refusals.push(...scaffold.refusals);
143
- const after = state === "same" ? before : state === "different"
144
- ? { ...before, vault_path: vaultPath, layout: chosenLayout, language: chosenLanguage }
145
- : { vault_path: vaultPath, layout: chosenLayout, auto_inject: true, language: chosenLanguage, tags: [], default_author: author || "", active_skills: true, approval_mode: "always" };
146
- const keptKeys = state === "different" ? Object.keys(before).filter((k) => !["vault_path", "layout", "language"].includes(k)).sort() : [];
267
+ const existing = Boolean(
268
+ scaffoldExisting && vaultPath && vaultExists && state !== "same" && !buildable && scaffold && scaffold.creates > 0,
269
+ );
270
+ if (existing) refusals.push(...scaffold.refusals);
271
+ const after =
272
+ state === "same"
273
+ ? before
274
+ : state === "different"
275
+ ? { ...before, vault_path: vaultPath, layout: chosenLayout, language: chosenLanguage }
276
+ : {
277
+ vault_path: vaultPath,
278
+ layout: chosenLayout,
279
+ auto_inject: true,
280
+ language: chosenLanguage,
281
+ tags: [],
282
+ default_author: author || "",
283
+ active_skills: true,
284
+ approval_mode: "always",
285
+ };
286
+ const keptKeys =
287
+ state === "different"
288
+ ? Object.keys(before)
289
+ .filter((k) => !["vault_path", "layout", "language"].includes(k))
290
+ .sort()
291
+ : [];
292
+ const ok = refusals.length === 0;
293
+ return {
294
+ projectDir,
295
+ configPath,
296
+ vault: vaultPath,
297
+ vaultExists,
298
+ layout: chosenLayout,
299
+ language: chosenLanguage,
300
+ ignored,
301
+ state,
302
+ before,
303
+ after,
304
+ keptKeys,
305
+ writes: ok && state !== "same",
306
+ createsVault: Boolean(init && vaultPath && !vaultExists && ok),
307
+ buildsVault: buildable && ok,
308
+ scaffoldsExisting: existing && ok,
309
+ scaffold,
310
+ refusals,
311
+ ok,
312
+ inherit: null,
313
+ };
314
+ }
315
+
316
+ // The keys a worktree's binding never takes from its main checkout: each
317
+ // records a choice about THIS checkout's own rows in the host's registry
318
+ // (local scope, one row per checkout), so another checkout's answer does not
319
+ // apply here (the setup story, rule 7; commands/bind.md step 0a).
320
+ export const INHERIT_DROPS = Object.freeze([CHANNEL_SWITCH_KEY, CHANNEL_KEEP_KEY]);
321
+
322
+ // The worktree row (the setup story, rule 7): `projectDir`'s binding copied
323
+ // from the checkout `from` — read through readConfigAt, the new path then the
324
+ // legacy one — every key verbatim but those in `drop`, as bind's inherit
325
+ // path (step 0a) copies it. The vault is the parent's and is shared: it is
326
+ // never created, git-initialised or scaffolded here. A relative vault_path
327
+ // is relative to the parent, so the copy carries it resolved there. The plan
328
+ // has planBind's shape, so applyBind writes it (with the .projectstore/
329
+ // .gitignore) and bindResult reports it; `inherit` names the checkout, the
330
+ // keys never copied (`drop`) and those its binding carried (`dropped`).
331
+ // Refusals: NO_PROJECT, USAGE (no checkout named,
332
+ // a `~user` vault path, a vault that is not a directory — as planBind
333
+ // refuses it), NOT_BOUND (the checkout has no binding, or one that
334
+ // names no vault), MISSING (its vault is gone), UNREADABLE, and REBIND for a
335
+ // project bound elsewhere — inheriting never rebinds.
336
+ export function planInherit(
337
+ projectDir,
338
+ { from, drop = INHERIT_DROPS, env = process.env, root = pluginRoot(), home = homedir() } = {},
339
+ ) {
340
+ const refusals = [];
341
+ const configPath = harnessConfigPath(projectDir, env);
342
+ if (!existsSync(projectDir))
343
+ refusals.push({
344
+ code: "NO_PROJECT",
345
+ message: `${projectDir} does not exist — a binding belongs to an existing project directory`,
346
+ });
347
+ const parent = from ? readConfigAt(from) : null;
348
+ const usable =
349
+ parent &&
350
+ typeof parent === "object" &&
351
+ !Array.isArray(parent) &&
352
+ typeof parent.vault_path === "string" &&
353
+ parent.vault_path.trim() !== "";
354
+ if (!from) refusals.push({ code: "USAGE", message: "a checkout to inherit the binding from is required" });
355
+ else if (!usable)
356
+ refusals.push({
357
+ code: "NOT_BOUND",
358
+ message: `${from} has no binding naming a vault, so there is nothing to inherit`,
359
+ });
360
+ const norm = usable ? normaliseVaultPath(parent.vault_path, from, home) : null;
361
+ const vaultPath = typeof norm === "string" ? norm : null;
362
+ if (norm && norm.error) refusals.push({ code: "USAGE", message: norm.error });
363
+ const vaultExists = vaultPath ? existsSync(vaultPath) : false;
364
+ if (vaultPath && !vaultExists)
365
+ refusals.push({ code: "MISSING", message: `${vaultPath}, the vault ${from} is bound to, does not exist` });
366
+ // As planBind refuses it: a vault is a directory.
367
+ const vaultIsDir = vaultExists && statSync(vaultPath).isDirectory();
368
+ if (vaultExists && !vaultIsDir)
369
+ refusals.push({ code: "USAGE", message: `${vaultPath}, the vault ${from} is bound to, is not a directory` });
370
+ const { cfg: before, corrupt } = readExisting(configPath);
371
+ if (corrupt)
372
+ refusals.push({
373
+ code: "UNREADABLE",
374
+ message: `${configPath} exists but is not valid JSON — fix or remove it before binding; nothing is overwritten`,
375
+ });
376
+ const storedVault = before ? normaliseVaultPath(before.vault_path, projectDir, home) : null;
377
+ const sameVault = Boolean(
378
+ vaultPath &&
379
+ typeof storedVault === "string" &&
380
+ (storedVault === vaultPath ||
381
+ (existsSync(storedVault) && existsSync(vaultPath) && realOr(storedVault) === realOr(vaultPath))),
382
+ );
383
+ const state = !before ? "unbound" : sameVault ? "same" : "different";
384
+ if (state === "different")
385
+ refusals.push({
386
+ code: "REBIND",
387
+ message: `${projectDir} is bound to ${before.vault_path}; inheriting never rebinds`,
388
+ });
389
+ const dropped = usable ? drop.filter((k) => Object.hasOwn(parent, k)) : [];
390
+ const copied = usable ? Object.fromEntries(Object.entries(parent).filter(([k]) => !drop.includes(k))) : null;
391
+ // `~` means the same home in both checkouts; a relative path does not.
392
+ if (copied && vaultPath && !isAbsolute(parent.vault_path) && !parent.vault_path.trim().startsWith("~"))
393
+ copied.vault_path = vaultPath;
394
+ const layout = (copied && copied.layout) || DEFAULT_LAYOUT;
395
+ const language = (copied && copied.language) || DEFAULT_LANGUAGE;
396
+ const scaffold = vaultPath && vaultIsDir ? planScaffold(vaultPath, { layout, language, root }) : null;
147
397
  const ok = refusals.length === 0;
148
398
  return {
149
- projectDir, configPath, vault: vaultPath, vaultExists, layout: chosenLayout, language: chosenLanguage, ignored,
150
- state, before, after, keptKeys, writes: ok && state !== "same", createsVault: Boolean(init && vaultPath && !vaultExists && ok),
151
- buildsVault: buildable && ok, scaffold, refusals, ok,
399
+ projectDir,
400
+ configPath,
401
+ vault: vaultPath,
402
+ vaultExists,
403
+ layout,
404
+ language,
405
+ ignored: [],
406
+ state,
407
+ before,
408
+ after: state === "same" ? before : copied,
409
+ keptKeys: [],
410
+ writes: ok && state === "unbound",
411
+ createsVault: false,
412
+ buildsVault: false,
413
+ scaffoldsExisting: false,
414
+ scaffold,
415
+ refusals,
416
+ ok,
417
+ inherit: from ? { from, drop: [...drop], dropped } : null,
152
418
  };
153
419
  }
154
420
 
421
+ // The steps a binding plan writes, in apply's order — one list for the
422
+ // installer's PLAN (setup's `vault` item, the story's rule 10) and for the
423
+ // APPLY lines applyBind reports through `onStep`, so the two never disagree:
424
+ // mkdir the vault's directory (createsVault)
425
+ // bind the binding at configPath — for an inherit, the checkout it
426
+ // copies and the keys it drops
427
+ // git-init `git init --quiet` in the vault (buildsVault)
428
+ // scaffold one per layout folder with something to create — the folder
429
+ // and its README.md — then the vault's README.md: `rels` are
430
+ // vault-relative, a folder with its trailing slash
431
+ export function bindPlanSteps(p) {
432
+ if (!p || !p.ok) return [];
433
+ const steps = [];
434
+ if (p.createsVault) steps.push({ kind: "mkdir", path: p.vault, why: "the vault's directory" });
435
+ if (p.writes) {
436
+ steps.push({
437
+ kind: "bind",
438
+ path: p.configPath,
439
+ vault: p.vault,
440
+ layout: p.layout,
441
+ language: p.language,
442
+ ...(p.inherit ? { from: p.inherit.from, drop: [...p.inherit.drop], dropped: [...p.inherit.dropped] } : {}),
443
+ why: p.inherit
444
+ ? `the binding of ${p.inherit.from}, copied but for the keys that record that checkout's own registry rows; with .projectstore/.gitignore, which keeps it machine-local`
445
+ : "the binding, with .projectstore/.gitignore, which keeps it machine-local",
446
+ });
447
+ }
448
+ if (p.buildsVault)
449
+ steps.push({
450
+ kind: "git-init",
451
+ path: p.vault,
452
+ argv: ["git", "init", "--quiet"],
453
+ why: "the vault's own repository, with no commit, on your own init.defaultBranch",
454
+ });
455
+ if ((p.buildsVault || p.scaffoldsExisting) && p.scaffold && p.scaffold.ok) steps.push(...scaffoldSteps(p.scaffold));
456
+ return steps;
457
+ }
458
+
459
+ function scaffoldSteps(s) {
460
+ const out = [];
461
+ const shown = (r) => (r.type === "folder" ? `${r.rel}/` : r.rel);
462
+ const byRel = new Map(s.rows.map((r) => [r.rel, r]));
463
+ for (const r of s.rows.filter((x) => x.type === "folder")) {
464
+ const own = [r, byRel.get(`${r.rel}/README.md`)].filter((x) => x && x.action === "create");
465
+ if (own.length)
466
+ out.push({
467
+ kind: "scaffold",
468
+ path: own[0].abs,
469
+ vault: s.vault,
470
+ rels: own.map(shown),
471
+ why: "the layout's folder and its index",
472
+ });
473
+ }
474
+ const top = s.rows.find((r) => r.type === "vault-readme");
475
+ if (top && top.action === "create")
476
+ out.push({
477
+ kind: "scaffold",
478
+ path: top.abs,
479
+ vault: s.vault,
480
+ rels: [shown(top)],
481
+ why: "the vault's own README, its folder table",
482
+ });
483
+ return out;
484
+ }
485
+
486
+ // The apply-time check of a binding plan (the setup story, rule 10): the
487
+ // binding is still what the preview read — absent, or the same JSON, at the
488
+ // same path — and a vault the plan builds is still missing or empty. Returns
489
+ // the reason it does not hold, or null.
490
+ export function recheckBind(p, { env = process.env } = {}) {
491
+ const at = harnessConfigPath(p.projectDir, env);
492
+ const { cfg, corrupt } = readExisting(at);
493
+ if (
494
+ corrupt ||
495
+ resolve(at) !== resolve(p.configPath) ||
496
+ JSON.stringify(cfg ?? null) !== JSON.stringify(p.before ?? null)
497
+ )
498
+ return "the binding changed after the preview";
499
+ if (p.buildsVault && existsSync(p.vault) && !isEmptyDir(p.vault))
500
+ return `${p.vault} changed after the preview: it is no longer missing or empty`;
501
+ return null;
502
+ }
503
+
155
504
  // `git init` in a vault `init` just made: no commit, on the person's own
156
505
  // init.defaultBranch (no `-b`), quiet, with its stderr captured so git's
157
506
  // default-branch hint never reaches the person — its exit status alone
@@ -166,17 +515,67 @@ export function planBind(projectDir, { vault, layout = null, language = null, re
166
515
  // init still runs, and the result carries that top. A failure carries its
167
516
  // remedy, so a caller reading --json can relay it as the text does.
168
517
  const GIT_LOCAL_ENV = Object.freeze([
169
- "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
170
- "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
171
- "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
172
- "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
518
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
519
+ "GIT_CONFIG",
520
+ "GIT_CONFIG_PARAMETERS",
521
+ "GIT_CONFIG_COUNT",
522
+ "GIT_OBJECT_DIRECTORY",
523
+ "GIT_DIR",
524
+ "GIT_WORK_TREE",
525
+ "GIT_IMPLICIT_WORK_TREE",
526
+ "GIT_GRAFT_FILE",
527
+ "GIT_INDEX_FILE",
528
+ "GIT_NO_REPLACE_OBJECTS",
529
+ "GIT_REPLACE_REF_BASE",
530
+ "GIT_PREFIX",
531
+ "GIT_SHALLOW_FILE",
532
+ "GIT_COMMON_DIR",
173
533
  ]);
534
+ const gitEnv = (env) => {
535
+ const out = { ...(env || {}) };
536
+ for (const k of GIT_LOCAL_ENV) delete out[k];
537
+ return out;
538
+ };
539
+
540
+ // The top of the git work tree `cwd` lies in, realpath'd (macOS answers
541
+ // /private/var for /var), or null: outside a work tree (exit 128), git not on
542
+ // PATH, or no answer within `timeout` — each means "not in one" (the setup
543
+ // story, rule 4: 2 s). The environment is cleaned of every variable that
544
+ // locates a repository, as for git init: an inherited GIT_DIR would answer
545
+ // for another repository than the folder's.
546
+ export function gitTop(cwd, { env = process.env, spawn = spawnSync, timeout = 2000 } = {}) {
547
+ let r;
548
+ try {
549
+ r = spawn("git", ["rev-parse", "--show-toplevel"], {
550
+ cwd,
551
+ env: gitEnv(env),
552
+ encoding: "utf8",
553
+ timeout,
554
+ stdio: ["ignore", "pipe", "pipe"],
555
+ });
556
+ } catch {
557
+ return null;
558
+ }
559
+ if (!r || r.error || r.status !== 0) return null;
560
+ const top = String(r.stdout || "").trim();
561
+ return top ? realOr(top) : null;
562
+ }
563
+
174
564
  function runGit(vault, { spawn = spawnSync, env = process.env } = {}) {
175
- const childEnv = { ...env };
176
- for (const k of GIT_LOCAL_ENV) delete childEnv[k];
177
- const opts = { cwd: vault, env: childEnv, encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] };
565
+ const opts = { cwd: vault, env: gitEnv(env), encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] };
178
566
  const absent = (r) => r.error && r.error.code === "ENOENT";
179
- const failed = (message) => ({ failed: message, remedy: `Run \`git init\` in ${vault}, or ${commandForm("doctor", { args: "--fix", env })}, which offers it.` });
567
+ // The remedy: its lead names the vault, then git's command and doctor's fix
568
+ // in the session, each alone on its line — the commands setup's ▲ row
569
+ // names for the same failure (the story "User-facing text says where to
570
+ // run and names no project path and no shell", rule 4).
571
+ const failed = (message) => ({
572
+ failed: message,
573
+ remedy: [
574
+ `Run git init in ${JSON.stringify(String(vault))} from a terminal, or doctor's fix in a session, which offers it:`,
575
+ `git -C ${shellQuote(String(vault))} init`,
576
+ commandForm("doctor", { args: "--fix", env }),
577
+ ].join("\n"),
578
+ });
180
579
  const top = spawn("git", ["rev-parse", "--show-toplevel"], opts);
181
580
  if (absent(top)) return { skipped: "git is not on PATH" };
182
581
  const inside = top.status === 0 && top.stdout && top.stdout.trim() ? top.stdout.trim() : null;
@@ -187,7 +586,17 @@ function runGit(vault, { spawn = spawnSync, env = process.env } = {}) {
187
586
  return inside && realOr(inside) !== realOr(vault) ? { done: true, inside } : { done: true };
188
587
  }
189
588
 
190
- const SCAFFOLD_REMEDY = "The binding is written; `projectstore scaffold --write` finishes the vault.";
589
+ // What finishes the vault once the binding is written: its lead, then the
590
+ // session's form and the core's through npx, each alone on its line, the
591
+ // directory in the lead (the story "User-facing text says where to run and
592
+ // names no project path and no shell", rules 1, 4 and 6). --json carries it
593
+ // one part per line.
594
+ const scaffoldRemedy = (projectDir, env) =>
595
+ [
596
+ `The binding is written. Finish the vault in a session, or ${fromTerminalIn(projectDir)}:`,
597
+ commandForm("scaffold", { env }),
598
+ packageCommand([], "scaffold", { args: "--write" }),
599
+ ].join("\n");
191
600
 
192
601
  // Writes what the plan says, in the story's order: the vault directory when
193
602
  // init asked for it (not when it is there and empty), the config through
@@ -198,44 +607,112 @@ const SCAFFOLD_REMEDY = "The binding is written; `projectstore scaffold --write`
198
607
  // and the next step still runs — the binding is already written, so a re-run
199
608
  // of `init` would only be refused, and the output names the one command that
200
609
  // finishes the vault instead. `spawn` is a parameter so tests can stand in
201
- // for git.
202
- export function applyBind(plan, { spawn = spawnSync, env = process.env } = {}) {
203
- if (!plan.ok) throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), { code: plan.refusals[0].code });
610
+ // for git. A plan with `scaffoldsExisting` (an existing vault, bound) runs
611
+ // the scaffold after the binding, with no mkdir and no git init.
612
+ //
613
+ // `onStep(step, phase, result)` watches, as the installer's apply() steps
614
+ // do: each step of bindPlanSteps(plan) is reported "start", then "end" with
615
+ // { ok } — { ok, kept, label } for a git init skipped (git not on PATH) or a
616
+ // scaffold step whose files were all there already, { ok, warn } for a
617
+ // failed git init, which never stops the run. The scaffold's steps are
618
+ // reported once it has run: it writes every folder before any README.
619
+ export function applyBind(plan, { spawn = spawnSync, env = process.env, onStep = null } = {}) {
620
+ if (!plan.ok)
621
+ throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), { code: plan.refusals[0].code });
204
622
  const done = { created_vault: false, wrote_config: false, git: null, scaffold: null };
205
- if (plan.createsVault) { mkdirSync(plan.vault, { recursive: true }); done.created_vault = true; }
206
- if (plan.writes) {
207
- // Contract 5 names bind among the writers of the line-merged
208
- // .projectstore/.gitignore, and it was the one writer that never did:
209
- // measured 2026-09-06 on a fresh project, `init` left .projectstore/
210
- // holding the binding alone, so `git add -A` committed a machine-local
211
- // absolute vault path. Only when we are writing the NEW path — a rebind of
212
- // a not-yet-migrated project still writes the legacy file (contract 8),
213
- // and creating .projectstore/ beside a legacy layout is the migration
214
- // item's write, not ours.
215
- if (plan.configPath === layoutPaths(plan.projectDir).binding) {
216
- try { ensureRuntimeDir(plan.projectDir); } catch {}
623
+ const steps = onStep ? bindPlanSteps(plan) : [];
624
+ const stepOf = (kind) => steps.find((s) => s.kind === kind) || null;
625
+ const tell = (st, phase, r) => {
626
+ if (onStep && st) onStep(st, phase, r);
627
+ };
628
+ // A write that throws ends its step as failed before the error leaves.
629
+ const watched = (st, fn) => {
630
+ tell(st, "start");
631
+ try {
632
+ fn();
633
+ } catch (e) {
634
+ tell(st, "end", { ok: false });
635
+ throw e;
217
636
  }
218
- mkdirSync(dirname(plan.configPath), { recursive: true });
219
- writeFileAtomic(plan.configPath, JSON.stringify(plan.after, null, 2) + "\n");
220
- done.wrote_config = true;
637
+ tell(st, "end", { ok: true });
638
+ };
639
+ if (plan.createsVault) {
640
+ watched(stepOf("mkdir"), () => mkdirSync(plan.vault, { recursive: true }));
641
+ done.created_vault = true;
221
642
  }
643
+ if (plan.writes)
644
+ watched(stepOf("bind"), () => {
645
+ // Contract 5 names bind among the writers of the line-merged
646
+ // .projectstore/.gitignore, and it was the one writer that never did:
647
+ // measured 2026-09-06 on a fresh project, `init` left .projectstore/
648
+ // holding the binding alone, so `git add -A` committed a machine-local
649
+ // absolute vault path. Only when we are writing the NEW path — a rebind of
650
+ // a not-yet-migrated project still writes the legacy file (contract 8),
651
+ // and creating .projectstore/ beside a legacy layout is the migration
652
+ // item's write, not ours.
653
+ if (plan.configPath === layoutPaths(plan.projectDir).binding) {
654
+ try {
655
+ ensureRuntimeDir(plan.projectDir);
656
+ } catch {}
657
+ }
658
+ mkdirSync(dirname(plan.configPath), { recursive: true });
659
+ writeFileAtomic(plan.configPath, JSON.stringify(plan.after, null, 2) + "\n");
660
+ done.wrote_config = true;
661
+ });
222
662
  if (plan.buildsVault) {
663
+ const st = stepOf("git-init");
664
+ tell(st, "start");
223
665
  done.git = runGit(plan.vault, { spawn, env });
224
- try { done.scaffold = applyScaffold(plan.scaffold); } catch (e) { done.scaffold = { failed: e && e.message ? e.message : String(e), remedy: SCAFFOLD_REMEDY }; }
666
+ const g = done.git;
667
+ tell(
668
+ st,
669
+ "end",
670
+ g.failed
671
+ ? { ok: true, warn: `git init failed: ${String(g.failed).split("\n")[0]}` }
672
+ : g.skipped
673
+ ? { ok: true, kept: true, label: `skipped git init: ${g.skipped}` }
674
+ : { ok: true },
675
+ );
676
+ }
677
+ if (plan.buildsVault || plan.scaffoldsExisting) {
678
+ try {
679
+ done.scaffold = applyScaffold(plan.scaffold);
680
+ } catch (e) {
681
+ done.scaffold = { failed: e && e.message ? e.message : String(e), remedy: scaffoldRemedy(plan.projectDir, env) };
682
+ }
683
+ const made = new Set(done.scaffold.failed ? [] : done.scaffold.created.map((r) => r.replace(/\/$/, "")));
684
+ for (const st of steps.filter((s) => s.kind === "scaffold")) {
685
+ tell(st, "start");
686
+ if (done.scaffold.failed) {
687
+ tell(st, "end", { ok: false });
688
+ break;
689
+ }
690
+ tell(st, "end", { ok: true, kept: !st.rels.some((r) => made.has(r.replace(/\/$/, ""))) });
691
+ }
225
692
  }
226
693
  return done;
227
694
  }
228
695
 
229
696
  // The result a front-end reports: the decided values, never the file body.
230
- // `git` and `scaffold` are null unless `init` built the vault: git is
697
+ // `git` and `scaffold` are null unless `init` built the vault (`scaffold`
698
+ // also when setup scaffolded an existing one): git is
231
699
  // {done: true[, inside]}, {skipped: reason} or {failed: message, remedy};
232
700
  // scaffold is {created, exists} or {failed: message, remedy}.
233
701
  export function bindResult(plan, done) {
234
702
  return {
235
- state: plan.state, vault_path: plan.vault, vault_exists: Boolean(done && done.created_vault) || plan.vaultExists, layout: plan.layout, language: plan.language,
236
- config_path: plan.configPath, wrote: Boolean(done && done.wrote_config), created_vault: Boolean(done && done.created_vault),
237
- kept_keys: plan.keptKeys, ignored: plan.ignored, refusals: plan.refusals,
238
- git: (done && done.git) || null, scaffold: (done && done.scaffold) || null,
703
+ state: plan.state,
704
+ vault_path: plan.vault,
705
+ vault_exists: Boolean(done && done.created_vault) || plan.vaultExists,
706
+ layout: plan.layout,
707
+ language: plan.language,
708
+ config_path: plan.configPath,
709
+ wrote: Boolean(done && done.wrote_config),
710
+ created_vault: Boolean(done && done.created_vault),
711
+ kept_keys: plan.keptKeys,
712
+ ignored: plan.ignored,
713
+ refusals: plan.refusals,
714
+ git: (done && done.git) || null,
715
+ scaffold: (done && done.scaffold) || null,
239
716
  };
240
717
  }
241
718
 
@@ -253,31 +730,66 @@ const prose = (c, text, lead = "") => lead + wrap(text, c.live ? c.width : 0, le
253
730
  // The bind Next lines, one decision: the scaffold plan has a `create` row.
254
731
  // Both forms — the session's and the bin's — because the bind interview
255
732
  // relays this to a person who runs slash commands, and a git-marketplace
256
- // install has no bin on PATH: the terminal's form is the package this run was
257
- // invoked as, through npx (harness.mjs invokedAs — a shell's own name when it
258
- // came through one). `--write`: at a terminal that form shows the plan and
259
- // asks anyway. Each command stands on its own line, after a blank one
260
- // (presentation spec contract 6), so a copy takes exactly the command.
733
+ // install has no bin on PATH: the terminal's form is the core through npx
734
+ // (harness.mjs packageCommand), through a shell too — what a person runs next
735
+ // names no shell (the story "User-facing text says where to run and names no
736
+ // project path and no shell", rule 6). `--write`: at a terminal that form
737
+ // shows the plan and asks anyway. Each command stands on its own line, after
738
+ // a blank one (presentation spec contract 6), so a copy takes exactly the
739
+ // command.
261
740
  function nextLines(p, env, c) {
262
- const { cmd } = invokedAs(env);
263
741
  return p.scaffold && p.scaffold.ok && p.scaffold.creates > 0
264
- ? ["", prose(c, "Next: create the layout's missing folders and READMEs, in a session or from a terminal:"), ...commandBlock(c, [commandForm("scaffold", { env }), `${cmd} scaffold --write`])]
265
- : ["", prose(c, "Next: see where the project stands:"), ...commandBlock(c, [`${cmd} status`])];
742
+ ? [
743
+ "",
744
+ prose(c, "Next: create the layout's missing folders and READMEs, in a session or from a terminal:"),
745
+ ...commandBlock(c, [commandForm("scaffold", { env }), packageCommand([], "scaffold", { args: "--write" })]),
746
+ ]
747
+ : ["", prose(c, "Next: see where the project stands:"), ...commandBlock(c, [packageCommand([], "status")])];
266
748
  }
267
749
 
268
750
  // One line per step that ran. The whole-vault path's own Next lines: the
269
751
  // derived views are reconcile's, offered rather than run.
270
- function vaultSteps(p, done, c, env) {
752
+ function vaultSteps(p, done, c) {
271
753
  const lines = [];
754
+ // A step's remedy: its lead as prose, then each command after it alone on
755
+ // its line, after a blank one (the story "User-facing text says where to
756
+ // run…", rule 4). A remedy of one line is prose alone.
757
+ const remedy = (text) => {
758
+ const [lead, ...commands] = String(text).split("\n");
759
+ return [prose(c, lead, " "), ...(commands.length ? commandBlock(c, commands, { indent: 4 }) : [])];
760
+ };
272
761
  const g = done.git;
273
762
  if (g && g.done) lines.push(prose(c, `Initialised a git repository in ${p.vault} (no commit).`));
274
- if (g && g.inside) lines.push(prose(c, `\`${p.vault}\` is inside the git work tree at \`${g.inside}\`; the vault gets its own repository, which that repository will see as an embedded one.`));
275
- if (g && g.skipped) lines.push(prose(c, `Skipped git init: ${g.skipped}. The vault works without one; doctor's vault-git finding stays until it is a repository.`));
276
- if (g && g.failed) lines.push(prose(c, `git init failed: ${g.failed}`), prose(c, g.remedy, " "));
763
+ if (g && g.inside)
764
+ lines.push(
765
+ prose(
766
+ c,
767
+ `\`${p.vault}\` is inside the git work tree at \`${g.inside}\`; the vault gets its own repository, which that repository will see as an embedded one.`,
768
+ ),
769
+ );
770
+ if (g && g.skipped)
771
+ lines.push(
772
+ prose(
773
+ c,
774
+ `Skipped git init: ${g.skipped}. The vault works without one; doctor's vault-git finding stays until it is a repository.`,
775
+ ),
776
+ );
777
+ if (g && g.failed) lines.push(prose(c, `git init failed: ${g.failed}`), ...remedy(g.remedy));
277
778
  const s = done.scaffold;
278
- if (s && s.failed) lines.push(prose(c, `Scaffold failed: ${s.failed}`), prose(c, s.remedy, " "));
279
- else if (s) lines.push(prose(c, `Scaffolded the ${p.layout} layout (${p.language}): ${s.created.length} folders and READMEs created${s.exists.length ? `, ${s.exists.length} already there` : ""}.`));
280
- if (!bindFailed(done)) lines.push("", prose(c, `Next, optional: generate the derived views ${icon(c, "dash")} kanban.md, graph.md and code-map.md:`), ...commandBlock(c, [`${invokedAs(env).cmd} reconcile --write`]));
779
+ if (s && s.failed) lines.push(prose(c, `Scaffold failed: ${s.failed}`), ...remedy(s.remedy));
780
+ else if (s)
781
+ lines.push(
782
+ prose(
783
+ c,
784
+ `Scaffolded the ${p.layout} layout (${p.language}): ${s.created.length} folders and READMEs created${s.exists.length ? `, ${s.exists.length} already there` : ""}.`,
785
+ ),
786
+ );
787
+ if (!bindFailed(done))
788
+ lines.push(
789
+ "",
790
+ prose(c, `Next, optional: generate the derived views ${icon(c, "dash")} kanban.md, graph.md and code-map.md:`),
791
+ ...commandBlock(c, [packageCommand([], "reconcile", { args: "--write" })]),
792
+ );
281
793
  return lines;
282
794
  }
283
795
 
@@ -287,19 +799,38 @@ function vaultSteps(p, done, c, env) {
287
799
  // the stream's (term.mjs); the default is plain text.
288
800
  export function renderBindPlan(p, done = null, { env = process.env, caps: c = PLAIN } = {}) {
289
801
  const lines = [];
290
- if (!p.ok) { for (const r of p.refusals) lines.push(r.message); return lines.join("\n") + "\n"; }
291
- const ignoredNote = p.ignored.length ? ` ${icon(c, "dash")} --${p.ignored.join(" and --")} ignored: a change of ${p.ignored.join("/")} is not a rebind (edit the config, or rebind to another vault)` : "";
802
+ if (!p.ok) {
803
+ for (const r of p.refusals) lines.push(r.message);
804
+ return lines.join("\n") + "\n";
805
+ }
806
+ const ignoredNote = p.ignored.length
807
+ ? ` ${icon(c, "dash")} --${p.ignored.join(" and --")} ignored: a change of ${p.ignored.join("/")} is not a rebind (edit the config, or rebind to another vault)`
808
+ : "";
292
809
  if (p.state === "same" && !(done && p.buildsVault)) {
293
810
  lines.push(prose(c, `Already bound to ${p.vault}${ignoredNote}.`), ...nextLines(p, env, c));
294
811
  return lines.join("\n") + "\n";
295
812
  }
296
813
  if (done && done.created_vault) lines.push(prose(c, `Created ${p.vault}`));
297
- if (p.state === "same") lines.push(prose(c, `Already bound to ${p.vault}${ignoredNote}; its directory was missing, so init made the vault again.`));
814
+ if (p.state === "same")
815
+ lines.push(
816
+ prose(c, `Already bound to ${p.vault}${ignoredNote}; its directory was missing, so init made the vault again.`),
817
+ );
298
818
  else {
299
- lines.push(prose(c, `Wrote ${p.configPath}${p.state === "different" ? ` (rebind from ${p.before.vault_path}; kept: ${p.keptKeys.join(", ") || "nothing else"})` : ""}`));
300
- lines.push(...kv(c, [["vault_path", p.vault], ["layout", p.layout], ["language", p.language]]));
819
+ lines.push(
820
+ prose(
821
+ c,
822
+ `Wrote ${p.configPath}${p.state === "different" ? ` (rebind from ${p.before.vault_path}; kept: ${p.keptKeys.join(", ") || "nothing else"})` : ""}`,
823
+ ),
824
+ );
825
+ lines.push(
826
+ ...kv(c, [
827
+ ["vault_path", p.vault],
828
+ ["layout", p.layout],
829
+ ["language", p.language],
830
+ ]),
831
+ );
301
832
  }
302
- if (done && p.buildsVault) lines.push(...vaultSteps(p, done, c, env));
833
+ if (done && p.buildsVault) lines.push(...vaultSteps(p, done, c));
303
834
  else if (done) lines.push(...nextLines(p, env, c));
304
835
  return lines.join("\n") + "\n";
305
836
  }