projectstore-codex 0.0.1 → 0.28.2

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 (184) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +176 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +35 -14
  155. package/skills/projectstore-adr/SKILL.md +76 -0
  156. package/skills/projectstore-agents/SKILL.md +50 -0
  157. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  158. package/skills/projectstore-bind/SKILL.md +44 -0
  159. package/skills/projectstore-clerk/SKILL.md +126 -0
  160. package/skills/projectstore-codemap/SKILL.md +69 -0
  161. package/skills/projectstore-concept/SKILL.md +36 -0
  162. package/skills/projectstore-critic/SKILL.md +127 -0
  163. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  164. package/skills/projectstore-doctor/SKILL.md +33 -0
  165. package/skills/projectstore-epic/SKILL.md +59 -0
  166. package/skills/projectstore-graph/SKILL.md +75 -0
  167. package/skills/projectstore-kanban/SKILL.md +60 -0
  168. package/skills/projectstore-librarian/SKILL.md +114 -0
  169. package/skills/projectstore-meeting/SKILL.md +36 -0
  170. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  171. package/skills/projectstore-planner/SKILL.md +113 -0
  172. package/skills/projectstore-reconcile/SKILL.md +92 -0
  173. package/skills/projectstore-research/SKILL.md +36 -0
  174. package/skills/projectstore-review/SKILL.md +108 -0
  175. package/skills/projectstore-reviewer/SKILL.md +131 -0
  176. package/skills/projectstore-runbook/SKILL.md +36 -0
  177. package/skills/projectstore-scaffold/SKILL.md +42 -0
  178. package/skills/projectstore-search/SKILL.md +41 -0
  179. package/skills/projectstore-spec/SKILL.md +110 -0
  180. package/skills/projectstore-status/SKILL.md +47 -0
  181. package/skills/projectstore-statusline/SKILL.md +29 -0
  182. package/skills/projectstore-story/SKILL.md +132 -0
  183. package/skills/projectstore-story-completion/SKILL.md +69 -0
  184. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,421 @@
1
+ // projectstore — surfaces.mjs
2
+ //
3
+ // The STATE of every installed surface, read but never written: what is on
4
+ // disk at each path the manifest names, whether it is ours, and if so whether
5
+ // it is current. install-harness.mjs turns these states into actions behind
6
+ // its gate; doctor turns them into findings. Neither derives a state itself,
7
+ // so the two can never disagree about a file — and neither knows a harness
8
+ // id: everything here is keyed by the manifest's surfaces.<key>.kind and
9
+ // .format.
10
+ //
11
+ // Direction: installer → surfaces ← doctor, and surfaces → provenance,
12
+ // lib.mjs. Doctor imports this module DYNAMICALLY, inside the one check that
13
+ // needs it, because hooks/session-start.mjs imports doctor statically and the
14
+ // install spec keeps provenance.mjs out of the SessionStart module graph.
15
+ //
16
+ // Read-only by construction: no write call appears here (the suite greps for
17
+ // them). stamp() is a pure render; its text is what install would write and
18
+ // its hash is what the state ladder compares.
19
+ //
20
+ // Normative: the install spec — contract 0 (exclusive vs shared), 3–4 (the
21
+ // four states and the ladder, rungs 1′ and 1″ included), 6 (per-entry states
22
+ // for shared files: ours-current, ours-stale, ours-absent, unparseable),
23
+ // 12 (current, last written by), 14 (host-managed surfaces have no state to
24
+ // derive), 16 (a harness is in use when detected or when it has files of
25
+ // ours). Pure node, no external deps.
26
+
27
+ import { readFileSync, existsSync, realpathSync, readdirSync } from "node:fs";
28
+ import { join, resolve } from "node:path";
29
+ import { homedir } from "node:os";
30
+ import { loadHarnesses, loadHarness, harnessIds, detectHarnesses, sourceHarness, MANIFEST_DIR } from "./harness.mjs";
31
+ import { stamp, deriveState, parseProvenance, sourceHash, STALE, STALE_TEXT, FOREIGN_TEXT } from "./provenance.mjs";
32
+ import {
33
+ pluginRoot,
34
+ renderStatusLineLauncher,
35
+ statusLineIsOurWiring,
36
+ isPluginCacheRoot,
37
+ desiredStatusLineCommand,
38
+ findAgentsBlock,
39
+ agentsBlockVersion,
40
+ agentsBlockTemplatePath,
41
+ renderAgentsBlock,
42
+ loadLayout,
43
+ readConfigAt,
44
+ claudeHome,
45
+ whichOnPath,
46
+ installedPluginEntries,
47
+ pluginEnabled,
48
+ treeDigest,
49
+ packageDigest,
50
+ cmpPrecedence,
51
+ layoutPaths,
52
+ RUNTIME_GITIGNORE_HEADER,
53
+ LAUNCHER_HEADER,
54
+ } from "./lib.mjs";
55
+ import { analysePortableRegistration, portableRegistrationPaths, portablePayloadRoot } from "./portable-registration.mjs";
56
+
57
+ export const GENERATOR = "scripts/install-harness.mjs";
58
+ export const INSTALLED_REMEDY = "projectstore doctor reports this file when it is stale; run install again to refresh it.";
59
+ // The line every launcher has carried since the template was written; the
60
+ // recogniser for a pre-provenance launcher (contract 4, rung 1″). A substring
61
+ // of the template's own header, so a reworded comment cannot turn every
62
+ // existing install foreign.
63
+ export { LAUNCHER_HEADER };
64
+
65
+ export function readText(p) {
66
+ if (!existsSync(p)) return { present: false, text: null };
67
+ try {
68
+ return { present: true, text: readFileSync(p, "utf8") };
69
+ } catch {
70
+ return { present: true, text: null };
71
+ }
72
+ }
73
+
74
+ export function pluginVersionAt(root, harness) {
75
+ const rel = harness?.version_file || sourceHarness()?.version_file || ".claude-plugin/plugin.json";
76
+ try {
77
+ return String(JSON.parse(readFileSync(join(root, rel), "utf8")).version || "");
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
83
+ // Is this text a file we wrote — stamped, or a pre-provenance launcher?
84
+ export function isOurFile(text) {
85
+ if (typeof text !== "string") return false;
86
+ return Boolean(parseProvenance(text)) || text.includes(LAUNCHER_HEADER);
87
+ }
88
+
89
+ // ─── markdown-block: the ADR-002 block ─────────────────────────────────
90
+
91
+ export function analyseBlock(projectDir, s, { root = pluginRoot(), manifestDir = MANIFEST_DIR } = {}) {
92
+ const files = s.files || ["AGENTS.md", "CLAUDE.md"];
93
+ const PREFERRED = files[0], FALLBACK = files[files.length - 1];
94
+ // LOOK across every file any manifest names; WRITE to one this harness can
95
+ // read. The two are different questions and conflating them is what let the
96
+ // block be created twice: a harness that scanned only its own list could not
97
+ // see the copy another harness had already written, so it created a second.
98
+ // Scanning is free and static — the union is manifest data, not project
99
+ // state — while the write target stays this manifest's own preference.
100
+ const scan = [...files, ...[...loadHarnesses(manifestDir).values()]
101
+ .flatMap((m) => m.surfaces?.agents_block?.files || [])
102
+ .filter((f) => !files.includes(f))];
103
+ const found = scan.map((f) => ({ file: f, path: join(projectDir, f), ...readText(join(projectDir, f)) }))
104
+ .map((e) => ({ ...e, block: e.text !== null ? findAgentsBlock(e.text) : null, own: files.includes(e.file) }));
105
+ const withBlock = found.filter((e) => e.block);
106
+ const preferred = found.find((e) => e.file === PREFERRED && e.present) || found.find((e) => e.file === FALLBACK);
107
+ const claude = found.find((e) => e.file === FALLBACK);
108
+ const importLine = `@${PREFERRED}`;
109
+ const a = { files: found, own: files, withBlock, preferred, claude, importLine, PREFERRED, FALLBACK, entryKey: "projectstore:agents", version: null, desired: null, current: null, state: "ours-absent", reason: null, refusal: null };
110
+
111
+ const unclosed = withBlock.find((e) => e.block.unclosed);
112
+ if (unclosed && unclosed.block.wrapped) return { ...a, state: "unparseable", refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run /projectstore:agents register` };
113
+ if (unclosed) return { ...a, state: "unparseable", refusal: `${unclosed.file}: the block opens and never closes — restore the closing marker or remove the block by hand` };
114
+ const twice = withBlock.find((e) => e.block.count > 1);
115
+ if (twice) return { ...a, state: "unparseable", refusal: `${twice.file} carries the block more than once — keep exactly one` };
116
+
117
+ const tmpl = readText(agentsBlockTemplatePath(root));
118
+ if (!tmpl.present || tmpl.text === null) return { ...a, state: "unparseable", refusal: `the block's source is missing from the plugin at ${agentsBlockTemplatePath(root)}` };
119
+ a.version = agentsBlockVersion(tmpl.text);
120
+ const cfg = readConfigAt(projectDir);
121
+ let roster = null;
122
+ if (cfg && typeof cfg.layout === "string") {
123
+ try { roster = loadLayout(cfg.layout, root).agents || null; } catch { roster = null; }
124
+ }
125
+ a.desired = renderAgentsBlock(tmpl.text, roster);
126
+ a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
127
+ a.duplicates = withBlock.filter((e) => e !== a.current);
128
+ if (!a.current) return { ...a, state: "ours-absent" };
129
+ // A block this harness CANNOT READ moves, and its target is created if it has
130
+ // to be: that is the whole difference between a second harness arriving and a
131
+ // preference. A block it can read moves only when the preferred file already
132
+ // exists — so a Claude-Code-only project keeps its block in CLAUDE.md and
133
+ // never acquires an AGENTS.md it did not ask for, while installing Codex into
134
+ // that same project moves the substance to the file Codex can read and leaves
135
+ // CLAUDE.md importing it.
136
+ if (!a.current.own) return { ...a, state: "ours-stale", reason: `in ${a.current.file}, which ${s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}` };
137
+ if (a.current.file !== preferred.file && preferred.present) return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
138
+ if (a.duplicates.length) return { ...a, state: "ours-stale", reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}` };
139
+ if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
140
+ return { ...a, state: "ours-stale", reason: a.current.block.v !== a.version ? `v${a.current.block.v} → v${a.version}` : "content differs from the current source" };
141
+ }
142
+
143
+ // ─── json-entry: one entry in a JSON file the user co-owns ─────────────
144
+
145
+ // `renderRoot` (contract 4′, two phases): the root the surface is planned
146
+ // AGAINST — the host's install path a registration produces — when it differs
147
+ // from the root the templates are read from. Defaults to `root`.
148
+ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = homedir(), renderRoot = root } = {}) {
149
+ const path = join(projectDir, s.file);
150
+ const pointer = s.marker?.pointer || "statusLine.command";
151
+ const entryKey = pointer.split(".")[0];
152
+ const cur = readText(path);
153
+ const a = { path, entryKey, cur, settings: {}, curEntry: null, curCmd: null, ours: false, desired: null, state: "ours-absent", reason: null };
154
+ if (cur.present) {
155
+ if (cur.text === null) return { ...a, state: "unparseable", reason: "the file cannot be read" };
156
+ try { a.settings = JSON.parse(cur.text); } catch { return { ...a, state: "unparseable", reason: "the file is not valid JSON — nothing outside a marked entry is touched, so nothing is written" }; }
157
+ if (!a.settings || typeof a.settings !== "object" || Array.isArray(a.settings)) return { ...a, state: "unparseable", reason: "the file is not a JSON object" };
158
+ }
159
+ a.curEntry = a.settings[entryKey] ?? null;
160
+ a.curCmd = a.curEntry && typeof a.curEntry.command === "string" ? a.curEntry.command : null;
161
+ // "Did we write this?" is asked of the EXISTING entry, written by whatever
162
+ // root was current then: a dev checkout's direct wiring stays ours when the
163
+ // plan now renders against a registration's install path (reviewer, 2026-09-05).
164
+ a.ours = statusLineIsOurWiring(a.curCmd, projectDir, home, root) || statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
165
+ a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
166
+ if (!a.curEntry) return { ...a, state: "ours-absent" };
167
+ if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
168
+ if (a.curCmd === a.desired) return { ...a, state: "ours-current" };
169
+ return { ...a, state: "ours-stale", reason: "the command no longer matches this installation" };
170
+ }
171
+
172
+ // ─── mjs: an exclusive, provenance-stamped file ────────────────────────
173
+
174
+ export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home = homedir(), harness = null, renderRoot = root } = {}) {
175
+ const path = join(projectDir, s.file);
176
+ let file = readText(path);
177
+ // The manifest may name where an earlier release wrote this file
178
+ // (legacy_file, the layout ADR): read it when the current path is absent, so
179
+ // the file is classified and, once the new one is written, cleaned up.
180
+ const legacyPath = s.legacy_file ? join(projectDir, s.legacy_file) : null;
181
+ const atLegacy = !file.present && legacyPath && existsSync(legacyPath);
182
+ if (atLegacy) file = readText(legacyPath);
183
+ const a = { path, file, produced: true, ours: null, refusal: null, stamped: null, state: "absent", reason: null, writtenBy: null, sameProject: false, legacy: false, renderRoot, legacyPath: atLegacy ? legacyPath : null };
184
+ if (s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home)) {
185
+ // Not produced for this installation (a dev checkout is wired directly).
186
+ a.produced = false;
187
+ if (!file.present) return { ...a, state: "absent" };
188
+ a.ours = isOurFile(file.text);
189
+ return { ...a, state: a.ours ? "stale" : "foreign", reason: a.ours ? "not produced for this installation (a dev checkout is wired directly) — left in place; a prune of a launcher this root did not write needs the root that wrote it, or uninstall" : null };
190
+ }
191
+ const src = join(root, s.source);
192
+ const tpl = readText(src);
193
+ if (!tpl.present || tpl.text === null) return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
194
+ const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
195
+ if (rendered === null) return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
196
+ const pkg = pluginVersionAt(root, harness) || "0.0.0";
197
+ a.pkg = pkg;
198
+ a.stamped = stamp(rendered, { format: s.format, src: s.source, srcHash: sourceHash(tpl.text), pkg, project: projectDir, harness: harness?.id || sourceHarness()?.id || "harness", generator: GENERATOR, remedy: INSTALLED_REMEDY });
199
+ let st = deriveState({ file, sourceHash: sourceHash(tpl.text), pkg, renderNowHash: a.stamped.render, project: projectDir });
200
+ // Contract 4, rung 1″: a launcher written before provenance existed carries
201
+ // no line but the template's own header. It is ours, stale, replaceable.
202
+ if (st.state === "foreign" && typeof file.text === "string" && file.text.includes(LAUNCHER_HEADER)) { st = { ...st, state: "stale", reason: STALE.PLUGIN }; a.legacy = true; }
203
+ a.installedPkg = st.provenance?.pkg || null;
204
+ return { ...a, state: st.state, reason: st.reason ? STALE_TEXT[st.reason] + (a.legacy ? " (pre-provenance file)" : "") : null, writtenBy: st.writtenBy, sameProject: st.sameProject };
205
+ }
206
+
207
+ // ─── host-plugin-registration: our marketplace directory + the host's registry ──
208
+ //
209
+ // Contract 4′ (2026-09-05): a total, ordered function over registry facts —
210
+ // our directory under the harness home (owned as a whole, recognised by the
211
+ // provenance field in its manifest), the host's marketplace and plugin
212
+ // registries, the project's settings, and PATH. Reads only; the host binary is
213
+ // located, never run.
214
+
215
+ function samePath(a, b) {
216
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
217
+ return Boolean(a && b) && real(a) === real(b);
218
+ }
219
+
220
+ function readJson(p) {
221
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
222
+ }
223
+
224
+ export function registrationPaths(s, { home = homedir(), projectDir = null, harness = null } = {}) {
225
+ const hh = claudeHome(home);
226
+ const dir = join(hh, ...s.dir);
227
+ const reg = s.registry || {};
228
+ const cfgDir = harness?.runtime?.harness_dir || ".claude";
229
+ return {
230
+ dir,
231
+ manifest: join(dir, s.manifest),
232
+ payload: join(dir, s.plugin_subdir),
233
+ installed: join(hh, ...(reg.installed || ["plugins", "installed_plugins.json"])),
234
+ marketplaces: join(hh, ...(reg.marketplaces || ["plugins", "known_marketplaces.json"])),
235
+ cacheDir: join(hh, ...(reg.cache_dir || ["plugins", "cache"])),
236
+ projectSettings: projectDir ? join(projectDir, cfgDir, ...(reg.project_settings || ["settings.json"])) : null,
237
+ userSettings: join(hh, ...(reg.user_settings || ["settings.json"])),
238
+ };
239
+ }
240
+
241
+ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home = homedir(), harness = null, env = process.env } = {}) {
242
+ const id = `${s.plugin_name}@${s.marketplace_name}`;
243
+ const paths = registrationPaths(s, { home, projectDir, harness });
244
+ const pkg = pluginVersionAt(root, harness) || "0.0.0";
245
+ const a = { id, paths, path: paths.dir, entryKey: id, pkg, bin: whichOnPath(s.cli?.bin || "claude", env), produced: !(s.condition === "npm_package_root" && isPluginCacheRoot(root, home)),
246
+ dir: { present: existsSync(paths.dir), manifest: null, prov: null, pkg: null, disabled: [], digestOk: null }, known: null, registeredHere: null, installed: null, installedVersion: null, installPath: null,
247
+ enabled: null, others: [], otherProjects: 0, writtenBy: null, newer: false, state: "absent", reason: null, refusal: null };
248
+
249
+ // Machine-global facts: our directory, the host's marketplace registry, every row of ours.
250
+ if (a.dir.present) {
251
+ a.dir.manifest = readJson(paths.manifest);
252
+ const prov = a.dir.manifest && a.dir.manifest[s.provenance_key];
253
+ if (prov && typeof prov === "object" && typeof prov.pkg === "string") {
254
+ a.dir.prov = prov; a.dir.pkg = prov.pkg;
255
+ a.dir.disabled = Array.isArray(prov.disabled) ? prov.disabled.filter((x) => typeof x === "string") : [];
256
+ if (typeof prov.project === "string" && !samePath(prov.project, projectDir)) a.writtenBy = prov.project;
257
+ // The payload digest: a copy that did not finish, or a hand edit, is not current.
258
+ if (prov.digest && typeof prov.digest.sha256 === "string") {
259
+ try { const d = treeDigest(paths.payload); a.dir.digestOk = d.count === prov.digest.count && d.sha256 === prov.digest.sha256; } catch { a.dir.digestOk = false; }
260
+ } else a.dir.digestOk = null; // no digest recorded: nothing to compare
261
+ }
262
+ }
263
+ const known = readJson(paths.marketplaces);
264
+ const mp = known && known[s.marketplace_name];
265
+ a.known = mp ? { path: (mp.installLocation || mp.source?.path || null), source: mp.source?.source || null } : null;
266
+ const all = installedPluginEntries(home, projectDir).filter((e) => e.key === id);
267
+ // Per-project facts: the checkout's own settings file and the registry row carrying its path.
268
+ const local = readJson(paths.projectSettings);
269
+ const here = local && local[s.registry?.known_pointer || "extraKnownMarketplaces"] && local[s.registry?.known_pointer || "extraKnownMarketplaces"][s.marketplace_name];
270
+ a.registeredHere = here ? { path: here.source?.path || null } : null;
271
+ const enabledHere = (local && local[s.registry?.enabled_pointer || "enabledPlugins"]) || {};
272
+ a.disabledHere = Object.keys(enabledHere).filter((k) => enabledHere[k] === false);
273
+ const mine = all.filter((e) => e.projectPath && samePath(e.projectPath, projectDir));
274
+ a.otherProjects = all.filter((e) => !e.projectPath || !samePath(e.projectPath, projectDir)).length;
275
+ const row = mine.find((e) => e.present) || mine[0] || null;
276
+ a.installed = row ? { path: row.path, version: row.version, present: row.present, scope: row.scope } : null;
277
+ a.installPath = row ? row.path : null;
278
+ a.installedVersion = row ? row.version : null;
279
+ a.enabled = pluginEnabled(id, home, projectDir);
280
+ // Competing registrations of the same plugin, enabled for this project.
281
+ const seen = new Set();
282
+ for (const e of installedPluginEntries(home, projectDir)) {
283
+ if (e.key === id || seen.has(e.key) || !e.present || e.enabled === false) continue;
284
+ if (e.projectPath && !samePath(e.projectPath, projectDir)) continue;
285
+ seen.add(e.key); a.others.push({ key: e.key, path: e.path, version: e.version });
286
+ }
287
+ a.newer = Boolean(a.dir.pkg) && cmpPrecedence(a.dir.pkg, pkg) > 0;
288
+ // Same version, different payload: the directory's recorded digest against
289
+ // this package's — contract 4's rung 3 (source changed) for a directory.
290
+ a.contentDiffers = null;
291
+ if (a.dir.prov && a.dir.pkg === pkg && a.dir.prov.digest && typeof a.dir.prov.digest.sha256 === "string") {
292
+ try { const d = packageDigest(root); a.contentDiffers = d.sha256 !== a.dir.prov.digest.sha256 || d.count !== a.dir.prov.digest.count; } catch { a.contentDiffers = null; }
293
+ }
294
+ // The version the host would install from the directory as it will stand after this run.
295
+ a.targetPkg = a.newer ? a.dir.pkg : pkg;
296
+ a.predictedInstallPath = join(paths.cacheDir, s.marketplace_name, s.plugin_name, a.targetPkg);
297
+ const nothingOfOursHere = !a.registeredHere && !mine.length;
298
+ const nothingOfOurs = !a.dir.present && !a.known && !all.length && nothingOfOursHere;
299
+ const bin = s.cli?.bin || "claude";
300
+
301
+ // The ladder (contract 4′).
302
+ if (!a.bin && nothingOfOurs) return { ...a, state: "unavailable", reason: `\`${bin}\` is not on PATH — the registration needs the host's CLI; the other surfaces still install` };
303
+ if (a.dir.present && !a.dir.prov) return { ...a, state: "foreign", refusal: `${paths.dir} exists and its ${s.manifest} carries no \`${s.provenance_key}\` field of ours — a directory we did not write sits at our path; move it if it is yours, or delete it to let install take the name` };
304
+ if (a.known && a.known.path && !samePath(a.known.path, paths.dir)) return { ...a, state: "foreign", refusal: `the host's registry names marketplace \`${s.marketplace_name}\` at ${a.known.path}, not at ${paths.dir} — a registration we did not make holds our name; \`${bin} plugin marketplace remove ${s.marketplace_name}\` lets install take it` };
305
+ if (a.registeredHere && a.registeredHere.path && !samePath(a.registeredHere.path, paths.dir)) return { ...a, state: "foreign", refusal: `${paths.projectSettings} declares marketplace \`${s.marketplace_name}\` at ${a.registeredHere.path}, not at ${paths.dir} — remove that entry to let install take the name` };
306
+ if (nothingOfOurs) return { ...a, state: "absent" };
307
+ // The directory is shared by every checkout on the machine; a project that never registered is absent, whatever the directory holds.
308
+ if (nothingOfOursHere) return { ...a, state: "absent", reason: a.dir.present ? `the marketplace directory is present${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}; this checkout is not registered` : null };
309
+ if (!a.dir.present) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + " (the directory is gone)" };
310
+ if (cmpPrecedence(a.dir.pkg, pkg) < 0) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})` };
311
+ if (a.dir.digestOk === false) return { ...a, state: "stale", reason: "edited by hand or half-written (the payload does not match the digest its manifest carries)" };
312
+ if (a.contentDiffers === true) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (same version ${pkg}, different content — the directory's digest is not this package's)` };
313
+ if (!a.known || !a.registeredHere) return { ...a, state: "stale", reason: STALE_TEXT[STALE.CONFIG] + (a.known ? " (this checkout does not declare the marketplace)" : " (the host does not know the marketplace)") };
314
+ if (!a.installed || !a.installed.present) return { ...a, state: "stale", reason: "not installed" + (a.installed ? " (the host's install path is gone)" : " (registered, not installed for this checkout)") };
315
+ if (a.installedVersion !== a.dir.pkg) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (installed ${a.installedVersion}, directory ${a.dir.pkg})` };
316
+ if (!a.enabled) return { ...a, state: "stale", reason: "disabled for this checkout" };
317
+ return { ...a, state: "current", reason: a.newer ? `the directory is at ${a.dir.pkg}, newer than this package (${pkg})${a.writtenBy ? `, written from ${a.writtenBy}` : ""} — not downgraded` : null };
318
+ }
319
+
320
+ // ─── the project-level layout: legacy / new / both (the layout ADR) ─────
321
+ //
322
+ // Which of the two layouts a project holds, file by file — what the `layout`
323
+ // migration item and doctor's layout-legacy read (layout spec, contracts 6–7).
324
+ // The legacy runtime directory is ours when its nested .gitignore carries the
325
+ // header every release since 0.6 wrote; a directory without it is left alone.
326
+ export function analyseLayout(projectDir, { harness = null } = {}) {
327
+ const p = layoutPaths(projectDir, { harnessDir: harness?.runtime?.harness_dir || null });
328
+ const has = (f) => existsSync(f);
329
+ const legacy = {
330
+ dir: p.legacy.dir,
331
+ binding: has(p.legacy.binding), runtime: has(p.legacy.runtime), state: has(p.legacy.state), launcher: has(p.legacy.launcher),
332
+ entryLog: has(p.legacy.entryLog), welcomed: has(p.legacy.welcomed), sessionId: has(p.legacy.sessionId),
333
+ runtimeOurs: false, stateFiles: [],
334
+ };
335
+ if (legacy.runtime) {
336
+ try { legacy.runtimeOurs = readFileSync(p.legacy.gitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER); } catch { legacy.runtimeOurs = false; }
337
+ if (legacy.state) { try { legacy.stateFiles = readdirSync(p.legacy.state).filter((n) => n !== ".gitignore"); } catch {} }
338
+ }
339
+ const current = { root: has(p.root), binding: has(p.binding) };
340
+ const any = legacy.binding || legacy.runtime || legacy.welcomed || legacy.sessionId;
341
+ // Two bindings whose only difference is the legacy agents block are an
342
+ // interrupted move (the new file written, the old not yet removed): resumable.
343
+ let resumable = false;
344
+ if (legacy.binding && current.binding) {
345
+ try {
346
+ const { agents, ...oldRest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
347
+ const cur = JSON.parse(readFileSync(p.binding, "utf8"));
348
+ resumable = JSON.stringify(oldRest) === JSON.stringify(cur);
349
+ } catch { resumable = false; }
350
+ }
351
+ return {
352
+ paths: p, legacy, current, resumable,
353
+ twoConfigs: legacy.binding && current.binding,
354
+ pending: any,
355
+ state: !any ? "current" : (legacy.binding && current.binding) ? "both" : legacy.binding ? "legacy" : "partial",
356
+ };
357
+ }
358
+
359
+ // ─── every surface, for doctor ─────────────────────────────────────────
360
+
361
+ export { analysePortableRegistration, portableRegistrationPaths };
362
+ const ANALYSERS = { "markdown-block": analyseBlock, "json-entry": analyseJsonEntry, "mjs": analyseStampedFile, "host-plugin-registration": analyseRegistration, "portable-plugin-registration": analysePortableRegistration };
363
+
364
+ // The states of every non-host, supported surface of every harness this
365
+ // project uses — detected by directory, or carrying a file of ours (contract
366
+ // 16). Host-managed rows have nothing to derive (contract 14).
367
+ export function surfaceStates(projectDir, { home = homedir(), root = pluginRoot(), manifestDir = MANIFEST_DIR, harnesses = null, env = process.env } = {}) {
368
+ projectDir = resolve(projectDir);
369
+ const detected = detectHarnesses(projectDir, { dir: manifestDir }).map((d) => d.id);
370
+ const out = { projectDir, detected, used: [], installable: harnessIds(manifestDir), states: [] };
371
+ for (const m of loadHarnesses(manifestDir).values()) {
372
+ if (harnesses && !harnesses.includes(m.id)) continue;
373
+ // A registration conditioned on a distribution root has no state to read
374
+ // from a run that has none — the installer defers it before reading any,
375
+ // and so does this (S2, the 2026-10-03 review): reporting it from the
376
+ // core printed "<core> is not a portable plugin root" into every project
377
+ // that merely contained the harness's directory.
378
+ const rows = Object.entries(m.surfaces || {}).filter(([k, s]) => !k.startsWith("_") && s.kind !== "host" && s.supported !== false && ANALYSERS[s.format]
379
+ && !(s.condition === "distribution_root" && !portablePayloadRoot(s, { root, env })));
380
+ const states = [];
381
+ for (const [key, s] of rows) {
382
+ const a = ANALYSERS[s.format](projectDir, s, { root, home, harness: m, env });
383
+ const entry = s.kind === "shared" ? (a.entryKey || s.marker?.pointer || null) : null;
384
+ const path = a.legacyPath || a.path || (a.current ? a.current.path : (a.preferred ? a.preferred.path : join(projectDir, s.file || "")));
385
+ const row = { harness: m.id, surface: key, kind: s.kind, path, entry, state: a.state, reason: a.reason || a.refusal || null, writtenBy: a.writtenBy || null, sameProject: Boolean(a.sameProject), produced: a.produced !== false, legacy: Boolean(a.legacy), installedPkg: a.installedPkg || null, present: a.file ? a.file.present : (a.current ? true : (a.curEntry ? true : false)) };
386
+ if (s.kind === "registration") Object.assign(row, {
387
+ entry: a.id,
388
+ present: Boolean(a.dir?.present || a.ownership || a.known || a.market || a.installed),
389
+ produced: a.produced,
390
+ pkg: a.pkg || a.desiredVersion || null,
391
+ dirPkg: a.dir?.pkg || a.ownership?.version || null,
392
+ installedVersion: a.installedVersion,
393
+ installPath: a.installPath,
394
+ enabled: a.enabled,
395
+ others: a.others || [],
396
+ otherProjects: a.otherProjects || 0,
397
+ writtenBy: a.writtenBy || null,
398
+ newer: Boolean(a.newer),
399
+ bin: a.bin,
400
+ // What this registration silenced for the checkout and the checkout
401
+ // still holds off: exactly the set uninstall re-enables (contract 13).
402
+ silenced: (a.dir?.disabled || []).filter((k) => (a.disabledHere || []).includes(k)),
403
+ });
404
+ states.push(row);
405
+ }
406
+ // A registration is the host's to load and never counts as a file of ours
407
+ // (contract 16): it decides nothing about whether the harness is in use.
408
+ const hasOurs = states.some((x) => (x.kind === "exclusive" && ["current", "stale"].includes(x.state)) || (x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)));
409
+ if (detected.includes(m.id) || hasOurs) {
410
+ out.used.push(m.id);
411
+ // The agents block is deliberately shared across harnesses. Its presence
412
+ // must not advertise an unavailable global registration for every other
413
+ // harness on the machine; a registration joins doctor only when that
414
+ // harness is detected in the project or the registration actually exists.
415
+ out.states.push(...states.filter((x) => x.kind !== "registration" || detected.includes(m.id) || x.present));
416
+ }
417
+ }
418
+ return out;
419
+ }
420
+
421
+ export { FOREIGN_TEXT };