projectstore-claude 0.29.2 → 0.29.3

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 +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -24,17 +24,16 @@ import {
24
24
  compareArtifactOrder, isMain,
25
25
  commandForm,
26
26
  } from "./lib.mjs";
27
+ // The status table lives in stages.mjs, Node-free, so the band above the
28
+ // prompt counts stories exactly as the board files them.
29
+ import { NOT_ACTIONABLE, statusToColumn } from "./stages.mjs";
30
+ export { NOT_ACTIONABLE, statusToColumn };
27
31
 
28
32
  function die(msg) {
29
33
  process.stderr.write(`projectstore/kanban: ${msg}\n`);
30
34
  process.exit(1);
31
35
  }
32
36
 
33
- // Statuses that describe work which is not on the board by definition: it was
34
- // replaced, postponed, or set aside. Putting them in a column would claim the
35
- // work is queued, which is the opposite of what the status says.
36
- const NOT_ACTIONABLE = new Set(["superseded", "deferred", "parked"]);
37
-
38
37
  export function findStories(vault, epicsPath) {
39
38
  const root = join(vault, epicsPath);
40
39
  const stories = [];
@@ -92,21 +91,6 @@ export function findStories(vault, epicsPath) {
92
91
  return { stories, skipped };
93
92
  }
94
93
 
95
- export function statusToColumn(status) {
96
- const m = {
97
- planned: "Backlog",
98
- todo: "ToDo",
99
- "to-do": "ToDo",
100
- "in-progress": "In Progress",
101
- in_progress: "In Progress",
102
- "in progress": "In Progress",
103
- review: "Review",
104
- done: "Done",
105
- closed: "Done",
106
- };
107
- return m[status] || "Backlog";
108
- }
109
-
110
94
  function renderItem(story) {
111
95
  const tags = [`#${story.priority}`];
112
96
  if (story.status === "done") tags.push("#done");
@@ -8,6 +8,7 @@ import { join, dirname, basename, resolve, relative, isAbsolute } from "node:pat
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { hostname, homedir } from "node:os";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
+ import { parseFrontmatter } from "./stages.mjs";
11
12
  import {
12
13
  projectRoot as harnessProjectRoot,
13
14
  pluginRoot as harnessPluginRoot,
@@ -20,6 +21,7 @@ import {
20
21
  overlayId,
21
22
  hostSettingsPath,
22
23
  layoutPaths,
24
+ cachePaths,
23
25
  pickExisting,
24
26
  LAYOUT,
25
27
  RUNTIME_GITIGNORE_HEADER,
@@ -31,6 +33,7 @@ import {
31
33
  invocation,
32
34
  speakingHarness,
33
35
  packageCommand,
36
+ whichOnPath,
34
37
  } from "./harness.mjs";
35
38
 
36
39
  // ─── Paths ─────────────────────────────────────────────────────────────
@@ -103,10 +106,35 @@ export function speakingDisplayName({ env = process.env } = {}) {
103
106
  return speakingHarness(env)?.display_name || "another";
104
107
  }
105
108
 
109
+ // ─── doctor's last line (presentation spec, contract 11) ───────────────
110
+ //
111
+ // The Summary line ends every text report of doctor's, verbatim from its
112
+ // 0.29.2 report: the write ceremony captures it as the pre-state and compares
113
+ // it after (commands/story.md step 5a, agents/clerk.md), so its bytes are a
114
+ // contract. It lives here, beside commandForm, because every printer of the
115
+ // report needs it — doctor-report.mjs, and the fallback of each caller when
116
+ // that renderer cannot be loaded or throws — and this is the one module all
117
+ // of them already import. `env` decides the command forms.
118
+ export function doctorSummaryLine(findings, { env = process.env } = {}) {
119
+ const issues = findings.filter((f) => f.level === "issue").length;
120
+ const warns = findings.filter((f) => f.level === "warn").length;
121
+ return `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix", env })} (install), ${commandForm("kanban", { env })} / reconcile (vault).` : "Vault and wiring look healthy."}`;
122
+ }
123
+
124
+ // What a reader gets when the renderer cannot draw the findings: each one
125
+ // per line, `level check message — file`, then the Summary line, still last.
126
+ export function doctorFallbackText(findings, { env = process.env } = {}) {
127
+ const lines = findings.map((f) => `${f.level} ${f.check} ${f.message}${f.file ? ` — ${f.file}` : ""}`);
128
+ return [...lines, ...(lines.length ? [""] : []), doctorSummaryLine(findings, { env })].join("\n") + "\n";
129
+ }
130
+
106
131
  // The layout resolver and its constants, re-exported so hooks and scripts
107
132
  // import one module (the layout ADR, 2026-09-06). The active harness's id,
108
- // for the paths keyed by it (state/<id>/…).
109
- export { layoutPaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId, speakingHarness };
133
+ // for the paths keyed by it (state/<id>/…). cachePaths is the user-level
134
+ // class beside it (layout spec contract 0): the shell fetch's scratch.
135
+ // whichOnPath, the PATH walk for a host binary, moved to harness.mjs beside
136
+ // hostPresence (the setup story, rule 5); its importers keep this name.
137
+ export { layoutPaths, cachePaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId, speakingHarness, whichOnPath };
110
138
 
111
139
  // A hook's payload carries the `cwd` of the session that fired it, and on a
112
140
  // harness that exports no project-dir variable it is the only answer better
@@ -354,16 +382,18 @@ function sweepOrphanTemps(dir) {
354
382
  if (!m) continue;
355
383
  const pid = parseInt(m[1], 10);
356
384
  if (!pid || pid === process.pid) continue;
357
- try {
358
- process.kill(pid, 0); // returns ⇒ alive; EPERM ⇒ alive, not ours
359
- } catch (e) {
360
- if (e.code === "ESRCH") {
361
- try { unlinkSync(join(dir, n)); } catch {}
362
- }
363
- }
385
+ if (!pidAlive(pid)) { try { unlinkSync(join(dir, n)); } catch {} }
364
386
  }
365
387
  }
366
388
 
389
+ // The sweeps' one liveness rule: signal 0 returns ⇒ alive; EPERM ⇒ alive but
390
+ // not ours; only ESRCH is dead. Shared by the temp sweep above and the shell
391
+ // fetch's scratch sweep (fetch-shell.mjs), so the two cannot drift.
392
+ export function pidAlive(pid) {
393
+ try { process.kill(pid, 0); return true; }
394
+ catch (e) { return e?.code !== "ESRCH"; }
395
+ }
396
+
367
397
  // ─── Installed-plugin resolution ───────────────────────────────────────
368
398
 
369
399
  // The harness's config directory. Almost always ~/.claude, but the harness's
@@ -474,18 +504,104 @@ export function installedPluginEntries(home = homedir(), projectDir = null) {
474
504
  return out;
475
505
  }
476
506
 
477
- // A pure PATH walk for a host binary — a read, not a subprocess: the analysers
478
- // run on every doctor call. Returns the absolute path or null.
479
- export function whichOnPath(name, env = process.env) {
480
- const sep = process.platform === "win32" ? ";" : ":";
481
- const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
482
- for (const dir of String(env.PATH || "").split(sep).filter(Boolean)) {
483
- for (const ext of exts) {
484
- const p = join(dir, name + ext);
485
- try { const st = statSync(p); if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111))) return p; } catch {}
486
- }
487
- }
488
- return null;
507
+ // Contract 12's selection (the layout spec, as amended 2026-10-03): the copies
508
+ // of ours the host loads for this project — present, enabled for it, and this
509
+ // checkout's own row or a row with no project (user scope), never another
510
+ // checkout's. This checkout's row first, then the most recently updated. Real
511
+ // paths: the host records the project's cwd as one, and /var against
512
+ // /private/var would otherwise drop the project's own row. One filter for
513
+ // doctor's layout remedy and the convergence story's running copies (the
514
+ // converge story's rule 6), so the two cannot disagree about what a project
515
+ // runs. `own` marks this checkout's row.
516
+ export function projectCopies(home = homedir(), projectDir) {
517
+ const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
518
+ const here = real(projectDir);
519
+ const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
520
+ return installedPluginEntries(home, projectDir)
521
+ .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
522
+ .map((e) => ({ ...e, own: mine(e) }))
523
+ .sort((a, b) => (Number(b.own) - Number(a.own)) || (b.at - a.at));
524
+ }
525
+
526
+ // The same selection, one row per plugin key: the row the host loads for this
527
+ // project — this checkout's own row over a user-scope row of the same key,
528
+ // else the most recent. What "a copy enabled for this project" means for the
529
+ // competing copies the registration silences (surfaces.mjs, contract 13) and
530
+ // for the copies the convergence story's rule 6 compares, so the stop and the
531
+ // deferral of a newer copy always see the same (key, version) pairs.
532
+ export function projectCopiesByKey(home = homedir(), projectDir) {
533
+ const seen = new Set();
534
+ return projectCopies(home, projectDir).filter((e) => !seen.has(e.key) && seen.add(e.key));
535
+ }
536
+
537
+ // Whether a registry copy can run the command named for it. `--no-register`
538
+ // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
539
+ // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
540
+ // amended after the rc.3 tag). Read from the copy's own parse table, never
541
+ // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
542
+ // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
543
+ // file read, because this runs in the SessionStart subset: never an import()
544
+ // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
545
+ // released copies keep their parse there. Moved here from doctor.mjs (the
546
+ // converge story's rule 5), which re-exports it: doctor's layout remedy and
547
+ // the installer's keep read one function.
548
+ export function takesNoRegister(copy) {
549
+ try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
550
+ }
551
+ // …and whether its CLI takes --verbose, the same way: the flag arrived in
552
+ // 0.29.0, after --no-register, and a copy between them (rc.3 to 0.28.x)
553
+ // parses strictly, so a command naming it would exit 2. Read before DONE names
554
+ // a kept copy's own `plan --verbose`.
555
+ export function takesVerbose(copy) {
556
+ try { return /\bverbose\b/.test(readFileSync(join(copy, "scripts", "cli.mjs"), "utf8")); } catch { return false; }
557
+ }
558
+
559
+ // Contract 12's selection with its capability read (the layout spec, contract
560
+ // 12 as amended 2026-10-03; the converge story's rule 5): the first of
561
+ // projectCopies — this checkout's own row, then user-scope rows by recency,
562
+ // never another checkout's — and whether its own bin can run the remedy form
563
+ // (`missing`: "bin" for a copy with no bin/projectstore.mjs, 0.27.x;
564
+ // "no-register" for one whose CLI predates the flag, rc.1 and rc.2; null when
565
+ // it can). `only` limits the rows to one install channel before the first is
566
+ // taken: the keep takes "marketplace", so the npm registration's copy is never
567
+ // the kept one. Doctor's layout remedy and the installer's keep call this one
568
+ // function, so the two never disagree about which copy a project runs.
569
+ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarness(), only = null } = {}) {
570
+ const copy = projectCopies(home, projectDir).find((e) => !only || installChannel(e.path, { home, harness }) === only);
571
+ if (!copy) return null;
572
+ const bin = join(copy.path, "bin", "projectstore.mjs");
573
+ const missing = !existsSync(bin) ? "bin" : !takesNoRegister(copy.path) ? "no-register" : null;
574
+ return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
575
+ }
576
+
577
+ // The advice for a marketplace copy that cannot run what is named for it, in
578
+ // that copy's own channel: the host's update, then a restart. `cannot` says
579
+ // what it cannot do, `then` what follows the update. One text for doctor's
580
+ // layout remedy and the keep's skip row and DONE line.
581
+ export function copyUpdateAdvice(copy, { cannot, then }) {
582
+ const v = copy?.version ? ` (${copy.version})` : "";
583
+ return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
584
+ }
585
+
586
+ // The mark a channel switch leaves in this checkout's machine-local binding
587
+ // (the converge story's rules 2–4): `channel_switch: { by, at }`, two strings.
588
+ // It says this checkout chose the npm install; it lists no copies — the
589
+ // silenced set lives in our manifest and the checkout's local settings.
590
+ // Doctor drops contract 13's "if moving to npm was meant, ignore this" info
591
+ // for a marked checkout, the installer plans no second mark, and uninstall
592
+ // removes the key with the registration. `bind --inherit` never copies it.
593
+ export const CHANNEL_SWITCH_KEY = "channel_switch";
594
+ export function channelMark(cfg) {
595
+ const m = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg[CHANNEL_SWITCH_KEY] : null;
596
+ return m && typeof m === "object" && !Array.isArray(m) && typeof m.by === "string" && typeof m.at === "string" ? m : null;
597
+ }
598
+
599
+ // A switch read from the environment the way contract 9 reads CI: set, and
600
+ // not empty, "0" or "false" (any case). One reading for CI and for
601
+ // PROJECTSTORE_NO_UPDATE_CHECK (the converge story's rule 8).
602
+ export function envFlag(env, name) {
603
+ const v = env ? env[name] : undefined;
604
+ return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
489
605
  }
490
606
 
491
607
  // The files a package ships, as the release gate defines them — package.json
@@ -615,10 +731,13 @@ export function rollbackPortableMarketplace(dir, backup = null) {
615
731
  return dir;
616
732
  }
617
733
 
618
- export function removeTreeUnder(dir, homeBase) {
734
+ // `maxRetries` is rmSync's own: a tree another process may still be writing
735
+ // into (an npm the fetch budget stopped) can fail with ENOTEMPTY or EBUSY
736
+ // once and succeed on the next pass. The path guard holds whatever it is.
737
+ export function removeTreeUnder(dir, homeBase, { maxRetries = 0 } = {}) {
619
738
  const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
620
739
  if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`removeTreeUnder: ${dir} is not under ${homeBase}`);
621
- rmSync(dir, { recursive: true, force: true });
740
+ rmSync(dir, { recursive: true, force: true, maxRetries });
622
741
  }
623
742
 
624
743
  export function installedPluginRoot(home = homedir(), preferFamily = null) {
@@ -714,7 +833,8 @@ export function projectDirRefusal(dir) {
714
833
  // PREVIOUS session start, one restart behind every update. The launcher path
715
834
  // never changes, and it resolves the installed plugin at render time, so
716
835
  // `/plugin update` + `/reload-plugins` show up immediately. Dev checkouts and
717
- // --plugin-dir roots carry no version, so those stay wired directly.
836
+ // --plugin-dir roots carry no version, so install wires those directly; a
837
+ // session run from one keeps whatever launcher entry install wrote.
718
838
  //
719
839
  // Idempotent (writes only when the value changes); never clobbers a foreign
720
840
  // statusLine; bails on an unparseable settings file. Returns a status string,
@@ -751,21 +871,65 @@ export function statusLineScriptPath(cmd) {
751
871
  return m ? m[1].replace(/\\/g, "/") : null;
752
872
  }
753
873
 
874
+ // Is `p` a launcher path of ours in this project: any harness's launcher in
875
+ // its state directory, or the legacy one? A path test — nothing is read. The
876
+ // ownership check below and the SessionStart refresh both ask it.
877
+ function ourLauncherPath(p, projectDir) {
878
+ if (!p) return false;
879
+ const norm = (s) => String(s).replace(/\\/g, "/");
880
+ const q = norm(p);
881
+ const lp = layoutPaths(projectDir);
882
+ return (q.startsWith(norm(lp.state) + "/") && isLauncherPath(q)) || q === norm(lp.legacy.launcher);
883
+ }
884
+
885
+ // The provenance field of a registration directory's manifest, or null — the
886
+ // install spec's contract 2 for a JSON directory: emitted by one function
887
+ // (registrationManifest, the installer) and read by this one. The registration
888
+ // analysis (surfaces.mjs) and statusLineIsOurWiring both call it; the key is
889
+ // the surface's provenance_key.
890
+ export function registrationProvenance(manifest, s) {
891
+ const prov = manifest && s && s.provenance_key ? manifest[s.provenance_key] : null;
892
+ return prov && typeof prov === "object" && typeof prov.pkg === "string" ? prov : null;
893
+ }
894
+
895
+ // Is `p` the npm registration's own payload script —
896
+ // <registration dir>/<plugin_subdir>/scripts/statusline.mjs, whose directory's
897
+ // manifest carries our provenance field? The host loads that registration in
898
+ // place from its marketplace directory, so a session refreshed the entry to
899
+ // that script and a package root then read it as someone else's (the story
900
+ // "The SessionStart hook and install agree on the status line of an npm
901
+ // registration loaded from its marketplace directory"). A string test first;
902
+ // the manifest is read only when the path has the payload's shape. Every name
903
+ // comes from the source manifest's registration surface, and the directory
904
+ // from `p` itself — never a path built from the home.
905
+ function isRegistrationPayloadScript(p, harness = sourceHarness()) {
906
+ const s = Object.values(harness?.surfaces || {}).find((x) => x && x.kind === "registration" && x.format === "host-plugin-registration" && x.plugin_subdir && x.manifest);
907
+ if (!s) return false;
908
+ const tail = `/${s.plugin_subdir}/scripts/statusline.mjs`;
909
+ if (!p.endsWith(tail)) return false;
910
+ try {
911
+ return Boolean(registrationProvenance(JSON.parse(readFileSync(join(p.slice(0, -tail.length), s.manifest), "utf8")), s));
912
+ } catch {
913
+ return false;
914
+ }
915
+ }
916
+
754
917
  // Strict test — "did WE write this?". Required wherever we would overwrite or
755
918
  // delete the entry: the loose shape above also matches a user's own
756
919
  // ~/.claude/scripts/statusline.mjs, and clobbering that would take their HUD.
757
920
  // Ours means one of: this project's launcher, the running plugin's own script
758
- // (dev checkouts included), or any versioned install under the plugin cache.
921
+ // (dev checkouts included), any versioned install under the plugin cache, or
922
+ // the npm registration's payload script — the one rung that reads a file, so
923
+ // it runs last, after every cheaper test has failed.
759
924
  export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root = pluginRoot()) {
760
925
  const p = statusLineScriptPath(cmd);
761
926
  if (!p) return false;
762
927
  const norm = (s) => String(s).replace(/\\/g, "/");
763
928
  // Ours if it is any harness's launcher in this project's state, or the legacy launcher.
764
- const lp = layoutPaths(projectDir);
765
- if (p.startsWith(norm(lp.state) + "/") && isLauncherPath(p)) return true;
766
- if (p === norm(lp.legacy.launcher)) return true;
929
+ if (ourLauncherPath(p, projectDir)) return true;
767
930
  if (p === norm(join(root, "scripts", "statusline.mjs"))) return true;
768
- return isPluginCacheRoot(dirname(dirname(p)), home);
931
+ if (isPluginCacheRoot(dirname(dirname(p)), home)) return true;
932
+ return isRegistrationPayloadScript(p);
769
933
  }
770
934
 
771
935
  // The launcher template runs standalone, before it knows which plugin root to
@@ -867,14 +1031,26 @@ export function syncStatusLine(cfg, projectDir, home = homedir()) {
867
1031
  // nothing into the project, since we are not wiring anything here.
868
1032
  if (cur && !isOurs) return "foreign-present";
869
1033
  if (!cur) return "needs-install";
870
- // Refresh: the launcher when it is on disk, else the plugin's own script,
871
- // so an entry always names a renderer that exists. A missing launcher is
872
- // install's to create (stamped), never this path's.
1034
+ // Install alone decides the entry's shape; this hook never demotes a
1035
+ // launcher. An entry naming a launcher of ours that is on disk is left
1036
+ // byte-identical, whatever root runs the hook: the host loads an npm
1037
+ // registration in place from its marketplace directory, which is not the
1038
+ // cache path the launcher was planned against, and re-pointing the entry
1039
+ // at that root's script undid every install (the install spec, contract 0
1040
+ // as amended 2026-10-10). Recognised by path and existsSync only — the
1041
+ // launcher is not read here.
1042
+ const named = statusLineScriptPath(curCmd);
1043
+ const namesLauncher = ourLauncherPath(named, projectDir);
1044
+ if (namesLauncher && existsSync(named)) return "unchanged";
1045
+ // Refresh: a launcher of ours on disk when this root is a cache install or
1046
+ // the entry named a launcher, else the plugin's own script, so an entry
1047
+ // always names a renderer that exists. A missing launcher is install's to
1048
+ // create (stamped), never this path's.
873
1049
  const { launcher } = desiredStatusLineCommand(projectDir, root, home);
874
1050
  // A launcher on disk at either path — the new one, or the legacy one an
875
1051
  // earlier release wrote (the layout ADR) — is kept; moving it is install's.
876
1052
  const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) => existsSync(f));
877
- const desired = launcher && onDisk
1053
+ const desired = (launcher || namesLauncher) && onDisk
878
1054
  ? `node "${onDisk}"`
879
1055
  : `node "${join(root, "scripts", "statusline.mjs")}"`;
880
1056
  if (curCmd !== desired) {
@@ -1065,14 +1241,33 @@ export function folderByKind(layout, kind) {
1065
1241
 
1066
1242
  // ─── Templates ─────────────────────────────────────────────────────────
1067
1243
 
1068
- export function loadTemplate(lang, name) {
1069
- const p = join(pluginRoot(), "templates", lang, `${name}.md.tmpl`);
1244
+ // `root` is the plugin root to read from — the bin passes its own package's,
1245
+ // as it does for layouts, so a scaffold plans against the copy that runs.
1246
+ export function loadTemplate(lang, name, root = pluginRoot()) {
1247
+ const p = join(root, "templates", lang, `${name}.md.tmpl`);
1070
1248
  if (!existsSync(p)) {
1071
1249
  throw new Error(`Template not found: templates/${lang}/${name}.md.tmpl`);
1072
1250
  }
1073
1251
  return readFileSync(p, "utf8");
1074
1252
  }
1075
1253
 
1254
+ // templates/<lang>/strings.json, parsed — or null when the language has none
1255
+ // or it does not parse. No English fallback, unlike the status line's own
1256
+ // reader: the folder names and descriptions are written into a vault's
1257
+ // READMEs, and an English description in a Russian vault would read as the
1258
+ // vault's own prose forever after (the skeleton's Purpose column takes it
1259
+ // from there). A missing string is the caller's refusal to name.
1260
+ export function loadStrings(lang, root = pluginRoot()) {
1261
+ const p = join(root, "templates", String(lang), "strings.json");
1262
+ if (!existsSync(p)) return null;
1263
+ try {
1264
+ const s = JSON.parse(readFileSync(p, "utf8"));
1265
+ return s && typeof s === "object" && !Array.isArray(s) ? s : null;
1266
+ } catch {
1267
+ return null;
1268
+ }
1269
+ }
1270
+
1076
1271
  // {{x}} substitutes raw; {{x_json}} substitutes JSON.stringify(String(x)) — a
1077
1272
  // valid YAML double-quoted scalar. Frontmatter lines in templates use the
1078
1273
  // _json form so titles containing `"` or `:` cannot corrupt the YAML.
@@ -1100,7 +1295,9 @@ export function renderTemplate(template, vars) {
1100
1295
  // language is the canonical form used when WRITING. Matching always accepts
1101
1296
  // every registered form of every language — a ru-headed file in an en-bound
1102
1297
  // vault must still lint. This is deliberately separate from
1103
- // templates/<lang>/strings.json, which is a render-only map for the statusline.
1298
+ // templates/<lang>/strings.json, a render-only map: the status line's strings
1299
+ // and the folder names and descriptions scaffold writes into READMEs, which
1300
+ // nothing ever matches against.
1104
1301
 
1105
1302
  let _headingsCache = null;
1106
1303
 
@@ -2373,13 +2570,25 @@ export function ensureRuntimeDir(projectDir) {
2373
2570
  // harness inside; its own ignore file so nothing in here can reach git,
2374
2571
  // whichever writer gets there first.
2375
2572
  export function ensureStateDir(projectDir) {
2376
- ensureRuntimeDir(projectDir);
2573
+ ensureStateRoot(projectDir);
2377
2574
  const p = layoutPaths(projectDir);
2378
2575
  mkdirSync(p.sessions, { recursive: true });
2379
- if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
2380
2576
  return p.sessions;
2381
2577
  }
2382
2578
 
2579
+ // state/ and its .gitignore, header included, without sessions/: what the
2580
+ // welcome marker's writer needs in a project that is not bound, where no
2581
+ // session state may appear. The header is how a project uninstall
2582
+ // recognises the marker as ours (install spec contract 13, as amended by the
2583
+ // shell-fetch story).
2584
+ export function ensureStateRoot(projectDir) {
2585
+ ensureRuntimeDir(projectDir);
2586
+ const p = layoutPaths(projectDir);
2587
+ mkdirSync(p.state, { recursive: true });
2588
+ if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
2589
+ return p.state;
2590
+ }
2591
+
2383
2592
  export function readSessionState(projectDir, sessionId) {
2384
2593
  try {
2385
2594
  const p = pickExisting(sessionStatePath(projectDir, sessionId), join(legacyStateDir(projectDir), `${sessionId}.json`));
@@ -2462,16 +2671,21 @@ export const SOURCE_IGNORE = [
2462
2671
 
2463
2672
  // The entry-rule counter ignores strictly more than the base set, and the
2464
2673
  // difference is deliberate rather than an oversight of one or the other.
2465
- // /projectstore:bind writes these three itself, in a session that by
2674
+ // /projectstore:bind writes these itself, in a session that by
2466
2675
  // construction has no story open — counting them makes the plugin nag about its
2467
2676
  // own setup. They must NOT join SOURCE_IGNORE: an edit to AGENTS.md is a real
2468
2677
  // code reference (the PS-AGENTS epic already lists it), and folding these into
2469
2678
  // the shared set would silently drop it from every proposed code_refs.
2470
2679
  // Root-anchored, unlike the patterns above: a monorepo's nested AGENTS.md is
2471
- // ordinary source and must still count.
2680
+ // ordinary source and must still count. The fourth is the binding's own
2681
+ // ignore file, which `bind` and `init` write through ensureRuntimeDir: the
2682
+ // layout move added it and this list missed it, so a fresh `init` on a clean
2683
+ // project read as one uncommitted source file (measured 2026-10-08 on 0.29.2).
2684
+ // Built from LAYOUT.root, which the resolver alone spells (layout contract 0).
2472
2685
  export const ENTRY_IGNORE = [
2473
2686
  ...SOURCE_IGNORE,
2474
2687
  /^AGENTS\.md$/, /^CLAUDE\.md$/, /^\.gitignore$/,
2688
+ new RegExp(`^${LAYOUT.root.replace(".", "\\.")}\\/\\.gitignore$`),
2475
2689
  ];
2476
2690
 
2477
2691
  export function isSourcePath(absPath, projectDir, vaultPath) {
@@ -3138,21 +3352,6 @@ export function sessionNameOfferText(offer, { env = process.env } = {}) {
3138
3352
 
3139
3353
  // ─── Frontmatter parsing (minimal) ─────────────────────────────────────
3140
3354
 
3141
- export function parseFrontmatter(md) {
3142
- const m = md.match(/^---\n([\s\S]*?)\n---/);
3143
- if (!m) return { data: {}, body: md };
3144
- const data = {};
3145
- for (const line of m[1].split("\n")) {
3146
- const kv = line.match(/^(\w+):\s*(.*)$/);
3147
- if (!kv) continue;
3148
- let v = kv[2].trim();
3149
- if (v === "null") v = null;
3150
- else if (v.startsWith('"') && v.endsWith('"')) {
3151
- // JSON.parse round-trips escaped scalars from renderTemplate's _json
3152
- // form (titles containing quotes); fall back to the bare strip.
3153
- try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
3154
- }
3155
- data[kv[1]] = v;
3156
- }
3157
- return { data, body: md.slice(m[0].length) };
3158
- }
3355
+ // Moved to stages.mjs (Node-free) so the band module parses frontmatter by
3356
+ // the same rule; re-exported here for every existing caller.
3357
+ export { parseFrontmatter };
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { whichOnPath } from "./lib.mjs";
9
+ import { whichOnPath, cmpPrecedence } from "./lib.mjs";
10
10
 
11
11
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
12
12
  const samePath = (a, b) => {
@@ -114,13 +114,35 @@ function pluginIds(sections) {
114
114
  return ids;
115
115
  }
116
116
 
117
+ // The manifests a portable plugin root may carry, in the order they are read:
118
+ // an Agent Plugins plugin.json at the root, then the legacy one Codex
119
+ // validates — from 0.28.2 the shell's one manifest, since Codex picks a root
120
+ // one first and loads no hooks from it (openai/codex#37027). The one place
121
+ // either is spelled; the shell fetch's verification names them by kind.
122
+ const PAYLOAD_MANIFESTS = { root: ["plugin.json"], legacy: [".codex-plugin", "plugin.json"] };
123
+ export function payloadManifestPaths(root) {
124
+ return Object.values(PAYLOAD_MANIFESTS).map((parts) => join(root, ...parts));
125
+ }
126
+ export function payloadManifestPath(root, kind) {
127
+ return join(root, ...PAYLOAD_MANIFESTS[kind]);
128
+ }
129
+ // Is `root` a portable plugin root: does it carry either manifest?
130
+ export function isPortablePluginRoot(root) {
131
+ return Boolean(root) && payloadManifestPaths(root).some((p) => existsSync(p));
132
+ }
133
+ // The first manifest that parses, or null — or, with `kind`, that one only.
134
+ export function payloadManifest(root, { kind = null } = {}) {
135
+ for (const p of kind ? [payloadManifestPath(root, kind)] : payloadManifestPaths(root)) { const m = readJson(p); if (m) return m; }
136
+ return null;
137
+ }
138
+
117
139
  function cacheEntries(paths, s) {
118
140
  const base = join(paths.cacheDir, s.marketplace_name, s.plugin_name);
119
141
  let names;
120
142
  try { names = readdirSync(base); } catch { return []; }
121
143
  return names.map((name) => {
122
144
  const path = join(base, name);
123
- const manifest = readJson(join(path, "plugin.json")) || readJson(join(path, ".codex-plugin", "plugin.json"));
145
+ const manifest = payloadManifest(path);
124
146
  let at = 0; try { at = statSync(path).mtimeMs; } catch {}
125
147
  if (!manifest || typeof manifest.version !== "string") return null;
126
148
  let digest = null;
@@ -139,18 +161,33 @@ function cacheEntries(paths, s) {
139
161
  // the harness's directory (S2 of the 2026-10-03 review in "The Codex shell's
140
162
  // plugin root: .codex-plugin, the rendered surfaces, and the first Codex
141
163
  // install").
142
- export function portablePayloadRoot(s, { root, env = process.env } = {}) {
164
+ //
165
+ // `fetched` is the root a core run fetched for this harness (the shell fetch
166
+ // of install and upgrade). The variable wins: a set one is used as is, a root
167
+ // or not, and the fetched root is consulted only when it is unset — a shell
168
+ // that names its own root is never second-guessed by a fetch.
169
+ export function portablePayloadRoot(s, { root, env = process.env, fetched = null } = {}) {
143
170
  const named = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
144
171
  if (s.condition !== "distribution_root") return named || root;
145
- if (!named) return null;
146
- return existsSync(join(named, "plugin.json")) || existsSync(join(named, ".codex-plugin", "plugin.json")) ? named : null;
172
+ const candidate = named || fetched || null;
173
+ return candidate && isPortablePluginRoot(candidate) ? candidate : null;
147
174
  }
148
175
 
149
- export function analysePortableRegistration(projectDir, s, { root, payloadRoot: explicitPayloadRoot = null, home = homedir(), harness, env = process.env, ignoreJournal = false } = {}) {
176
+ // Two modes. With a payload, every rung is decided, the published digest
177
+ // against the recorded one included. With a `version` and no payload — plan's
178
+ // dry run, both uninstalls, a run whose fetch was skipped, and the recheck of
179
+ // an item planned that way — every rung that reads host facts is decided, and
180
+ // the one comparison that needs the payload's bytes is not: its digest, so
181
+ // `contentDiffers` and the same-version, different-content conflict. That is
182
+ // the one rung a local-tarball install at an unchanged version trips, and it
183
+ // is decided after a real fetch. `unavailable` for want of a payload fires
184
+ // only when there is neither. Doctor passes neither and reads as before.
185
+ export function analysePortableRegistration(projectDir, s, { root, payloadRoot: explicitPayloadRoot = null, version = null, fetched = null, home = homedir(), harness, env = process.env, ignoreJournal = false } = {}) {
150
186
  const paths = portableRegistrationPaths(s, { home, projectDir, harness, env });
151
- const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env });
152
- const desiredManifest = payloadRoot ? (readJson(join(payloadRoot, "plugin.json")) || readJson(join(payloadRoot, ".codex-plugin", "plugin.json"))) : null;
153
- const desiredVersion = desiredManifest?.version || null;
187
+ const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env, fetched });
188
+ const desiredManifest = payloadRoot ? payloadManifest(payloadRoot) : null;
189
+ const versionOnly = !payloadRoot && typeof version === "string" && version !== "";
190
+ const desiredVersion = desiredManifest?.version || (versionOnly ? version : null);
154
191
  const id = `${s.plugin_name}@${s.marketplace_name}`;
155
192
  const bin = whichOnPath(s.cli.bin, env);
156
193
  const ownership = readJson(paths.ownership)?.[s.provenance_key] || null;
@@ -168,15 +205,21 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
168
205
  const g = global.get(pluginTable(other)) || null, p = project.get(pluginTable(other)) || null;
169
206
  return { id: other, enabled: typeof p?.enabled === "boolean" ? p.enabled : g?.enabled === true };
170
207
  });
208
+ // A registration newer than what this run offers (the converge story's rule
209
+ // 6): set before the ladder, so a damaged one keeps it beside its real
210
+ // reason, and the cache is then read at the version the host registered —
211
+ // keyed on the desired version, a newer registration's integrity rungs
212
+ // checked the wrong cache directory.
213
+ const newer = typeof ownership?.version === "string" && typeof desiredVersion === "string" && cmpPrecedence(ownership.version, desiredVersion) > 0;
171
214
  const caches = cacheEntries(paths, s);
172
- const installed = caches.find((e) => e.version === desiredVersion) || caches[0] || null;
173
- const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null };
215
+ const installed = caches.find((e) => e.version === (newer ? ownership.version : desiredVersion)) || caches[0] || null;
216
+ const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, versionOnly, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null, newer };
174
217
  const enabledOther = others.find((other) => other.enabled);
175
218
  const nothing = !existsSync(paths.dir) && !market && !globalPlugin && !installed && !enabledOther;
176
- if (!desiredManifest) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
219
+ if (!desiredManifest && !versionOnly) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
177
220
  if (!bin && nothing) return { ...out, state: "unavailable", reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI` };
178
221
  let desiredDigest = null;
179
- try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; }
222
+ if (!versionOnly) { try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; } }
180
223
  out.desiredDigest = desiredDigest;
181
224
  if (enabledOther) return { ...out, state: "conflict", refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry` };
182
225
  // A journal the rollback could not prove names the run that left it, when,
@@ -191,13 +234,17 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
191
234
  let sourceDigest = null;
192
235
  try { if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload); } catch {}
193
236
  out.sourceDigest = sourceDigest;
194
- out.contentDiffers = Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
237
+ out.contentDiffers = versionOnly ? null : Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
195
238
  if (!ownership || !catalog) return { ...out, state: "stale", reason: "the owned marketplace source is incomplete" };
196
- if (ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
239
+ if (!versionOnly && ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
197
240
  if (!sourceDigest || sourceDigest.sha256 !== ownership.digest?.sha256 || sourceDigest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
198
241
  if (!market) return { ...out, state: "stale", reason: "the host does not know the marketplace" };
199
242
  if (!installed || installed.version !== ownership.version) return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
200
243
  if (!installed.digest || installed.digest.sha256 !== ownership.digest?.sha256 || installed.digest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the materialised host cache does not match the registered payload digest" };
244
+ // The newer rung, after the integrity rungs: a sound registration newer
245
+ // than this run is current and never staged over (rule 6). A damaged one
246
+ // stayed stale above, with its real reason; the planner skips it.
247
+ if (newer) return { ...out, state: "current", reason: `the registration is at ${ownership.version}, newer than this package (${desiredVersion}) — not downgraded` };
201
248
  if (ownership.version !== desiredVersion) return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
202
249
  return { ...out, state: "current", reason: enabled ? null : "installed globally but disabled by effective configuration" };
203
250
  }