@marcomigozzi/trailhead 0.3.0 → 0.4.0

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.
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // Tests for codex-projection.js. Run: node codex-projection.test.js
3
+ // No framework: plain asserts, mirrors the style of host-descriptor.test.js.
4
+ const assert = require('assert');
5
+ const {
6
+ codexLayout,
7
+ convertToCodex,
8
+ codexSkillAdapterHeader,
9
+ injectCodexAdapterHeader,
10
+ codexAgentsYaml,
11
+ codexAgentToml,
12
+ codexAgentTomlPlan,
13
+ codexVerbSkillContent,
14
+ codexVerbSkillAgentsYaml,
15
+ codexVerbSkillPlan,
16
+ codexHookEntries,
17
+ enableCodexHooksFeature,
18
+ enableCodexMultiAgentV2Feature,
19
+ } = require('./codex-projection.js');
20
+
21
+ let passed = 0;
22
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
23
+
24
+ // --- convertToCodex: command surface ---
25
+ ok('convertToCodex rewrites /trailhead:work to $trailhead work', convertToCodex('/trailhead:work') === '$trailhead work');
26
+ ok('convertToCodex rewrites bare /trailhead to $trailhead', (() => {
27
+ const out = convertToCodex('run /trailhead now');
28
+ return out.includes('$trailhead') && !out.includes('/trailhead ');
29
+ })());
30
+ ok('convertToCodex leaves bare labels untouched', convertToCodex('label trailhead:build stays').includes('trailhead:build'));
31
+ // Regression: the bare-command rule must NOT eat `/trailhead` inside a path
32
+ // segment (a \b boundary before `/` or `-` used to corrupt these).
33
+ ok('convertToCodex preserves a /trailhead/ path segment',
34
+ convertToCodex('read ~/.codex/skills/trailhead/references/foo.md').includes('/skills/trailhead/references/'));
35
+ ok('convertToCodex preserves a /trailhead- filename',
36
+ convertToCodex('copy templates/trailhead-commit-msg here').includes('templates/trailhead-commit-msg'));
37
+
38
+ // --- convertToCodex: /clear -> /new ---
39
+ ok('convertToCodex rewrites /clear to /new', (() => {
40
+ const out = convertToCodex('run `/clear` now');
41
+ return out.includes('/new') && !out.includes('/clear');
42
+ })());
43
+
44
+ // --- convertToCodex: paths ---
45
+ ok('convertToCodex rewrites ~/.claude/foo', convertToCodex('~/.claude/foo') === '~/.codex/foo');
46
+ ok('convertToCodex rewrites $HOME/.claude', convertToCodex('$HOME/.claude') === '$HOME/.codex');
47
+ ok('convertToCodex rewrites bare .claude/', convertToCodex('see .claude/skills/').includes('.codex/skills/'));
48
+
49
+ // --- convertToCodex: plugin root ---
50
+ ok('convertToCodex rewrites CLAUDE_PLUGIN_ROOT', convertToCodex('${CLAUDE_PLUGIN_ROOT}/templates').includes('~/.codex/skills/trailhead/templates'));
51
+
52
+ // --- codexLayout ---
53
+ const layout = codexLayout('/c');
54
+ ok('codexLayout skillDir', layout.skillDir === '/c/skills/trailhead');
55
+ ok('codexLayout skillMain', layout.skillMain === '/c/skills/trailhead/SKILL.md');
56
+ ok('codexLayout versionFile', layout.versionFile === '/c/skills/trailhead/VERSION');
57
+ ok('codexLayout templatesDir', layout.templatesDir === '/c/skills/trailhead/templates');
58
+ ok('codexLayout agentsYaml', layout.agentsYaml === '/c/skills/trailhead/agents/openai.yaml');
59
+ ok('codexLayout hooksScriptsDir', layout.hooksScriptsDir === '/c/skills/trailhead/hooks');
60
+ ok('codexLayout hooksJson', layout.hooksJson === '/c/hooks.json');
61
+ ok('codexLayout configToml', layout.configToml === '/c/config.toml');
62
+ ok('codexLayout codexAgentsDir', layout.codexAgentsDir === '/c/agents');
63
+
64
+ // --- codexSkillAdapterHeader ---
65
+ const header = codexSkillAdapterHeader();
66
+ ok('codexSkillAdapterHeader has adapter tag', header.includes('<codex_skill_adapter>'));
67
+ ok('codexSkillAdapterHeader mentions request_user_input', header.includes('request_user_input'));
68
+ ok('codexSkillAdapterHeader mentions $trailhead <verb> form', header.includes('$trailhead <verb>'));
69
+ ok('codexSkillAdapterHeader does not mention the legacy hyphen form', !header.includes('/trailhead-<verb>'));
70
+ ok('codexSkillAdapterHeader §D describes native multi_agent spawning',
71
+ header.includes('spawn_agent') && header.includes('multi_agent'));
72
+ ok('codexSkillAdapterHeader §D says subagents inherit the one session model',
73
+ header.includes('inherit the one session model'));
74
+ ok('codexSkillAdapterHeader §D dispatches by agent_type under multi_agent_v2',
75
+ header.includes('agent_type') && header.includes('multi_agent_v2'));
76
+ ok('codexSkillAdapterHeader §D detects the registry by spawn_agent schema introspection',
77
+ header.includes('spawn_agent') && header.includes('schema'));
78
+ ok('codexSkillAdapterHeader §D no longer calls per-subagent model pinning deferred',
79
+ !header.includes('deferred'));
80
+ ok('codexSkillAdapterHeader §D pins the exact agent_type dispatch call shape',
81
+ header.includes('spawn_agent(agent_type="trailhead-<technique>"'));
82
+ ok('codexSkillAdapterHeader §D keeps the base spawn_agent v1 fallback path',
83
+ header.includes('base multi_agent v1') && header.includes('inherit the one session model'));
84
+ ok('codexSkillAdapterHeader §F mentions real Codex hooks', header.includes('real Codex hooks'));
85
+ ok('codexSkillAdapterHeader §F no longer says Codex has no hook bus', !header.includes('Codex has no hook bus'));
86
+
87
+ // --- codexVerbSkillContent (#46) ---
88
+ ok('codexVerbSkillContent: SKILL.md frontmatter is at byte 0 with name+description', (() => {
89
+ const out = codexVerbSkillContent({ verb: 'work', description: 'Work the next frontier ticket' });
90
+ return out.startsWith('---\nname: trailhead-work\n') && out.includes('description: "Work the next frontier ticket"');
91
+ })());
92
+ ok('codexVerbSkillContent: body delegates to $trailhead <verb>', (() => {
93
+ const out = codexVerbSkillContent({ verb: 'work', description: 'x' });
94
+ return out.includes('`$trailhead work`') && out.includes('single source of truth');
95
+ })());
96
+ ok('codexVerbSkillContent: falls back to a description when none given', (() => {
97
+ const out = codexVerbSkillContent({ verb: 'update' });
98
+ return out.includes('description: "trailhead update"');
99
+ })());
100
+ ok('codexVerbSkillContent: YAML-quotes a description with a colon/quote', (() => {
101
+ const out = codexVerbSkillContent({ verb: 'bug', description: 'Capture a bug: "regression"' });
102
+ return out.includes('description: "Capture a bug: \\"regression\\""');
103
+ })());
104
+
105
+ // --- codexVerbSkillAgentsYaml (#46) ---
106
+ ok('codexVerbSkillAgentsYaml: explicit-only invocation, no auto-trigger', (() => {
107
+ const out = codexVerbSkillAgentsYaml({ verb: 'work', description: 'x' });
108
+ return out.includes('allow_implicit_invocation: false') && out.includes('display_name: "Trailhead: work"');
109
+ })());
110
+
111
+ // --- codexVerbSkillPlan (#46) ---
112
+ ok('codexVerbSkillPlan: one skills/trailhead-<verb>/ dir per verb (SKILL.md + openai.yaml)', (() => {
113
+ const plan = codexVerbSkillPlan('/c', [{ verb: 'work' }, { verb: 'bug' }]);
114
+ return plan.dirs.length === 2 &&
115
+ plan.dirs.includes('/c/skills/trailhead-work') &&
116
+ plan.writes.some((w) => w.path === '/c/skills/trailhead-work/SKILL.md') &&
117
+ plan.writes.some((w) => w.path === '/c/skills/trailhead-work/agents/openai.yaml') &&
118
+ plan.writes.some((w) => w.path === '/c/skills/trailhead-bug/SKILL.md');
119
+ })());
120
+ ok('codexVerbSkillPlan: never emits a bare trailhead skill ($trailhead already is smart entry)', (() => {
121
+ const plan = codexVerbSkillPlan('/c', [{ verb: 'work' }]);
122
+ return !plan.dirs.includes('/c/skills/trailhead') && !plan.writes.some((w) => w.path === '/c/skills/trailhead/SKILL.md');
123
+ })());
124
+ ok('codexVerbSkillPlan: accepts a bare-string verb and skips empty/invalid', (() => {
125
+ const plan = codexVerbSkillPlan('/c', [{ verb: '' }, { verb: null }, 'work']);
126
+ return plan.dirs.length === 1 && plan.dirs[0] === '/c/skills/trailhead-work';
127
+ })());
128
+ ok('codexVerbSkillPlan: null verbs yields nothing', codexVerbSkillPlan('/c', null).dirs.length === 0 && codexVerbSkillPlan('/c').dirs.length === 0);
129
+
130
+ // --- codexHookEntries ---
131
+ const entries = codexHookEntries('/h');
132
+ ok('codexHookEntries returns 4 entries', Array.isArray(entries) && entries.length === 4);
133
+ ok('codexHookEntries: commit-guard is PreToolUse/Bash', entries.some((e) =>
134
+ e.event === 'PreToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-commit-guard.js') && e.command.includes('/h')));
135
+ ok('codexHookEntries: secret-guard is PreToolUse/Bash', entries.some((e) =>
136
+ e.event === 'PreToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-secret-guard.js')));
137
+ ok('codexHookEntries: injection-scanner is PostToolUse/Bash', entries.some((e) =>
138
+ e.event === 'PostToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-issue-injection-scanner.js')));
139
+ ok('codexHookEntries: check-update is SessionStart', entries.some((e) =>
140
+ e.event === 'SessionStart' && e.command.includes('trailhead-check-update.js')));
141
+
142
+ // --- enableCodexHooksFeature ---
143
+ ok('enableCodexHooksFeature(empty) appends [features] table with hooks = true', (() => {
144
+ const out = enableCodexHooksFeature('');
145
+ return typeof out === 'string' && out.includes('[features]') && out.includes('hooks = true');
146
+ })());
147
+ ok('enableCodexHooksFeature: already enabled -> null', enableCodexHooksFeature('[features]\nhooks = true\n') === null);
148
+ ok('enableCodexHooksFeature: inserts hooks = true into existing [features]', (() => {
149
+ const out = enableCodexHooksFeature('[features]\nfoo = 1\n');
150
+ return typeof out === 'string' && out.includes('hooks = true') && (out.match(/\[features\]/g) || []).length === 1;
151
+ })());
152
+ ok('enableCodexHooksFeature: flips hooks = false to true', (() => {
153
+ const out = enableCodexHooksFeature('[features]\nhooks = false\n');
154
+ return typeof out === 'string' && out.includes('hooks = true') && !out.includes('hooks = false');
155
+ })());
156
+ ok('enableCodexHooksFeature: appends [features] table when none exists', (() => {
157
+ const out = enableCodexHooksFeature('[other]\nk = 1\n');
158
+ return typeof out === 'string' && out.includes('[other]') && out.includes('[features]') && out.includes('hooks = true');
159
+ })());
160
+ ok('enableCodexHooksFeature: dotted top-level form already true -> null', enableCodexHooksFeature('features.hooks = true\n') === null);
161
+
162
+ // --- codexAgentToml ---
163
+ ok('codexAgentToml with effort includes model + model_reasoning_effort + developer_instructions block', (() => {
164
+ const out = codexAgentToml({
165
+ name: 'trailhead-execute',
166
+ description: 'desc',
167
+ developerInstructions: 'do the thing',
168
+ model: 'gpt-5.6-sol',
169
+ effort: 'high',
170
+ });
171
+ return out.includes('name = "trailhead-execute"') &&
172
+ out.includes('model = "gpt-5.6-sol"') &&
173
+ out.includes('model_reasoning_effort = "high"') &&
174
+ out.includes("developer_instructions = '''") &&
175
+ out.includes('do the thing');
176
+ })());
177
+ ok('codexAgentToml without effort omits model_reasoning_effort', (() => {
178
+ const out = codexAgentToml({
179
+ name: 'trailhead-plan',
180
+ description: 'desc',
181
+ developerInstructions: 'plan the thing',
182
+ model: 'gpt-5.6-terra',
183
+ effort: null,
184
+ });
185
+ return out.includes('model = "gpt-5.6-terra"') && !out.includes('model_reasoning_effort');
186
+ })());
187
+
188
+ // --- codexAgentTomlPlan ---
189
+ ok('codexAgentTomlPlan: string value produces one write with the right path/name/model', (() => {
190
+ const plan = codexAgentTomlPlan('/c', { execute: 'gpt-5.6-terra' });
191
+ return plan.writes.length === 1 &&
192
+ plan.writes[0].path === '/c/agents/trailhead-execute.toml' &&
193
+ plan.writes[0].name === 'trailhead-execute' &&
194
+ plan.writes[0].content.includes('model = "gpt-5.6-terra"');
195
+ })());
196
+ ok('codexAgentTomlPlan: object value includes model + effort', (() => {
197
+ const plan = codexAgentTomlPlan('/c', { execute: { model: 'gpt-5.6-sol', effort: 'high' } });
198
+ const c = plan.writes[0].content;
199
+ return c.includes('model = "gpt-5.6-sol"') && c.includes('model_reasoning_effort = "high"');
200
+ })());
201
+ ok('codexAgentTomlPlan: empty object -> no writes', codexAgentTomlPlan('/c', {}).writes.length === 0);
202
+ ok('codexAgentTomlPlan: null -> no writes', codexAgentTomlPlan('/c', null).writes.length === 0);
203
+ ok('codexAgentTomlPlan: unknown key skipped', codexAgentTomlPlan('/c', { bogus: 'x' }).writes.length === 0);
204
+ ok('codexAgentTomlPlan: object value with no model is skipped', codexAgentTomlPlan('/c', { execute: { effort: 'high' } }).writes.length === 0);
205
+
206
+ // --- enableCodexMultiAgentV2Feature ---
207
+ ok('enableCodexMultiAgentV2Feature: dotted form already true -> null',
208
+ enableCodexMultiAgentV2Feature('features.multi_agent_v2 = true\n') === null);
209
+ ok('enableCodexMultiAgentV2Feature: inserts into existing [features] table', (() => {
210
+ const out = enableCodexMultiAgentV2Feature('[features]\nfoo = 1\n');
211
+ return typeof out === 'string' && out.includes('multi_agent_v2 = true') && (out.match(/\[features\]/g) || []).length === 1;
212
+ })());
213
+ ok('enableCodexMultiAgentV2Feature: appends a table when none exists', (() => {
214
+ const out = enableCodexMultiAgentV2Feature('[other]\nk = 1\n');
215
+ return typeof out === 'string' && out.includes('[other]') && out.includes('[features]') && out.includes('multi_agent_v2 = true');
216
+ })());
217
+ ok('enableCodexMultiAgentV2Feature: foreign explicit value is unsafe',
218
+ (() => {
219
+ const out = enableCodexMultiAgentV2Feature('features.multi_agent_v2 = "x"\n');
220
+ return out && out.unsafe === true;
221
+ })());
222
+
223
+ // --- enableCodexHooksFeature regression (still works after generalisation) ---
224
+ ok('enableCodexHooksFeature regression: still yields hooks = true', (() => {
225
+ const out = enableCodexHooksFeature('[features]\nfoo = 1\n');
226
+ return typeof out === 'string' && out.includes('hooks = true');
227
+ })());
228
+
229
+ // --- injectCodexAdapterHeader (frontmatter stays at the top: #40) ---
230
+ // A realistic converted SKILL.md: YAML frontmatter first, then body.
231
+ const skillMain = '---\nname: trailhead\ndescription: "chart & work a map"\nargument-hint: "[work] [ticket]"\n---\n\nA loose idea has arrived.\n';
232
+ const injected = injectCodexAdapterHeader(skillMain);
233
+ ok('injectCodexAdapterHeader keeps the projected SKILL.md starting with ---', injected.startsWith('---\n'));
234
+ ok('injectCodexAdapterHeader keeps the frontmatter before the adapter header',
235
+ injected.indexOf('name: trailhead') < injected.indexOf('<codex_skill_adapter>'));
236
+ ok('injectCodexAdapterHeader still injects the adapter header', injected.includes('<codex_skill_adapter>'));
237
+ ok('injectCodexAdapterHeader preserves the body', injected.includes('A loose idea has arrived.'));
238
+ ok('injectCodexAdapterHeader places the header after the closing --- of the frontmatter',
239
+ injected.indexOf('<codex_skill_adapter>') > injected.indexOf('\n---\n'));
240
+ // Fallback: no frontmatter -> header still prepended (nothing to protect).
241
+ ok('injectCodexAdapterHeader falls back to prepend when there is no frontmatter', (() => {
242
+ const out = injectCodexAdapterHeader('just a body, no frontmatter\n');
243
+ return out.startsWith('<codex_skill_adapter>') && out.includes('just a body');
244
+ })());
245
+
246
+ // --- codexAgentsYaml ---
247
+ const agentsYaml = codexAgentsYaml();
248
+ ok('codexAgentsYaml disallows implicit invocation', agentsYaml.includes('allow_implicit_invocation: false'));
249
+ ok('codexAgentsYaml mentions $trailhead', agentsYaml.includes('$trailhead'));
250
+ ok('codexAgentsYaml has the display name', agentsYaml.includes('display_name: "Trailhead"'));
251
+
252
+ console.log(`✓ codex-projection: ${passed} assertions passed`);
@@ -0,0 +1,404 @@
1
+ 'use strict';
2
+ // host-descriptor.js: trailhead's host descriptor + runtime detection module.
3
+ //
4
+ // This is the single source of truth for the 3 integration axes trailhead
5
+ // projects onto each supported host (commandSurface, subagentToolkit,
6
+ // hookBus), the frozen per-host descriptors built from them, and the
7
+ // projection/degradation helpers that describe what each host gets (or loses)
8
+ // relative to Claude. It is sized to trailhead's actual footprint: 2 hosts
9
+ // (claude, codex), not a general n-host framework.
10
+ //
11
+ // Two consumers:
12
+ // - install-time: the installer (a separate ticket, #23) reads HOSTS/AXES/
13
+ // configDirFor/hyphenateCommand/emitsAgentToml to lay out commands,
14
+ // prompts, and config for whichever host it's installing into.
15
+ // - run-time: detectHost() lets the engine's inline rules (executed as
16
+ // Claude Agent-tool text, not compiled code) figure out which host they
17
+ // are actually running under, without any installer having run first.
18
+ //
19
+ // This is a scaled-down mirror, in shape only, of GSD's host-integration.cjs
20
+ // (the axes + per-host descriptor table) and host-runtime-detection.cjs (the
21
+ // detection ladder + never-throw contract). GSD's versions are TS-compiled
22
+ // and carry far more hosts, adapters, and history; this file intentionally
23
+ // does not copy that machinery, only the pattern, dimensioned down to what
24
+ // trailhead needs today.
25
+ //
26
+ // Self-contained: no dependencies beyond Node's stdlib. Every exported
27
+ // function is pure. All descriptor data is frozen so callers can't mutate
28
+ // the single source of truth out from under each other.
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const os = require('os');
33
+
34
+ // The Codex support floor (decision #27): multi_agent (the binding constraint)
35
+ // is stable at 0.145.0, so trailhead's single codex descriptor (subagentToolkit
36
+ // 'full') is only projected onto a Codex at or above this version.
37
+ const CODEX_MIN_VERSION = '0.145.0';
38
+
39
+ // --- Axis 1: the 3 closed vocabularies (decision #17) ---------------------
40
+ // Each axis is a closed set of values a host can take. Closed on purpose:
41
+ // a new value here is a design decision (a new ticket), not a typo away.
42
+ const AXES = Object.freeze({
43
+ // How a host surfaces trailhead's commands to the user.
44
+ // 'slash-file' Claude: namespaced slash-command files under commands/trailhead/*.md
45
+ // 'hyphen-prompt' Codex: flat, hyphenated prompt files (trailhead-work.md, etc.)
46
+ // 'skill' Codex: one native skill folder skills/trailhead/, invoked `$trailhead <verb>`
47
+ commandSurface: Object.freeze(['slash-file', 'hyphen-prompt', 'skill']),
48
+ // Whether the host has a dedicated subagent/Task-fanout toolkit.
49
+ // 'full' Claude: the Agent tool; Codex: the native multi_agent tools
50
+ // (spawn_agent/..., on by default at the supported floor)
51
+ // 'none' a host with no such toolkit; everything the engine needs runs inline
52
+ subagentToolkit: Object.freeze(['full', 'none']),
53
+ // Whether the host has a lifecycle hook bus (PreToolUse/PostToolUse/Stop/...).
54
+ // 'host' Claude: native hooks; Codex: the `features.hooks` lifecycle bus
55
+ // 'none' a host with no hook bus (no supported host today, kept for future hosts)
56
+ hookBus: Object.freeze(['host', 'none']),
57
+ });
58
+
59
+ // --- Axis 2: per-host descriptors -------------------------------------------
60
+ // Each descriptor is a frozen data record, not behavior. Anything that reads
61
+ // like "if codex, do X" should be reading one of these fields, not branching
62
+ // on a host-name string sprinkled through the codebase.
63
+ const HOSTS = Object.freeze({
64
+ claude: Object.freeze({
65
+ label: 'Claude Code',
66
+ configDirEnv: 'CLAUDE_CONFIG_DIR',
67
+ configDirDefault: '.claude',
68
+ axes: Object.freeze({
69
+ commandSurface: 'slash-file',
70
+ subagentToolkit: 'full',
71
+ hookBus: 'host',
72
+ }),
73
+ // Structural layout only. The exact target tree (which files land where)
74
+ // is refined by the installer, ticket #23; this just says "namespaced
75
+ // dir of .md files" so the installer has a starting shape to work from.
76
+ commands: Object.freeze({
77
+ layout: 'namespaced-dir',
78
+ dir: 'commands/trailhead',
79
+ ext: '.md',
80
+ }),
81
+ // Explicit data flag, deliberately NOT derived from subagentToolkit:
82
+ // "full toolkit" and "emits an agents.toml" are independent facts. Claude
83
+ // uses its native Agent tool and has no reason to emit one.
84
+ emitsAgentToml: false,
85
+ // Whether this host can run a subagent on a trailhead `models.*` value (a
86
+ // Claude model id). Claude Code's subagents do, so the per-activity model
87
+ // split takes effect here. Like emitsAgentToml, an explicit data flag, not
88
+ // derived from an axis.
89
+ honorsModelKeys: true,
90
+ hooks: Object.freeze({ bus: 'host' }),
91
+ }),
92
+ codex: Object.freeze({
93
+ label: 'Codex',
94
+ configDirEnv: 'CODEX_HOME',
95
+ configDirDefault: '.codex',
96
+ axes: Object.freeze({
97
+ commandSurface: 'skill',
98
+ subagentToolkit: 'full',
99
+ hookBus: 'host',
100
+ }),
101
+ // A single native Codex skill folder, invoked `$trailhead <verb>`.
102
+ commands: Object.freeze({
103
+ layout: 'native-skill',
104
+ dir: 'skills/trailhead',
105
+ }),
106
+ // Also explicit, also independent from the axis: Codex's native multi_agent
107
+ // tools are on by default and need NO manifest for base fan-out, but when
108
+ // models.codex.* is set per technique, the installer projects those pins
109
+ // into per-technique agents/*.toml files under ~/.codex/agents/ and turns
110
+ // on features.multi_agent_v2 so Codex honours them.
111
+ emitsAgentToml: true,
112
+ // Whether Codex can run a subagent on a trailhead `models.*` value: NO. Its
113
+ // subagent model registry is OpenAI-only, so a Claude id (e.g. claude-sonnet-5)
114
+ // cannot run; subagents inherit the one session model. Hence models.* still
115
+ // collapses here even though fan-out is available.
116
+ honorsModelKeys: false,
117
+ // Codex now has a native hook bus (stable at the install floor #27), so
118
+ // the guardrails (secret-guard, commit-guard, etc.) run as real Codex
119
+ // hooks, projected into ~/.codex/hooks.json by the installer. The
120
+ // models.* collapse is the only remaining Codex degradation.
121
+ hooks: Object.freeze({ bus: 'host' }),
122
+ }),
123
+ });
124
+
125
+ // --- Helper: getHost ---------------------------------------------------------
126
+ // Resolve a host name to its descriptor. Throws on an unknown name, naming
127
+ // both the offender and the known hosts, so a typo fails loud at the call
128
+ // site instead of silently returning undefined.
129
+ function getHost(name) {
130
+ // Own-property check, not `HOSTS[name]`: a bare index walks the prototype
131
+ // chain, so inherited keys ('toString', 'constructor', ...) would slip past
132
+ // a truthiness guard and hand back a non-descriptor instead of throwing.
133
+ if (!Object.prototype.hasOwnProperty.call(HOSTS, name)) {
134
+ throw new Error(`host-descriptor: unknown host "${name}" (known hosts: ${Object.keys(HOSTS).join(', ')})`);
135
+ }
136
+ return HOSTS[name];
137
+ }
138
+
139
+ // Normalize a "host" argument that may already be a descriptor (has `axes`)
140
+ // or may be a name string, to a descriptor. Used by the small helpers below
141
+ // that accept either, per the spec.
142
+ function resolveHost(host) {
143
+ return typeof host === 'string' ? getHost(host) : host;
144
+ }
145
+
146
+ // --- Helper: configDirFor ----------------------------------------------------
147
+ // Resolve where a host's config directory lives: an explicit env override
148
+ // first (e.g. CLAUDE_CONFIG_DIR, CODEX_HOME), falling back to the host's
149
+ // default dir under the user's homedir (e.g. ~/.claude, ~/.codex).
150
+ function configDirFor(host, opts = {}) {
151
+ const descriptor = resolveHost(host);
152
+ const env = opts.env || process.env;
153
+ const homedir = opts.homedir || os.homedir();
154
+ const override = env[descriptor.configDirEnv];
155
+ if (typeof override === 'string' && override.trim() !== '') {
156
+ return override;
157
+ }
158
+ return path.join(homedir, descriptor.configDirDefault);
159
+ }
160
+
161
+ // --- Helper: hyphenateCommand -------------------------------------------------
162
+ // Normalize a command name to Codex's flat hyphen-prompt surface: strip a
163
+ // leading slash, replace ':' namespace separators with '-'.
164
+ // '/trailhead:work' -> 'trailhead-work'
165
+ // 'trailhead:work' -> 'trailhead-work'
166
+ // '/trailhead' -> 'trailhead'
167
+ function hyphenateCommand(name) {
168
+ return String(name).trim().replace(/^\//, '').replace(/:/g, '-');
169
+ }
170
+
171
+ // --- Helper: emitsAgentToml ---------------------------------------------------
172
+ function emitsAgentToml(host) {
173
+ return resolveHost(host).emitsAgentToml;
174
+ }
175
+
176
+ // --- Helper: degradations -----------------------------------------------------
177
+ // Plain data-table lookup of what degrades on a given host relative to
178
+ // Claude (the reference host with the full toolkit and a native hook bus).
179
+ // Deliberately NOT clever: no inference engine, just entries keyed off the
180
+ // axes that produced them, so the list stays legible and auditable.
181
+ function degradations(host) {
182
+ const descriptor = resolveHost(host);
183
+ const entries = [];
184
+
185
+ if (descriptor.axes.subagentToolkit === 'none') {
186
+ entries.push({
187
+ from: 'subagent fan-out (research, codebase-map, review, plan, execute)',
188
+ to: 'inline sequential execution in the main session',
189
+ });
190
+ }
191
+
192
+ // Independent of fan-out: a host whose subagents can't run a trailhead
193
+ // models.* value (a Claude id) loses the per-activity model split even when
194
+ // fan-out itself is available (e.g. Codex, OpenAI-only subagent registry).
195
+ if (!descriptor.honorsModelKeys) {
196
+ entries.push({
197
+ from: 'per-activity model split (models.*)',
198
+ to: 'collapses to a single session model (recover the split with models.codex.*)',
199
+ });
200
+ }
201
+
202
+ if (descriptor.axes.hookBus === 'none') {
203
+ entries.push({
204
+ from: 'lifecycle hooks (secret-guard, commit-guard, injection-scanner, check-update)',
205
+ to: 'a git commit-msg hook plus inline prose guardrails (decision #16)',
206
+ });
207
+ }
208
+
209
+ return entries;
210
+ }
211
+
212
+ // --- Helper: modelsCollapseNotice --------------------------------------------
213
+ // The one-time notice the engine surfaces at session start when models.* is
214
+ // configured on a host that cannot honour it (honorsModelKeys === false): its
215
+ // subagents run on the host's own model, so the per-activity split collapses to
216
+ // the single session model (see degradations()), UNLESS the host has a parallel
217
+ // native namespace configured. On Codex that namespace is models.codex.* (OpenAI
218
+ // model ids): once any models.codex.* key is set, the notice stays silent (the
219
+ // user has already opted into the honoured namespace there) and only reworded
220
+ // to point at models.codex.* while a stray Claude-id models.* key is set and
221
+ // models.codex.* is still empty. Keyed on honorsModelKeys, NOT on
222
+ // subagentToolkit: a host can have fan-out yet still be unable to run a
223
+ // subagent on a Claude models.* id (Codex). Pure: returns the notice string, or
224
+ // null when there is nothing to say (the host honours the keys, no models.* is
225
+ // set, or models.codex.* is already set). The caller owns the "one-time"
226
+ // bookkeeping (a marker file) and how the string is shown, so this stays pure
227
+ // and trivially testable.
228
+ function modelsCollapseNotice(host, config) {
229
+ const descriptor = resolveHost(host);
230
+ if (descriptor.honorsModelKeys) return null;
231
+ const models = config && typeof config === 'object' ? config.models : null;
232
+ if (!models || typeof models !== 'object') return null;
233
+
234
+ const codexModels = models.codex && typeof models.codex === 'object' ? models.codex : null;
235
+ const codexSet = codexModels
236
+ ? Object.keys(codexModels).filter(
237
+ (k) => codexModels[k] != null && String(codexModels[k]).trim() !== ''
238
+ )
239
+ : [];
240
+ if (codexSet.length > 0) return null;
241
+
242
+ // Exclude the literal 'codex' key: models.codex is a nested object, and
243
+ // String({}).trim() is the non-empty "[object Object]", so without this
244
+ // exclusion a lone models.codex.* namespace (with no Claude ids set) would
245
+ // mis-count as a set Claude key and mis-fire the notice below.
246
+ const set = Object.keys(models).filter(
247
+ (k) => k !== 'codex' && models[k] != null && String(models[k]).trim() !== ''
248
+ );
249
+ if (set.length === 0) return null;
250
+
251
+ return (
252
+ `Heads up: trailhead is running on ${descriptor.label}, whose subagents ` +
253
+ `run on its own OpenAI models. Your models.* setting (${set.join(', ')}) is a ` +
254
+ `Claude model id and cannot take effect here: every activity's subagent still ` +
255
+ `inherits the one session model. Set models.codex.* (OpenAI model ids) instead ` +
256
+ `to get the per-activity split on ${descriptor.label}. The models.* keys stay ` +
257
+ `in .trailhead/config.json and apply again on Claude Code.`
258
+ );
259
+ }
260
+
261
+ // --- Helper: updateNotice -----------------------------------------------------
262
+ // The one-line update notice the engine surfaces at session start on a host
263
+ // WITHOUT a hook bus (no SessionStart check-update hook, no statusline flag):
264
+ // there the engine runs the throttled check inline and shows this. Pure: given
265
+ // the host and a parsed update-check cache object, returns the notice string,
266
+ // or null when nothing should be shown (the host has a hook bus, or no update
267
+ // is available). Keying on hookBus === 'none' means it never fires on a host
268
+ // that already notifies via the hook + statusline (no double-notify). The
269
+ // caller owns the cache I/O and throttling, mirroring modelsCollapseNotice.
270
+ function updateNotice(host, cache) {
271
+ const descriptor = resolveHost(host);
272
+ if (descriptor.axes.hookBus !== 'none') return null;
273
+ if (!cache || typeof cache !== 'object' || !cache.updateAvailable) return null;
274
+ const latest = String(cache.latest || '').trim();
275
+ const installed = String(cache.installed || '').trim();
276
+ const ver = latest ? ` ${latest}` : '';
277
+ const from = installed ? ` (you have ${installed})` : '';
278
+ return `Heads up: trailhead${ver} is available${from}. Run /trailhead:update to install it.`;
279
+ }
280
+
281
+ // --- Codex version floor gate (decision #27) --------------------------------
282
+ // The install-time enforcement of CODEX_MIN_VERSION. Pure and never-throwing:
283
+ // the installer runs `codex --version` and hands the raw output here; this
284
+ // decides whether to project. Below the floor the multi_agent tools the
285
+ // adapter's §D relies on are not stable, so the installer refuses (fail below
286
+ // floor). An undeterminable version (codex absent, or unparseable output)
287
+ // fails OPEN with a warning: a gate can't prove a violation it can't read, and
288
+ // a false block would wrongly stop a valid install.
289
+
290
+ // Extract a dotted-numeric [major, minor, patch] from any version string
291
+ // ("codex-cli 0.149.0", "0.145.0-alpha.1", "0.149.0"), or null when absent.
292
+ function parseVersion(str) {
293
+ const m = String(str == null ? '' : str).match(/(\d+)\.(\d+)\.(\d+)/);
294
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
295
+ }
296
+
297
+ // -1 / 0 / 1 comparison of two [maj,min,patch] triples.
298
+ function compareVersion(a, b) {
299
+ for (let i = 0; i < 3; i++) {
300
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
301
+ }
302
+ return 0;
303
+ }
304
+
305
+ // Verdict the installer acts on. Given the raw `codex --version` output (or
306
+ // null/'' when the probe failed) and the floor, returns { ok, proceed,
307
+ // undetermined, version, floor, message }. Never throws.
308
+ // at/above floor -> { ok:true, proceed:true, undetermined:false, message:null }
309
+ // parseable & below -> { ok:false, proceed:false, undetermined:false, message:<refuse> }
310
+ // unparseable/absent -> { ok:true, proceed:true, undetermined:true, message:<warn> }
311
+ function codexVersionGate(versionOutput, floor = CODEX_MIN_VERSION) {
312
+ const got = parseVersion(versionOutput);
313
+ if (!got) {
314
+ return {
315
+ ok: true, proceed: true, undetermined: true, version: null, floor,
316
+ message: `Could not determine the Codex version (\`codex --version\` unavailable or unrecognised). trailhead needs Codex >= ${floor} for its multi_agent fan-out; proceeding, but the subagent features will not work on an older Codex.`,
317
+ };
318
+ }
319
+ // A caller-supplied floor that can't be parsed can't gate anything: treat it
320
+ // as no constraint (proceed) rather than dereferencing null in compareVersion,
321
+ // honouring the "never throws" contract. The default floor is always parseable.
322
+ const floorV = parseVersion(floor);
323
+ if (floorV && compareVersion(got, floorV) < 0) {
324
+ return {
325
+ ok: false, proceed: false, undetermined: false, version: got.join('.'), floor,
326
+ message: `Codex ${got.join('.')} is below trailhead's floor of ${floor}: its multi_agent subagent tools are not stable, so trailhead will not install. Upgrade Codex to >= ${floor} and re-run.`,
327
+ };
328
+ }
329
+ return { ok: true, proceed: true, undetermined: false, version: got.join('.'), floor, message: null };
330
+ }
331
+
332
+ // --- Runtime host detection --------------------------------------------------
333
+ // Mirrors GSD's host-runtime-detection.cjs in shape: a short signal ladder,
334
+ // wrapped so it NEVER throws. This is what lets the engine's inline rules
335
+ // (plain text executed by the Claude Agent tool, not compiled code) figure
336
+ // out which host they're actually running under at runtime, independent of
337
+ // whatever the installer laid down at install-time.
338
+
339
+ // Codex sandbox env vars set by the shell tool / Seatbelt child-process spawn.
340
+ // Best-effort: absent under sandbox_mode = "danger-full-access", so this
341
+ // signal degrades to the next rung when unset rather than being load-bearing.
342
+ const CODEX_SESSION_ENV_SIGNALS = Object.freeze(['CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED']);
343
+ const CODEX_HOME_ENV = 'CODEX_HOME';
344
+ const CODEX_CONFIG_MARKER = 'config.toml';
345
+
346
+ // detectHost never throws: every read here (including `deps.env` itself,
347
+ // which a caller could hand in as a throwing Proxy) is wrapped in a single
348
+ // guarded region that degrades to 'claude' on any unexpected error. A
349
+ // detection helper that can throw is worse than useless in an inline rule
350
+ // that has no try/catch of its own around it: the whole point is that
351
+ // callers can invoke this without defensive code of their own.
352
+ function detectHost(deps = {}) {
353
+ try {
354
+ const env = deps.env || process.env;
355
+ const fileExists = deps.fileExists || ((p) => fs.existsSync(p));
356
+
357
+ for (const key of CODEX_SESSION_ENV_SIGNALS) {
358
+ const value = env[key];
359
+ if (typeof value === 'string' && value.trim() !== '') {
360
+ return 'codex';
361
+ }
362
+ }
363
+
364
+ const codexHome = env[CODEX_HOME_ENV];
365
+ if (typeof codexHome === 'string' && codexHome.trim() !== '') {
366
+ try {
367
+ if (fileExists(path.join(codexHome, CODEX_CONFIG_MARKER))) {
368
+ return 'codex';
369
+ }
370
+ } catch {
371
+ // Swallow probe failures (EACCES etc.) and fall through to claude.
372
+ }
373
+ }
374
+
375
+ // The DEFAULT ~/.codex/config.toml is deliberately NEVER probed here.
376
+ // Every machine that has ever run Codex has that file lying around, so
377
+ // checking it unconditionally would misreport plain Claude Code sessions
378
+ // as codex just because Codex was installed at some point. Only an
379
+ // EXPLICITLY exported CODEX_HOME counts: that's the current session
380
+ // designating a Codex root, not ambient machine history.
381
+ return 'claude';
382
+ } catch {
383
+ return 'claude';
384
+ }
385
+ }
386
+
387
+ module.exports = {
388
+ AXES,
389
+ HOSTS,
390
+ getHost,
391
+ configDirFor,
392
+ hyphenateCommand,
393
+ emitsAgentToml,
394
+ degradations,
395
+ modelsCollapseNotice,
396
+ updateNotice,
397
+ codexVersionGate,
398
+ parseVersion,
399
+ detectHost,
400
+ CODEX_MIN_VERSION,
401
+ CODEX_SESSION_ENV_SIGNALS,
402
+ CODEX_HOME_ENV,
403
+ CODEX_CONFIG_MARKER,
404
+ };