projectstore-codex 0.0.1 → 0.28.1

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 (185) 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 +163 -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 +255 -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 +36 -14
  155. package/plugin.json +53 -0
  156. package/skills/projectstore-adr/SKILL.md +76 -0
  157. package/skills/projectstore-agents/SKILL.md +50 -0
  158. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  159. package/skills/projectstore-bind/SKILL.md +44 -0
  160. package/skills/projectstore-clerk/SKILL.md +126 -0
  161. package/skills/projectstore-codemap/SKILL.md +69 -0
  162. package/skills/projectstore-concept/SKILL.md +36 -0
  163. package/skills/projectstore-critic/SKILL.md +127 -0
  164. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  165. package/skills/projectstore-doctor/SKILL.md +33 -0
  166. package/skills/projectstore-epic/SKILL.md +59 -0
  167. package/skills/projectstore-graph/SKILL.md +75 -0
  168. package/skills/projectstore-kanban/SKILL.md +60 -0
  169. package/skills/projectstore-librarian/SKILL.md +114 -0
  170. package/skills/projectstore-meeting/SKILL.md +36 -0
  171. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  172. package/skills/projectstore-planner/SKILL.md +113 -0
  173. package/skills/projectstore-reconcile/SKILL.md +92 -0
  174. package/skills/projectstore-research/SKILL.md +36 -0
  175. package/skills/projectstore-review/SKILL.md +108 -0
  176. package/skills/projectstore-reviewer/SKILL.md +131 -0
  177. package/skills/projectstore-runbook/SKILL.md +36 -0
  178. package/skills/projectstore-scaffold/SKILL.md +42 -0
  179. package/skills/projectstore-search/SKILL.md +41 -0
  180. package/skills/projectstore-spec/SKILL.md +110 -0
  181. package/skills/projectstore-status/SKILL.md +47 -0
  182. package/skills/projectstore-statusline/SKILL.md +29 -0
  183. package/skills/projectstore-story/SKILL.md +132 -0
  184. package/skills/projectstore-story-completion/SKILL.md +69 -0
  185. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,608 @@
1
+ // projectstore — harness.mjs
2
+ //
3
+ // The one place that knows which agentic harness this process is running under,
4
+ // and the ONLY module under scripts/ allowed to read a harness-branded
5
+ // environment variable. Everything else is pure compute over a vault and must
6
+ // stay that way: a function that reads `process.env.CLAUDE_PROJECT_DIR`
7
+ // directly is a function that silently resolves to `process.cwd()` on every
8
+ // other harness — not an error anyone sees, but a vault that quietly binds to
9
+ // the wrong directory. So the branded names live in harnesses/<id>.json, this
10
+ // file resolves them, and lib.mjs re-exports the results under the names it
11
+ // already published.
12
+ //
13
+ // Deliberately a near-leaf: node builtins only, nothing from this repository,
14
+ // so lib.mjs → harness.mjs can never become a cycle and the SessionStart
15
+ // module graph pays one manifest read per process (cached below).
16
+ //
17
+ // Normative: the spec "Generated harness surfaces: manifests, the generator
18
+ // and the three invariants" — contract 1 (what a manifest carries), contract
19
+ // 2 (exactly one manifest is the source layout and does not emit), and the
20
+ // Modules table row for this file. The manifest shape, the cached loader and
21
+ // the strong/weak detection ranking are contributed by Maxim Podreshetnikov
22
+ // (PR #13, scripts/harness.mjs); the comments recording the two detection
23
+ // defects are kept verbatim because they are the reason the ranking exists.
24
+ //
25
+ // Pure node, no external deps — same constraint as lib.mjs. Read-only.
26
+
27
+ import { readFileSync, existsSync, readdirSync, realpathSync } from "node:fs";
28
+ import { join, dirname, resolve, sep } from "node:path";
29
+ import { homedir } from "node:os";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ export const REPO_ROOT = dirname(HERE);
34
+ export const MANIFEST_DIR = join(REPO_ROOT, "harnesses");
35
+
36
+ // The write family of the source harness, as a fallback for the one failure
37
+ // this module must not turn silent: a missing or malformed
38
+ // harnesses/claude-code.json (a partial tarball, a botched install) would
39
+ // otherwise make WRITE_TOOLS empty and every write-dependent path — the
40
+ // activity log, the in-flight resolver, the entry-rule score — fail without
41
+ // a word. tests/portability.test.mjs pins this list to the manifest's.
42
+ export const SOURCE_WRITE_TOOLS_FALLBACK = Object.freeze(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
43
+
44
+ // ─── Manifests ─────────────────────────────────────────────────────────
45
+
46
+ let _cache = null;
47
+
48
+ // Every manifest in harnesses/, id-keyed. Read once per process: the hooks,
49
+ // the lint and the build all call this, and re-reading per call would put
50
+ // filesystem latency inside the PreToolUse budget.
51
+ export function loadHarnesses(dir = MANIFEST_DIR) {
52
+ if (_cache && _cache.dir === dir) return _cache.map;
53
+ const map = new Map();
54
+ let names = [];
55
+ try {
56
+ names = readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
57
+ } catch {
58
+ names = [];
59
+ }
60
+ for (const n of names) {
61
+ try {
62
+ const m = JSON.parse(readFileSync(join(dir, n), "utf8"));
63
+ if (m && typeof m.id === "string") map.set(m.id, m);
64
+ } catch {
65
+ // A malformed manifest must not take down a session. The portability
66
+ // suite parses every file strictly and fails there instead, which is
67
+ // where a human is actually looking.
68
+ }
69
+ }
70
+ _cache = { dir, map };
71
+ return map;
72
+ }
73
+
74
+ export function loadHarness(id, dir = MANIFEST_DIR) {
75
+ return loadHarnesses(dir).get(id) || null;
76
+ }
77
+
78
+ export function harnessIds(dir = MANIFEST_DIR) {
79
+ return [...loadHarnesses(dir).keys()];
80
+ }
81
+
82
+ // The single source-layout harness (contract 2). Null only when the
83
+ // manifests directory is missing or unreadable.
84
+ export function sourceHarness(dir = MANIFEST_DIR) {
85
+ return [...loadHarnesses(dir).values()].find((m) => m.source_layout) || null;
86
+ }
87
+
88
+ // The harnesses that receive a generated tree. Empty until the first emitting
89
+ // manifest lands; the generator story fills this seam.
90
+ export function emittingHarnesses(dir = MANIFEST_DIR) {
91
+ return [...loadHarnesses(dir).values()].filter((m) => m.emit);
92
+ }
93
+
94
+ // Test seam: manifests are read once per process, and a test that writes a
95
+ // fixture manifest needs the next read to see it.
96
+ export function resetManifests() {
97
+ _cache = null;
98
+ }
99
+
100
+ // ─── Detection ─────────────────────────────────────────────────────────
101
+
102
+ let _detected = null;
103
+
104
+ // Which harness launched us. Decided by the branded environment variables each
105
+ // manifest declares, never by a hardcoded name — so a new harness becomes
106
+ // detectable by adding its JSON, with no edit here.
107
+ //
108
+ // PROJECTSTORE_HARNESS overrides everything: it is how an installer wrapper or
109
+ // a test pins the answer. When nothing matches we return the source harness
110
+ // rather than null, because every caller wants a manifest and the source
111
+ // layout is the one that is always present.
112
+ export function detectHarnessId(env = process.env, dir = MANIFEST_DIR) {
113
+ const memo = env === process.env && dir === MANIFEST_DIR;
114
+ if (memo && _detected) return _detected;
115
+ const id = detect(env, dir);
116
+ if (memo) _detected = id;
117
+ return id;
118
+ }
119
+
120
+ function detect(env, dir) {
121
+ const forced = env.PROJECTSTORE_HARNESS;
122
+ if (forced && loadHarnesses(dir).has(forced)) return forced;
123
+ const best = ranked(env, dir);
124
+ // A strong signal is the harness identifying itself, and it decides.
125
+ if (best && best.strong) return best.id;
126
+
127
+ // Only weak signals. That is not enough to switch harness: the project
128
+ // itself carries better evidence — whichever harness directory is present
129
+ // in it is the harness this project is used from. (Until 2026-09-06 the
130
+ // evidence was "which harness directory holds our config"; the binding is
131
+ // harness-neutral now and carries no such signal.)
132
+ const cwd = process.cwd();
133
+ for (const m of loadHarnesses(dir).values()) {
134
+ const d = m.runtime?.harness_dir;
135
+ if (d && existsSync(join(cwd, d))) return m.id;
136
+ }
137
+ if (best) return best.id;
138
+ const src = sourceHarness(dir);
139
+ return src ? src.id : null;
140
+ }
141
+
142
+ // The harness that identifies ITSELF from the environment, or null: a strong
143
+ // signal (its own plugin-root or project-dir variable, not one another
144
+ // harness shares), or its session marker — runtime.session_env, which a Bash
145
+ // tool inside a session carries while the variables a hook receives are
146
+ // absent (measured 2026-09-05). Never the weak ranking, never the cwd's
147
+ // directory, never the source harness detectHarnessId falls back to. A
148
+ // finding that must not fire for a harness the project only might use asks
149
+ // this (the install spec, contract 6 as amended after the rc.3 tag).
150
+ export const IDENTIFIED_ENV = "PROJECTSTORE_IDENTIFIED";
151
+ export function identifiedHarnessId(env = process.env, dir = MANIFEST_DIR) {
152
+ const forced = env.PROJECTSTORE_HARNESS;
153
+ if (forced && loadHarnesses(dir).has(forced)) return forced;
154
+ // A parent of ours decided before it named the project in the harness's own
155
+ // vocabulary (childEnv), so its answer stands — an empty one included.
156
+ if (env[IDENTIFIED_ENV] !== undefined) return loadHarnesses(dir).has(env[IDENTIFIED_ENV]) ? env[IDENTIFIED_ENV] : null;
157
+ const best = ranked(env, dir);
158
+ if (best && best.strong) return best.id;
159
+ for (const m of loadHarnesses(dir).values()) {
160
+ if ((m.runtime?.session_env || []).some((k) => env[k])) return m.id;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function ranked(env, dir) {
166
+ // Explicit plugin-root/home variables beat merely-present ones: a shell that
167
+ // exports CODEX_HOME globally should not make a Claude Code session read as
168
+ // Codex when Claude Code also handed us CLAUDE_PLUGIN_ROOT.
169
+ // A variable two harnesses both set identifies neither. Codex hands a plugin
170
+ // hook `CLAUDE_PLUGIN_ROOT` for compatibility alongside its own `PLUGIN_ROOT`
171
+ // (measured 2026-09-07 from a live hook payload), and that is Claude Code's
172
+ // plugin-root variable — so without this, a Codex session scores a strong hit
173
+ // for claude-code, ties with codex, and loses the tie to `readdirSync().sort()`
174
+ // putting claude-code.json first. The manifest that also sets a foreign name
175
+ // declares it in `runtime.shared_env`, and the name is demoted to weak for
176
+ // EVERY manifest: the harness that owns it no longer gets to be identified by
177
+ // it, which is the whole point. Data, not an id branch (generation spec,
178
+ // contract 1).
179
+ const shared = new Set();
180
+ for (const m of loadHarnesses(dir).values()) {
181
+ for (const k of m.runtime?.shared_env || []) shared.add(k);
182
+ }
183
+
184
+ let best = null;
185
+ for (const m of loadHarnesses(dir).values()) {
186
+ // detect_env UNION the three runtime variables, not detect_env alone: a
187
+ // manifest that names its plugin-root variable under runtime but forgets to
188
+ // repeat it in detect_env would otherwise fail to identify its own harness,
189
+ // and misdetection is silent — the wrong write-tool vocabulary, an activity
190
+ // log that simply stays empty. Deriving the obvious keys removes the footgun.
191
+ // Ranked, not counted. A plugin-root or project-dir variable is the harness
192
+ // telling us it launched this process; a home variable is just something in
193
+ // the user's shell profile. Counting them equally meant a developer who
194
+ // exports CODEX_HOME globally — the exact person this feature is for — had
195
+ // Claude Code Bash-tool invocations detected as Codex — the wrong write-tool
196
+ // vocabulary for the whole session.
197
+ const claimed = [m.runtime?.plugin_root_env, m.runtime?.project_dir_env].filter(Boolean);
198
+ const strong = claimed.filter((k) => !shared.has(k));
199
+ const weak = [
200
+ ...(m.runtime?.detect_env || []).filter((k) => !strong.includes(k)),
201
+ ...claimed.filter((k) => shared.has(k)),
202
+ m.runtime?.home_env,
203
+ ].filter(Boolean);
204
+ const strongHits = strong.filter((k) => env[k]).length;
205
+ const weakHits = [...new Set(weak)].filter((k) => env[k]).length;
206
+ if (strongHits === 0 && weakHits === 0) continue;
207
+ // Any strong signal outranks every weak one; ties fall back to hit counts.
208
+ // A numeric score, not an array: `>` on arrays compares their string forms,
209
+ // which happens to work for single digits and stops working silently at ten.
210
+ const rank = (strongHits > 0 ? 1e6 : 0) + strongHits * 1e3 + weakHits;
211
+ if (!best || rank > best.rank) best = { id: m.id, rank, strong: strongHits > 0 };
212
+ }
213
+ return best;
214
+ }
215
+
216
+ // Test seam: the detected id is memoised for the process; a test that changes
217
+ // process.env to impersonate a harness needs the next call to look again.
218
+ export function resetDetection() {
219
+ _detected = null;
220
+ }
221
+
222
+ // Which harnesses this PROJECT uses — by directory (install spec, contract
223
+ // 8). Not detectHarnessId: that answers "which harness launched this
224
+ // process" from branded environment variables, and using it here would
225
+ // conjure .claude/ for a Codex user. Not memoised: it is per directory, and a
226
+ // test builds a project per case.
227
+ export function detectHarnesses(projectDir, { dir = MANIFEST_DIR } = {}) {
228
+ const out = [];
229
+ for (const m of loadHarnesses(dir).values()) {
230
+ const d = m.runtime?.harness_dir;
231
+ if (d && existsSync(join(projectDir, d))) out.push({ id: m.id, why: "directory", evidence: d });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ // The refusal when nothing is detected and nothing is named — built from the
237
+ // manifests, so a new harness appears in it without an edit here.
238
+ export function harnessRefusal(projectDir, dir = MANIFEST_DIR) {
239
+ const lines = [`No harness detected in ${projectDir}, and none named. Name one:`];
240
+ for (const m of loadHarnesses(dir).values()) {
241
+ lines.push(` --harness ${m.id} (${m.display_name}; detected by its project directory: ${m.runtime?.harness_dir || "?"})`);
242
+ }
243
+ return lines.join("\n");
244
+ }
245
+
246
+ export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
247
+ return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
248
+ }
249
+
250
+ // ─── Runtime names and paths ───────────────────────────────────────────
251
+
252
+ // The three branded names the active harness uses, so a caller can say which
253
+ // variable it is talking about without spelling it.
254
+ export function runtimeEnvNames(env = process.env) {
255
+ const r = activeHarness(env)?.runtime || {};
256
+ return { projectDir: r.project_dir_env || null, pluginRoot: r.plugin_root_env || null, home: r.home_env || null };
257
+ }
258
+
259
+ // A hook's own stdin payload carries `cwd`; on a harness that exports no
260
+ // project-dir variable it is the only trustworthy answer, and it is only
261
+ // available after stdin has been read. Hooks may call adoptHookInput() before
262
+ // readConfig() so config lookup resolves against the right root.
263
+ let _hookProjectRoot = null;
264
+
265
+ export function adoptHookInput(input) {
266
+ if (input && typeof input.cwd === "string" && input.cwd) _hookProjectRoot = input.cwd;
267
+ return input;
268
+ }
269
+
270
+ export function resetHookInput() {
271
+ _hookProjectRoot = null;
272
+ }
273
+
274
+ // The project the user is working in. Read fresh from env on every call —
275
+ // only the harness id is memoised — so a test that sets the variable
276
+ // mid-process sees it.
277
+ export function projectRoot(env = process.env, hookInput = null) {
278
+ const key = activeHarness(env)?.runtime?.project_dir_env;
279
+ // An explicitly exported project directory wins: it is the harness stating
280
+ // the answer, where cwd is us inferring it.
281
+ if (key && env[key]) return env[key];
282
+ // Every manifest's variable, not just the active one: a wrapper may set the
283
+ // source harness's name while running under another, and honouring it is
284
+ // strictly better than falling through to cwd.
285
+ for (const h of loadHarnesses().values()) {
286
+ const k = h.runtime?.project_dir_env;
287
+ if (k && env[k]) return env[k];
288
+ }
289
+ if (hookInput && typeof hookInput.cwd === "string" && hookInput.cwd) return hookInput.cwd;
290
+ if (_hookProjectRoot) return _hookProjectRoot;
291
+ return process.cwd();
292
+ }
293
+
294
+ // The project a harness DECLARED through a project-dir variable, or null —
295
+ // never cwd by inference. For a caller that has its own notion of cwd (the
296
+ // CLI, an in-process server) and must not inherit this process's.
297
+ export function projectRootDeclared(env = process.env) {
298
+ const key = activeHarness(env)?.runtime?.project_dir_env;
299
+ if (key && env[key]) return env[key];
300
+ for (const h of loadHarnesses().values()) {
301
+ const k = h.runtime?.project_dir_env;
302
+ if (k && env[k]) return env[k];
303
+ }
304
+ return null;
305
+ }
306
+
307
+ // Where this projectstore's core lives on disk: its layouts, templates and
308
+ // scripts. The bin runs ITS OWN copy of the core: a child gets that through childEnv's
309
+ // pluginRoot, an in-process read (the query verbs' loadLayout, the headings
310
+ // registry) through this pin. Set once by cli.mjs; nothing else may call it.
311
+ let _pinnedPluginRoot = null;
312
+ export function pinPluginRoot(root) {
313
+ _pinnedPluginRoot = root || null;
314
+ }
315
+
316
+ export function pluginRoot(env = process.env) {
317
+ if (_pinnedPluginRoot) return _pinnedPluginRoot;
318
+ const named = hostPluginRoot(env);
319
+ // fileURLToPath, not URL.pathname: the latter stays percent-encoded, so an
320
+ // install path containing a space resolves to a directory that does not exist.
321
+ if (!named) return REPO_ROOT;
322
+ return containsThisCore(named) ? REPO_ROOT : named;
323
+ }
324
+
325
+ function hostPluginRoot(env) {
326
+ const key = activeHarness(env)?.runtime?.plugin_root_env;
327
+ if (key && env[key]) return env[key];
328
+ for (const h of loadHarnesses().values()) {
329
+ const k = h.runtime?.plugin_root_env;
330
+ if (k && env[k]) return env[k];
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // The host's variable names its PLUGIN root, and the core is that root only in
336
+ // the source layout. In a shell the core sits beneath it (node_modules/
337
+ // projectstore/), so the variable names a directory with none of our layouts,
338
+ // templates or scripts in it — measured on the first Codex install's cached
339
+ // hooks, 2026-10-03: every session said "vault load failed — Layout not
340
+ // found", the first one beneath its welcome. A root that CONTAINS the core this
341
+ // code runs from is that case, whatever the shell's layout, and the core
342
+ // answers from its own location. Any other ancestor counts too ($HOME, a main
343
+ // checkout above a worktree); none of them holds our assets, so the answer is
344
+ // the same. A root equal to the core, or unrelated to it (a test's fixture, the
345
+ // bin's child), is honoured as named. Real paths on both sides: the module's
346
+ // own is already real, so a root under a symlink (macOS's /var and /tmp)
347
+ // would never compare.
348
+ const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
349
+ let _realCore = null;
350
+ function containsThisCore(root) {
351
+ const core = (_realCore ??= realOr(REPO_ROOT));
352
+ const r = realOr(root);
353
+ return core !== r && core.startsWith(r.endsWith(sep) ? r : r + sep);
354
+ }
355
+
356
+ // The harness's own config directory (~/.claude, ~/.codex, …). Almost always
357
+ // the default under $HOME, but the harness's home variable relocates it — and
358
+ // a consumer that hardcodes the default silently resolves nothing for those
359
+ // users instead of failing loudly.
360
+ export function agentHome(env = process.env, home = homedir()) {
361
+ const r = activeHarness(env)?.runtime || {};
362
+ if (r.home_env && env[r.home_env]) return env[r.home_env];
363
+ return join(home, r.home_default || ".claude");
364
+ }
365
+
366
+ // ─── The project-level layout (the layout ADR, 2026-09-06) ─────────────
367
+ //
368
+ // Everything of ours in a project lives under ONE harness-neutral directory,
369
+ // <project>/.projectstore/: the binding (machine-local, never committed), the
370
+ // harness overlays (harness/<id>.json, committed) and the machine-local state
371
+ // (state/, keyed by harness inside). This is the only place a project-side
372
+ // path is spelled — every reader and writer goes through layoutPaths(), and a
373
+ // test greps the rest of scripts/, hooks/ and bin/ for the literals. The
374
+ // legacy shape (.claude/projectstore.json, .claude/.projectstore/…) is named
375
+ // here too, for the readers' fallback through 0.29 (layout spec, contracts
376
+ // 0, 1 and 7); its harness directory is the source harness's own.
377
+ export const LAYOUT = Object.freeze({
378
+ root: ".projectstore",
379
+ binding: "projectstore.json",
380
+ overlayDir: "harness",
381
+ state: "state",
382
+ sessions: "sessions",
383
+ entryLog: "entry-log.jsonl",
384
+ worktrees: "worktrees", // reserved for the worktree ADR's records (planned); no reader yet
385
+ launcher: "statusline.mjs",
386
+ welcomed: "welcomed",
387
+ // The lines .projectstore/.gitignore must carry; merged by line, never rewritten.
388
+ gitignore: Object.freeze(["projectstore.json", "state/"]),
389
+ // The vault's own, unrelated files under the same name (ADR-007 decision 4; sessions).
390
+ vaultConfig: ".projectstore.json",
391
+ vaultSessions: "sessions",
392
+ });
393
+ // The header both the legacy and the new state .gitignore carry — how
394
+ // uninstall recognises a state directory as ours (layout spec, contract 6).
395
+ export const RUNTIME_GITIGNORE_HEADER = "projectstore — per-session runtime state";
396
+
397
+ export function layoutPaths(projectDir, { harnessDir = null, dir = MANIFEST_DIR } = {}) {
398
+ const legacyDir = harnessDir || sourceHarness(dir)?.runtime?.harness_dir || ".claude";
399
+ const root = join(projectDir, LAYOUT.root);
400
+ const state = join(root, LAYOUT.state);
401
+ const legacyRuntime = join(projectDir, legacyDir, LAYOUT.root);
402
+ return {
403
+ root,
404
+ binding: join(root, LAYOUT.binding),
405
+ overlayDir: join(root, LAYOUT.overlayDir),
406
+ overlay: (id) => join(root, LAYOUT.overlayDir, `${id}.json`),
407
+ gitignore: join(root, ".gitignore"),
408
+ state,
409
+ stateGitignore: join(state, ".gitignore"),
410
+ sessions: join(state, LAYOUT.sessions),
411
+ harnessState: (id) => join(state, id),
412
+ launcher: (id) => join(state, id, LAYOUT.launcher),
413
+ welcomed: (id) => join(state, id, LAYOUT.welcomed),
414
+ entryLog: join(state, LAYOUT.entryLog),
415
+ worktrees: join(state, LAYOUT.worktrees),
416
+ legacy: {
417
+ dir: legacyDir,
418
+ binding: join(projectDir, legacyDir, LAYOUT.binding),
419
+ runtime: legacyRuntime,
420
+ gitignore: join(legacyRuntime, ".gitignore"),
421
+ state: join(legacyRuntime, "state"),
422
+ launcher: join(legacyRuntime, LAYOUT.launcher),
423
+ entryLog: join(legacyRuntime, LAYOUT.entryLog),
424
+ welcomed: join(projectDir, legacyDir, ".projectstore-welcomed"),
425
+ sessionId: join(projectDir, legacyDir, ".projectstore-session-id"),
426
+ },
427
+ };
428
+ }
429
+
430
+ // The command a user types from a terminal for this harness (the layout ADR
431
+ // decision 6; layout spec contract 12): the harness's distribution shell when
432
+ // the manifest names one — `npx projectstore-claude install --project "…"`,
433
+ // the harness fixed by the shell — else the core with --harness. Finding
434
+ // messages and deferred reasons are built here, so the installer names no
435
+ // harness id and no shell name of its own. The shell's name is data
436
+ // (install.shell), not `projectstore-<id>`: the id is claude-code, the
437
+ // published name is projectstore-claude.
438
+ export function packageCommand(harness, verb, { version = null, args = "" } = {}) {
439
+ const shell = harness?.install?.shell || null;
440
+ const pkg = `${shell || "projectstore"}${version ? `@${version}` : ""}`;
441
+ const fixed = shell ? "" : ` --harness ${harness?.id || "<id>"}`;
442
+ return `npx ${pkg} ${verb}${fixed}${args ? ` ${args}` : ""}`;
443
+ }
444
+
445
+ // The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json —
446
+ // the manifest's runtime.overlay, the harness id by convention (the layout ADR,
447
+ // decision 3). Null only when no manifest at all can be found.
448
+ export function overlayId(env = process.env, dir = MANIFEST_DIR) {
449
+ const h = activeHarness(env, dir);
450
+ return h?.runtime?.overlay || h?.id || null;
451
+ }
452
+
453
+ // The manifest an OVERLAY key belongs to. `agents` addresses overlays, not
454
+ // manifests: --harness names an id, but a detected harness contributes its
455
+ // runtime.overlay, and the two are free to differ. Looking such a key up with
456
+ // loadHarness() alone returns null the day a manifest sets overlay !== id, and
457
+ // a null there is not an error anyone sees — it is the clerk quietly not being
458
+ // pinned. Overlay first, then id, so a key that is one manifest's overlay and
459
+ // another's id resolves to the overlay's owner rather than to filename order.
460
+ export function harnessForOverlay(key, dir = MANIFEST_DIR) {
461
+ if (!key) return null;
462
+ const all = [...loadHarnesses(dir).values()];
463
+ return all.find((m) => m.runtime?.overlay === key) || all.find((m) => m.id === key) || null;
464
+ }
465
+
466
+ // A reader's fallback: the new path when it exists, else the legacy one when
467
+ // THAT exists, else the new path (a writer's target) — at most two existsSync
468
+ // calls, never a directory scan (contract 1; the SessionStart budget).
469
+ export function pickExisting(current, legacy) {
470
+ if (existsSync(current)) return current;
471
+ return existsSync(legacy) ? legacy : current;
472
+ }
473
+
474
+ // The per-project directory the HARNESS discovers (".claude" for the source
475
+ // layout) — how a harness is detected in a project and where its own settings
476
+ // live. Not where our config is: that is layoutPaths() (2026-09-06).
477
+ export function projectConfigDir(env = process.env) {
478
+ return activeHarness(env)?.runtime?.harness_dir || ".claude";
479
+ }
480
+
481
+ // The host's own machine-local settings file in a project (the statusline
482
+ // surface's file in the manifest — `.claude/settings.local.json` for Claude
483
+ // Code). A harness surface, not ours: the one `.claude` path the core may
484
+ // build, and it builds it from the manifest (2026-09-06).
485
+ export function hostSettingsPath(projectDir, env = process.env) {
486
+ const h = activeHarness(env);
487
+ const file = h?.surfaces?.statusline?.file || join(h?.runtime?.harness_dir || ".claude", "settings.local.json");
488
+ return join(projectDir, file);
489
+ }
490
+
491
+ // Our binding for a project: the new layout, falling back to the legacy file
492
+ // while the window is open. A rebind edits the binding where it stands.
493
+ export function configPath(projectDir, env = process.env) {
494
+ const p = layoutPaths(projectDir, { harnessDir: activeHarness(env)?.runtime?.harness_dir || null });
495
+ return pickExisting(p.binding, p.legacy.binding);
496
+ }
497
+
498
+ // The ONE place a branded name is WRITTEN: a child process spawned by a core
499
+ // script needs the project handed to it in the harness's own vocabulary, and
500
+ // on a harness with no project-dir variable it needs nothing at all.
501
+ export function childEnv(base = process.env, { projectRoot: root, pluginRoot: plugin } = {}) {
502
+ const out = { ...base };
503
+ const r = activeHarness(base)?.runtime || {};
504
+ // Who called is decided here, before the project is named below: a
505
+ // project-dir variable we write would otherwise read, in the child, as the
506
+ // harness identifying itself — a terminal `doctor` took Claude Code for the
507
+ // session's own harness (the review of the post-rc.3 fixes, 2026-10-04).
508
+ if (base[IDENTIFIED_ENV] === undefined) out[IDENTIFIED_ENV] = identifiedHarnessId(base) || "";
509
+ if (r.project_dir_env && root) out[r.project_dir_env] = root;
510
+ // A caller that runs its own copy of the core (the npm bin) names it, so a
511
+ // child never resolves templates or its version from a sibling install.
512
+ if (r.plugin_root_env && plugin) out[r.plugin_root_env] = plugin;
513
+ return out;
514
+ }
515
+
516
+ // Harness-level overrides of agent configuration that are set in this
517
+ // environment — the manifest names them and says what each one beats; the
518
+ // caller (doctor) phrases the finding. Only the ones actually present.
519
+ export function agentOverrides(env = process.env) {
520
+ const list = activeHarness(env)?.runtime?.agent_overrides || [];
521
+ return list
522
+ .filter((o) => o && typeof o.env === "string" && env[o.env])
523
+ .map((o) => ({ env: o.env, kind: o.kind || null, beats: o.beats || null, value: env[o.env] }));
524
+ }
525
+
526
+ // ─── Tool vocabulary ───────────────────────────────────────────────────
527
+
528
+ // The source harness's write family — the manifest value, never empty.
529
+ export function sourceWriteTools(dir = MANIFEST_DIR) {
530
+ const tools = sourceHarness(dir)?.tools?.write_tools;
531
+ return Array.isArray(tools) && tools.length ? tools : SOURCE_WRITE_TOOLS_FALLBACK;
532
+ }
533
+
534
+ // The ACTIVE harness's write family. With one manifest this is the source's;
535
+ // widening it to a union across harnesses is a decision the generator story
536
+ // makes explicitly, with its own test — not a side effect of adding a file.
537
+ export function writeTools(env = process.env) {
538
+ const tools = activeHarness(env)?.tools?.write_tools;
539
+ return Array.isArray(tools) && tools.length ? tools : sourceWriteTools();
540
+ }
541
+
542
+ export function knownNonWriteTools(env = process.env) {
543
+ return activeHarness(env)?.tools?.known_non_write_tools || [];
544
+ }
545
+
546
+ export function isWriteTool(tool, env = process.env) {
547
+ return writeTools(env).includes(tool);
548
+ }
549
+
550
+ // Paths carried by a harness tool payload. Ordinary tools expose one of the
551
+ // manifest's path_fields directly. Codex's apply_patch carries a complete patch
552
+ // envelope in a string field instead, so every Add/Update/Delete path is
553
+ // extracted without teaching the hook a harness name or a patch field name.
554
+ export function toolPaths(input, env = process.env) {
555
+ const ti = input?.tool_input;
556
+ if (!ti || typeof ti !== "object") return [];
557
+ const tools = activeHarness(env)?.tools || {};
558
+ const out = [];
559
+ for (const field of tools.path_fields || []) {
560
+ const value = ti[field];
561
+ if (typeof value === "string" && value) out.push(value);
562
+ }
563
+ const envelope = tools.patch_envelope_field && ti[tools.patch_envelope_field];
564
+ if (typeof envelope === "string") {
565
+ for (const line of envelope.split(/\r?\n/)) {
566
+ const m = line.match(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/);
567
+ if (m) out.push(m[1]);
568
+ const moved = line.match(/^\*\*\* Move to:\s*(.+?)\s*$/);
569
+ if (moved) out.push(moved[1]);
570
+ }
571
+ }
572
+ return [...new Set(out)];
573
+ }
574
+
575
+ // ─── Lint patterns (contract 6) ────────────────────────────────────────
576
+
577
+ // The forbidden-unmapped list for one EMITTING harness: its own declared
578
+ // patterns plus patterns derived from every other manifest (their write-tool
579
+ // names, their branded environment variables). Empty while the source
580
+ // harness is the only one — the generator story gives it teeth.
581
+ export function lintPatterns(harness, dir = MANIFEST_DIR) {
582
+ if (!harness || harness.source_layout) return [];
583
+ const out = [];
584
+ for (const p of harness.lint?.forbidden_unmapped || []) {
585
+ // No default for `class`: the schema test requires every declared pattern
586
+ // to say whether it matches by name (case-insensitive) or token.
587
+ if (p && typeof p.pattern === "string") out.push({ pattern: p.pattern, class: p.class, derived: false });
588
+ }
589
+ // A name the linted harness declares ITSELF is never foreign, however many
590
+ // other manifests also name it. Codex declares CLAUDE_PLUGIN_ROOT under
591
+ // shared_env because it really does set it; without this exclusion, adding
592
+ // shared_env to the derivation below would forbid Claude Code's own variable
593
+ // inside Claude Code's own tree.
594
+ const o = harness.runtime || {};
595
+ const own = new Set([o.project_dir_env, o.plugin_root_env, o.home_env, ...(o.detect_env || []), ...(o.shared_env || [])].filter(Boolean));
596
+ for (const m of loadHarnesses(dir).values()) {
597
+ if (m.id === harness.id) continue;
598
+ for (const t of m.tools?.write_tools || []) out.push({ pattern: `\\b${t}\\b`, class: "token", derived: true });
599
+ const r = m.runtime || {};
600
+ // shared_env included: a name is no less branded for being set by two
601
+ // harnesses, and it exists in no other field of the manifest that declares
602
+ // it — CLAUDE_PLUGIN_DATA appears nowhere but codex.json's shared_env.
603
+ for (const k of [r.project_dir_env, r.plugin_root_env, r.home_env, ...(r.detect_env || []), ...(r.shared_env || [])]) {
604
+ if (k && !own.has(k)) out.push({ pattern: `\\b${k}\\b`, class: "token", derived: true });
605
+ }
606
+ }
607
+ return out;
608
+ }