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.
- package/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +163 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +36 -14
- package/plugin.json +53 -0
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- 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
|
+
}
|