@marcomigozzi/trailhead 0.10.0 → 0.11.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 (36) hide show
  1. package/README.it.md +2 -2
  2. package/README.md +2 -2
  3. package/bin/lib/install-verify.js +257 -0
  4. package/bin/lib/install-verify.test.js +379 -0
  5. package/bin/trailhead.js +86 -5
  6. package/bin/trailhead.test.js +500 -4
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  10. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  11. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  12. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  13. package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
  14. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
  15. package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
  16. package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  18. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +9 -6
  20. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  21. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  22. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  23. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
  24. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  25. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  31. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  34. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  35. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  36. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
package/README.it.md CHANGED
@@ -260,7 +260,7 @@ Molte persone (e le loro sessioni di agente) condividono una mappa e la lavorano
260
260
  - **Split** un ticket sovradimensionato in figli e supersede l'originale (`/trailhead:split`).
261
261
  - **Pause/resume** tramite un commento di checkpoint `PAUSED`, così qualsiasi sessione può riprendere un ticket.
262
262
  - **Isola le sessioni concorrenti con i worktree.** Di default (`isolation: auto`) trailhead lo fa per te: al work-start di ogni ticket chiede se ne lavorerai più di uno alla volta e sceglie la modalità in base alla forma del repo. Per forzarla in anticipo, imposta `isolation: worktree` nell'header delle conventions, e ogni ticket in esecuzione gira nel proprio `git worktree` su un branch `trailhead/t<n>`, integrato di nuovo nel trunk al Resolve. Implica un branch per ticket anche sotto `git: main` (git non farà il checkout del trunk due volte), ed è la postura giusta per un **monorepo** (un worktree condivide l'object store, molto più economico di un secondo clone). Un'avvertenza: un worktree isola il *source* ma è un path nuovo senza dipendenze installate, quindi un **package legato a un path** (React Native/Expo, toolchain native, qualsiasi cosa legata a un `node_modules` locale) non buildera da lì. Per questi c'è **`isolation: clone`**: un clone dedicato per-ticket che installa le proprie dipendenze e builda/UATa come l'app vera (più pesante di un worktree, e lo strumento giusto per un submodule Expo/RN). Oppure tieni `isolation: none` e serializza. Altrimenti esegui le sessioni concorrenti in clone separati.
263
- - **Un marker session-ticket per la tua statusline.** Quando inizia a lavorare un ticket, trailhead deposita una riga singola, gitignorata, `.trailhead/session-ticket` (`#<n> <title>`) alla radice di lavoro e la pulisce all'handoff. È puramente un hint economico e offline così una statusline (o qualsiasi tool) può mostrare *su quale ticket è questa sessione* senza colpire il tracker; trailhead stesso continua a trattare le Issue come fonte di verità.
263
+ - **Un marker session-ticket per la tua statusline.** Quando inizia a lavorare un ticket, trailhead deposita una riga singola, gitignorata, `.trailhead/session-ticket` (`#<n> <title>`) alla radice di lavoro e la pulisce all'handoff. È un hint economico e offline così una statusline (o qualsiasi tool) può mostrare *su quale ticket è questa sessione* senza colpire il tracker; trailhead stesso continua a trattare le Issue come fonte di verità. Fa anche di più: finché è presente, l'hook git commit-msg richiede che ogni commit porti un trailer `Refs: #<n>` corrispondente.
264
264
  - **Sceglie l'isolation per te di default (`auto`).** Senza un `isolation:` esplicito impostato (il default è `auto`), la prima volta che lavori un ticket trailhead chiede se ne lavorerai più di uno alla volta su questa macchina in questa sessione; se sì usa un `git worktree` (repo semplice) o un `clone` (superrepo con submodule), altrimenti lavora semplicemente nel checkout corrente. Non viene scritto nulla nelle conventions, e puoi comunque forzare `none`/`worktree`/`clone` esplicitamente.
265
265
  - **Dai scope ai ticket per path su un monorepo.** Un ticket può portare una riga `Scope:` che nomina il/i package/dir che tocca: scope disgiunti parallelizzano in sicurezza, e dà scope a build/test e ai commit del worktree limitandoli al package interessato. `/trailhead:adopt` rileva un monorepo e propone sia `isolation: worktree` sia la convenzione `Scope:`. Sotto `isolation: none`, `Scope:` **serializza** anche un'area condivisa: al momento del claim una sessione si trattiene se lo scope di un altro ticket in corso si sovrappone, così due ticket con scope sullo stesso submodule/package non possono girare insieme (è così che tieni due sessioni fuori da un'app legata a un path).
266
266
  - **I git submodule sono gestiti come repo separati.** Il lavoro che atterra dentro un submodule viene committato *dentro il submodule* (su un branch) e pubblicato prima che un bump del gitlink nel parent lo referenzi. Poiché i worktree del superproject condividono un unico git dir del submodule, un **repo con submodule risolve a `clone`** (sotto il default `auto`, o scalando un `worktree` esplicito), così ogni ticket ottiene uno stato del submodule indipendente; il clone provvede i suoi submodule al setup (`--recurse-submodules`) e il Verify controlla `git status` dentro ogni submodule toccato. Un ticket che copre due submodule resta atomico tramite un singolo commit del parent che bumpa entrambi i gitlink insieme; il suo `Scope:` li nomina entrambi, e lo stesso submodule conta come scope sovrapposto, quindi due ticket su di esso serializzano. `/trailhead:adopt` rileva `.gitmodules` e scrive la regola nelle conventions.
@@ -325,7 +325,7 @@ La **sessione principale resta l'orchestratore**. Tiene i momenti interattivi (i
325
325
  Esempio di file di config (la stessa forma funziona per il `.trailhead/config.json` di progetto e il `~/.claude/trailhead/config.json` globale):
326
326
 
327
327
  ```json
328
- { "ticket": { "language": "en" }, "models": { "plan": "claude-opus-4-8", "execute": "claude-sonnet-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
328
+ { "ticket": { "language": "en" }, "models": { "plan": "claude-opus-5-5", "execute": "claude-sonnet-5-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
329
329
  ```
330
330
 
331
331
  **Design mockups.** `design: disk` (il default) deposita un mockup HTML statico usa-e-getta accanto al codice e lo collega dal ticket. `design: claude.ai/design` invece costruisce il mockup su claude.ai/design (canvas tramite la skill integrata `/design` o un progetto creato a mano nella web app; design-system tramite l'**MCP ufficiale Claude Design** di Anthropic, `claude mcp add --scope user --transport http claude-design https://api.anthropic.com/v1/design/mcp`, accedi con `/design-login`), dove lo affini visivamente, e `design: stitch` usa l'**MCP Google Stitch** (`claude mcp add --scope user --transport http stitch https://stitch.googleapis.com/mcp`), il generatore UI di Google basato su Gemini che gira sia su **Claude Code sia su Codex** (l'opzione hosted cross-host). Entrambe onorano `design.surface`:
package/README.md CHANGED
@@ -260,7 +260,7 @@ Many people (and their agent sessions) share one map and work it concurrently:
260
260
  - **Split** an oversized ticket into children and supersede the original (`/trailhead:split`).
261
261
  - **Pause/resume** via a `PAUSED` checkpoint comment, so any session can pick a ticket back up.
262
262
  - **Isolate concurrent sessions with worktrees.** By default (`isolation: auto`) trailhead does this for you: at each ticket's work-start it asks whether you'll run more than one at once and picks the mode by repo shape. To force it eagerly, set `isolation: worktree` in the conventions header, and each executing ticket runs in its own `git worktree` on a `trailhead/t<n>` branch, integrated back to the trunk at Resolve. It implies a branch per ticket even under `git: main` (git won't check the trunk out twice), and it is the right posture for a **monorepo** (a worktree shares the object store, far cheaper than a second clone). One caveat: a worktree isolates *source* but is a fresh path with no installed deps, so a **path-bound package** (React Native/Expo, native toolchains, anything tied to a local `node_modules`) won't build from it. For those there's **`isolation: clone`**: a dedicated per-ticket clone that installs its own deps and builds/UATs like the real app (heavier than a worktree, and the right tool for an Expo/RN submodule). Or keep `isolation: none` and serialize. Otherwise run concurrent sessions in separate clones.
263
- - **A session-ticket marker for your statusline.** When it starts working a ticket, trailhead drops a one-line, gitignored `.trailhead/session-ticket` (`#<n> <title>`) at the working root and clears it at handoff. It's purely a cheap, offline hint so a statusline (or any tool) can show *which ticket this session is on* without hitting the tracker; trailhead itself still treats the Issues as the source of truth.
263
+ - **A session-ticket marker for your statusline.** When it starts working a ticket, trailhead drops a one-line, gitignored `.trailhead/session-ticket` (`#<n> <title>`) at the working root and clears it at handoff. It's a cheap, offline hint so a statusline (or any tool) can show *which ticket this session is on* without hitting the tracker; trailhead itself still treats the Issues as the source of truth. It also does more than that: while it's present, the git commit-msg hook requires every commit to carry a `Refs: #<n>` trailer matching it.
264
264
  - **It picks isolation for you by default (`auto`).** With no explicit `isolation:` set (the default is `auto`), the first time you work a ticket trailhead asks whether you'll work more than one at once on this machine this session; if so it uses a `git worktree` (plain repo) or a `clone` (submodule superrepo), otherwise it just works in the current checkout. Nothing is written into the conventions, and you can still force `none`/`worktree`/`clone` explicitly.
265
265
  - **Scope tickets by path on a monorepo.** A ticket can carry a `Scope:` line naming the package/dir(s) it touches: disjoint scopes parallelize safely, and it scopes build/test and the worktree's commits to the affected package. `/trailhead:adopt` detects a monorepo and proposes both `isolation: worktree` and the `Scope:` convention. Under `isolation: none`, `Scope:` also **serializes** a shared area: at claim time a session holds off if another in-progress ticket's scope overlaps, so two tickets scoped to the same submodule/package can't run at once (how you keep two sessions off one path-bound app).
266
266
  - **Git submodules are handled as separate repos.** Work that lands inside a submodule is committed *inside the submodule* (on a branch) and published before a gitlink bump in the parent points at it. Because superproject worktrees share one submodule git dir, a **submodule-bearing repo resolves to `clone`** (under the default `auto`, or by escalating an explicit `worktree`), so each ticket gets independent submodule state; the clone provisions its submodules at setup (`--recurse-submodules`) and Verify checks `git status` inside each touched submodule. A ticket spanning two submodules stays atomic via a single parent commit that bumps both gitlinks together; its `Scope:` names both, and the same submodule counts as overlapping scope, so two tickets on it serialise. `/trailhead:adopt` detects `.gitmodules` and writes the rule into the conventions.
@@ -325,7 +325,7 @@ The **main session stays the orchestrator**. It holds the interactive moments (a
325
325
  Example config file (the same shape works for the project `.trailhead/config.json` and the global `~/.claude/trailhead/config.json`):
326
326
 
327
327
  ```json
328
- { "ticket": { "language": "en" }, "models": { "plan": "claude-opus-4-8", "execute": "claude-sonnet-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
328
+ { "ticket": { "language": "en" }, "models": { "plan": "claude-opus-5-5", "execute": "claude-sonnet-5-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
329
329
  ```
330
330
 
331
331
  **Design mockups.** `design: disk` (the default) drops a throwaway static HTML mockup next to the code and links it from the ticket. `design: claude.ai/design` instead builds the mockup on claude.ai/design (canvas via the built-in `/design` skill or a hand-made web-app project; design-system via Anthropic's **official Claude Design MCP**, `claude mcp add --scope user --transport http claude-design https://api.anthropic.com/v1/design/mcp`, sign in with `/design-login`), where you refine it visually, and `design: stitch` uses the **Google Stitch MCP** (`claude mcp add --scope user --transport http stitch https://stitch.googleapis.com/mcp`), Google's Gemini-powered UI generator that runs on **both Claude Code and Codex** (the cross-host hosted option). Both honour `design.surface`:
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ // install-verify.js: post-install filesystem verification for both hosts.
3
+ //
4
+ // installClaude/installCodex place files and register hooks, but never
5
+ // checked any of it landed. This module adds that check: given a spec
6
+ // describing what an install is supposed to look like, it walks the
7
+ // filesystem (and the host's hook registry file) and reports what's missing
8
+ // or merely unverifiable (a registered hook target whose path depends on an
9
+ // env var this process doesn't have set).
10
+ //
11
+ // Reads the filesystem only, never writes, and never throws: a stat/parse
12
+ // error just counts as "missing" or an empty registry. It never reads
13
+ // process.env or os.homedir() itself; the caller always passes both in
14
+ // (spec.env, spec.home), so the tests stay hermetic.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { codexLayout } = require('./codex-projection.js');
19
+
20
+ // --- fs probes, never throw --------------------------------------------------
21
+ function isDir(p) {
22
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
23
+ }
24
+ function isFile(p) {
25
+ try { return fs.statSync(p).isFile(); } catch { return false; }
26
+ }
27
+
28
+ // --- sharedCoreFiles ----------------------------------------------------------
29
+ // The shared-core file list, single-sourced from _shared/load-first.md's own
30
+ // numbered "Load first, in order" list: load-first.md itself, plus every
31
+ // `../_shared/<name>.md` named on a numbered-list line, de-duplicated, in
32
+ // order. Empty/unreadable text still yields ['load-first.md'].
33
+ function sharedCoreFiles(loadFirstText) {
34
+ const names = ['load-first.md'];
35
+ const re = /^\d+\.\s+`\.\.\/_shared\/([\w.-]+\.md)`/gm;
36
+ let m;
37
+ while ((m = re.exec(loadFirstText || '')) !== null) {
38
+ if (!names.includes(m[1])) names.push(m[1]);
39
+ }
40
+ return names;
41
+ }
42
+
43
+ // --- hookScriptTokens ---------------------------------------------------------
44
+ // Split a shell-like command into tokens (quotes stripped), then return every
45
+ // token whose basename matches trailhead-<name>.js. A1: a quoted token that
46
+ // itself contains whitespace (e.g. bash -c's payload) is re-tokenized
47
+ // recursively, so an inner script path nested inside `bash -c '...'` is still
48
+ // found as its own token.
49
+ function tokenize(command) {
50
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
51
+ const tokens = [];
52
+ let m;
53
+ while ((m = re.exec(command)) !== null) {
54
+ tokens.push(m[1] !== undefined ? m[1] : (m[2] !== undefined ? m[2] : m[3]));
55
+ }
56
+ return tokens;
57
+ }
58
+
59
+ function hookScriptTokens(command) {
60
+ const found = [];
61
+ const visit = (cmd) => {
62
+ for (const token of tokenize(cmd)) {
63
+ if (/\s/.test(token)) {
64
+ visit(token);
65
+ } else if (/^trailhead-[\w-]+\.js$/.test(path.basename(token))) {
66
+ found.push(token);
67
+ }
68
+ }
69
+ };
70
+ visit(command || '');
71
+ return found;
72
+ }
73
+
74
+ // --- expandTarget --------------------------------------------------------------
75
+ // Expand a `~`/`~/...` prefix (to `home`) and any `$VAR`/`${VAR}` references
76
+ // (from `env`). The first undefined variable short-circuits to
77
+ // { unresolved: varName }; an empty-string value counts as defined and is
78
+ // substituted as-is. `~user/...` is intentionally NOT expanded (it stays
79
+ // relative and later fails as a relative target).
80
+ function expandTarget(token, { env, home }) {
81
+ let result = token;
82
+ if (result === '~' || result.startsWith('~/')) {
83
+ result = path.join(home, result.slice(1));
84
+ }
85
+ const re = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
86
+ let out = '';
87
+ let lastIndex = 0;
88
+ let match;
89
+ while ((match = re.exec(result)) !== null) {
90
+ const name = match[1] || match[2];
91
+ const val = env[name];
92
+ if (val === undefined) return { unresolved: name };
93
+ out += result.slice(lastIndex, match.index) + val;
94
+ lastIndex = match.index + match[0].length;
95
+ }
96
+ out += result.slice(lastIndex);
97
+ return { path: out };
98
+ }
99
+
100
+ // --- verifyInstall: individual checks -----------------------------------------
101
+ function checkSkills(spec, missing) {
102
+ for (const name of spec.skillDirs) {
103
+ if (name === '_shared') continue;
104
+ const skillMd = path.join(spec.skillsRoot, name, 'SKILL.md');
105
+ if (!isDir(path.join(spec.skillsRoot, name)) || !isFile(skillMd)) {
106
+ missing.push(`skill ${name}/SKILL.md`);
107
+ }
108
+ }
109
+ }
110
+
111
+ function checkSharedCore(spec, missing) {
112
+ const sharedDir = path.join(spec.skillsRoot, '_shared');
113
+ for (const f of spec.sharedFiles) {
114
+ if (!isDir(sharedDir) || !isFile(path.join(sharedDir, f))) missing.push(`shared core _shared/${f}`);
115
+ }
116
+ }
117
+
118
+ function checkHookScripts(spec, missing) {
119
+ for (const f of spec.hookFiles) {
120
+ if (!isFile(path.join(spec.hooksDir, f))) missing.push(`hook script ${f}`);
121
+ }
122
+ for (const f of spec.hookLibFiles) {
123
+ if (!isFile(path.join(spec.hooksDir, 'lib', f))) missing.push(`hook lib lib/${f}`);
124
+ }
125
+ }
126
+
127
+ function readRegistry(registryFile) {
128
+ try { return JSON.parse(fs.readFileSync(registryFile, 'utf8')); } catch { return {}; }
129
+ }
130
+
131
+ // Registered hook targets + expected registration. Walks hooks.<event>[].hooks[]
132
+ // .command in the registry file, expands each trailhead-* token, and sorts it
133
+ // into `missing` (relative or non-existent) or `unverified` (undefined var).
134
+ // Then, per A2, an expected hookFiles entry counts as registered when some
135
+ // token expands to exactly hooksDir/<f>, OR an unresolved token's basename is
136
+ // <f> (stays warning-only, never also missing).
137
+ function checkRegisteredTargets(spec, missing, unverified) {
138
+ const registry = readRegistry(spec.registryFile);
139
+ const registryName = path.basename(spec.registryFile);
140
+ const events = (registry && typeof registry.hooks === 'object' && registry.hooks) ? registry.hooks : {};
141
+ const registeredExact = new Set();
142
+ const registeredViaUnresolved = new Set();
143
+
144
+ for (const [event, groups] of Object.entries(events)) {
145
+ for (const group of Array.isArray(groups) ? groups : []) {
146
+ for (const hook of Array.isArray(group.hooks) ? group.hooks : []) {
147
+ const command = typeof hook.command === 'string' ? hook.command : '';
148
+ for (const token of hookScriptTokens(command)) {
149
+ const expanded = expandTarget(token, { env: spec.env, home: spec.home });
150
+ if (expanded.unresolved) {
151
+ unverified.push(`${token} (${event}, ${registryName}: $${expanded.unresolved} undefined)`);
152
+ registeredViaUnresolved.add(path.basename(token));
153
+ continue;
154
+ }
155
+ if (!path.isAbsolute(expanded.path)) {
156
+ missing.push(`registered hook target ${token} is relative (${event}, ${registryName})`);
157
+ continue;
158
+ }
159
+ if (!isFile(expanded.path)) {
160
+ missing.push(`registered hook target ${expanded.path} (${event}, ${registryName})`);
161
+ continue;
162
+ }
163
+ for (const f of spec.hookFiles) {
164
+ if (expanded.path === path.join(spec.hooksDir, f)) registeredExact.add(f);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ for (const f of spec.hookFiles) {
172
+ if (!registeredExact.has(f) && !registeredViaUnresolved.has(f)) {
173
+ missing.push(`hook registration ${f} (${registryName})`);
174
+ }
175
+ }
176
+ }
177
+
178
+ function checkAgents(spec, missing) {
179
+ let files = [];
180
+ if (isDir(spec.agentsDir)) {
181
+ try { files = fs.readdirSync(spec.agentsDir); } catch { files = []; }
182
+ }
183
+ const hasMatch = files.some((f) => spec.agentPattern.test(f) && isFile(path.join(spec.agentsDir, f)));
184
+ if (!hasMatch) missing.push(`agents: no trailhead-* agents in ${spec.agentsDir}`);
185
+ }
186
+
187
+ // --- verifyInstall --------------------------------------------------------------
188
+ // spec = { skillsRoot, skillDirs, sharedFiles, hooksDir, hookFiles,
189
+ // hookLibFiles, registryFile, agentsDir, agentPattern, env, home }
190
+ function verifyInstall(spec) {
191
+ const missing = [];
192
+ const unverified = [];
193
+ checkSkills(spec, missing);
194
+ checkSharedCore(spec, missing);
195
+ checkHookScripts(spec, missing);
196
+ checkRegisteredTargets(spec, missing, unverified);
197
+ checkAgents(spec, missing);
198
+ return { missing, unverified };
199
+ }
200
+
201
+ // --- spec builders ---------------------------------------------------------
202
+ function claudeVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env, home }) {
203
+ return {
204
+ skillsRoot: path.join(configDir, 'skills'),
205
+ skillDirs,
206
+ sharedFiles,
207
+ hooksDir: path.join(configDir, 'hooks'),
208
+ hookFiles,
209
+ hookLibFiles,
210
+ registryFile: path.join(configDir, 'settings.json'),
211
+ agentsDir: path.join(configDir, 'agents'),
212
+ agentPattern: /^trailhead-.*\.md$/,
213
+ env,
214
+ home,
215
+ };
216
+ }
217
+
218
+ function codexVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env, home }) {
219
+ const L = codexLayout(configDir);
220
+ return {
221
+ skillsRoot: L.skillsRoot,
222
+ skillDirs,
223
+ sharedFiles,
224
+ hooksDir: L.hooksScriptsDir,
225
+ hookFiles,
226
+ hookLibFiles,
227
+ registryFile: L.hooksJson,
228
+ agentsDir: L.codexAgentsDir,
229
+ agentPattern: /^trailhead-.*\.toml$/,
230
+ env,
231
+ home,
232
+ };
233
+ }
234
+
235
+ // --- formatters --------------------------------------------------------------
236
+ function formatVerifyFailure(hostLabel, configDir, { missing, unverified }) {
237
+ const lines = [`✗ trailhead install incomplete for ${hostLabel} → ${configDir}`];
238
+ for (const m of missing) lines.push(` missing: ${m}`);
239
+ for (const u of unverified) lines.push(` unverified: ${u}`);
240
+ lines.push('Re-run the installer to restore a missing file. For a stale or relative hook target, remove that entry from the registry file named above (or run --uninstall), then reinstall.');
241
+ return lines.join('\n');
242
+ }
243
+
244
+ function formatVerifyWarnings(unverified) {
245
+ return unverified.map((u) => ` ⚠ unverified hook target ${u}`);
246
+ }
247
+
248
+ module.exports = {
249
+ sharedCoreFiles,
250
+ hookScriptTokens,
251
+ expandTarget,
252
+ verifyInstall,
253
+ claudeVerifySpec,
254
+ codexVerifySpec,
255
+ formatVerifyFailure,
256
+ formatVerifyWarnings,
257
+ };