@marcomigozzi/trailhead 0.3.0 → 0.5.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.
- package/README.it.md +359 -0
- package/README.md +35 -22
- package/bin/lib/codex-projection.js +421 -0
- package/bin/lib/codex-projection.test.js +266 -0
- package/bin/lib/host-descriptor.js +404 -0
- package/bin/lib/host-descriptor.test.js +172 -0
- package/bin/trailhead.js +518 -67
- package/bin/trailhead.test.js +399 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +8 -3
- package/plugins/trailhead/commands/adopt.md +1 -1
- package/plugins/trailhead/commands/bug.md +1 -1
- package/plugins/trailhead/commands/config.md +1 -1
- package/plugins/trailhead/commands/dashboard.md +1 -1
- package/plugins/trailhead/commands/grill.md +1 -1
- package/plugins/trailhead/commands/idea.md +1 -1
- package/plugins/trailhead/commands/inbox.md +1 -1
- package/plugins/trailhead/commands/map.md +1 -1
- package/plugins/trailhead/commands/new.md +1 -1
- package/plugins/trailhead/commands/note.md +1 -1
- package/plugins/trailhead/commands/pause.md +1 -1
- package/plugins/trailhead/commands/quick.md +3 -1
- package/plugins/trailhead/commands/resume.md +1 -1
- package/plugins/trailhead/commands/seed.md +1 -1
- package/plugins/trailhead/commands/split.md +1 -1
- package/plugins/trailhead/commands/ticket.md +1 -1
- package/plugins/trailhead/commands/todo.md +1 -1
- package/plugins/trailhead/commands/update.md +1 -1
- package/plugins/trailhead/commands/whiteboard.md +1 -1
- package/plugins/trailhead/commands/work.md +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.js +74 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +83 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +54 -24
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +7 -10
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +19 -4
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +41 -1
- package/plugins/trailhead/skills/{trailhead/references/configuration.md → _shared/configuration-reference.md} +15 -3
- package/plugins/trailhead/skills/_shared/configuration.md +8 -0
- package/plugins/trailhead/skills/_shared/multi-map.md +27 -0
- package/plugins/trailhead/skills/_shared/out-of-scope.md +11 -0
- package/plugins/trailhead/skills/_shared/principles.md +8 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +22 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +159 -0
- package/plugins/trailhead/skills/_shared/substrate.md +35 -0
- package/plugins/trailhead/skills/{trailhead/references → _shared}/teamwork.md +8 -5
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/acceptance-testing.md +3 -2
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/code-review.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +21 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +36 -0
- package/plugins/trailhead/skills/_shared/techniques.md +20 -0
- package/plugins/trailhead/skills/_shared/ticket-anatomy.md +32 -0
- package/plugins/trailhead/skills/_shared/ticket-language.md +3 -0
- package/plugins/trailhead/skills/trailhead/SKILL.md +25 -450
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +28 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-capture}/references/capture.md +5 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +51 -0
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +24 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-chart}/references/inbox.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +29 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +112 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +65 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +52 -0
- package/plugins/trailhead/templates/statusline-trailhead.sh +40 -8
- package/plugins/trailhead/templates/trailhead-commit-msg +57 -0
- package/plugins/trailhead/skills/trailhead/references/techniques/plan-review.md +0 -24
- package/plugins/trailhead/skills/trailhead/references/techniques/prototype.md +0 -18
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/codebase-map.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/debug.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/domain-vocabulary.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/grilling.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/research.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/tdd.md +0 -0
- /package/plugins/trailhead/skills/{trailhead → trailhead-manage}/references/updating.md +0 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for host-descriptor.js. Run: node host-descriptor.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of trailhead-secret-guard.test.js.
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const {
|
|
6
|
+
AXES,
|
|
7
|
+
HOSTS,
|
|
8
|
+
getHost,
|
|
9
|
+
configDirFor,
|
|
10
|
+
hyphenateCommand,
|
|
11
|
+
emitsAgentToml,
|
|
12
|
+
degradations,
|
|
13
|
+
modelsCollapseNotice,
|
|
14
|
+
updateNotice,
|
|
15
|
+
codexVersionGate,
|
|
16
|
+
parseVersion,
|
|
17
|
+
CODEX_MIN_VERSION,
|
|
18
|
+
detectHost,
|
|
19
|
+
} = require('./host-descriptor.js');
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
// --- AXES ---
|
|
25
|
+
ok('AXES is frozen', Object.isFrozen(AXES));
|
|
26
|
+
ok('AXES.commandSurface is frozen and correct', Object.isFrozen(AXES.commandSurface) &&
|
|
27
|
+
AXES.commandSurface.length === 3 &&
|
|
28
|
+
AXES.commandSurface.includes('slash-file') && AXES.commandSurface.includes('hyphen-prompt') &&
|
|
29
|
+
AXES.commandSurface.includes('skill'));
|
|
30
|
+
ok('AXES.subagentToolkit is frozen and correct', Object.isFrozen(AXES.subagentToolkit) &&
|
|
31
|
+
AXES.subagentToolkit.length === 2 &&
|
|
32
|
+
AXES.subagentToolkit.includes('full') && AXES.subagentToolkit.includes('none'));
|
|
33
|
+
ok('AXES.hookBus is frozen and correct', Object.isFrozen(AXES.hookBus) &&
|
|
34
|
+
AXES.hookBus.length === 2 &&
|
|
35
|
+
AXES.hookBus.includes('host') && AXES.hookBus.includes('none'));
|
|
36
|
+
|
|
37
|
+
// --- getHost ---
|
|
38
|
+
const claude = getHost('claude');
|
|
39
|
+
const codex = getHost('codex');
|
|
40
|
+
ok('getHost(claude) has correct axes', claude.axes.commandSurface === 'slash-file' &&
|
|
41
|
+
claude.axes.subagentToolkit === 'full' && claude.axes.hookBus === 'host');
|
|
42
|
+
ok('getHost(codex) has correct axes', codex.axes.commandSurface === 'skill' &&
|
|
43
|
+
codex.axes.subagentToolkit === 'full' && codex.axes.hookBus === 'host');
|
|
44
|
+
ok('getHost(codex).hooks.bus === "host"', codex.hooks.bus === 'host');
|
|
45
|
+
assert.throws(() => getHost('gemini'), /gemini/i, 'getHost(gemini) throws naming the unknown host');
|
|
46
|
+
ok('getHost(gemini) throws', (() => {
|
|
47
|
+
try { getHost('gemini'); return false; } catch { return true; }
|
|
48
|
+
})());
|
|
49
|
+
// Inherited prototype keys are not hosts: getHost must throw, not hand back an
|
|
50
|
+
// Object.prototype member (own-property check, not a bare index truthiness test).
|
|
51
|
+
ok('getHost(toString) throws (no prototype-chain leak)', (() => {
|
|
52
|
+
try { getHost('toString'); return false; } catch { return true; }
|
|
53
|
+
})());
|
|
54
|
+
ok('getHost(constructor) throws (no prototype-chain leak)', (() => {
|
|
55
|
+
try { getHost('constructor'); return false; } catch { return true; }
|
|
56
|
+
})());
|
|
57
|
+
|
|
58
|
+
// --- frozen descriptors ---
|
|
59
|
+
ok('getHost(codex) descriptor is frozen', Object.isFrozen(codex));
|
|
60
|
+
ok('getHost(claude) descriptor is frozen', Object.isFrozen(claude));
|
|
61
|
+
ok('HOSTS is frozen', Object.isFrozen(HOSTS));
|
|
62
|
+
|
|
63
|
+
// --- emitsAgentToml ---
|
|
64
|
+
ok('emitsAgentToml(claude) === false', emitsAgentToml('claude') === false);
|
|
65
|
+
ok('emitsAgentToml(codex) === true', emitsAgentToml('codex') === true);
|
|
66
|
+
|
|
67
|
+
// --- honorsModelKeys ---
|
|
68
|
+
ok('claude honours models.* keys', claude.honorsModelKeys === true);
|
|
69
|
+
ok('codex does NOT honour models.* keys (OpenAI-only subagent registry)', codex.honorsModelKeys === false);
|
|
70
|
+
|
|
71
|
+
// --- hyphenateCommand ---
|
|
72
|
+
ok('hyphenateCommand strips leading slash and replaces colon', hyphenateCommand('/trailhead:work') === 'trailhead-work');
|
|
73
|
+
ok('hyphenateCommand handles no leading slash', hyphenateCommand('trailhead:work') === 'trailhead-work');
|
|
74
|
+
ok('hyphenateCommand handles no colon', hyphenateCommand('/trailhead') === 'trailhead');
|
|
75
|
+
|
|
76
|
+
// --- configDirFor ---
|
|
77
|
+
ok('configDirFor(claude) uses env override', configDirFor('claude', { env: { CLAUDE_CONFIG_DIR: '/x/y' }, homedir: '/home/u' }) === '/x/y');
|
|
78
|
+
ok('configDirFor(claude) falls back to homedir default', configDirFor('claude', { env: {}, homedir: '/home/u' }).endsWith('/home/u/.claude') ||
|
|
79
|
+
configDirFor('claude', { env: {}, homedir: '/home/u' }) === require('path').join('/home/u', '.claude'));
|
|
80
|
+
ok('configDirFor(codex) falls back to homedir default', configDirFor('codex', { env: {}, homedir: '/home/u' }) === require('path').join('/home/u', '.codex'));
|
|
81
|
+
|
|
82
|
+
// --- degradations ---
|
|
83
|
+
ok('degradations(claude) is empty', Array.isArray(degradations('claude')) && degradations('claude').length === 0);
|
|
84
|
+
ok('degradations(codex) is non-empty', Array.isArray(degradations('codex')) && degradations('codex').length > 0);
|
|
85
|
+
ok('degradations(codex) no longer lists subagent fan-out (it has multi_agent)',
|
|
86
|
+
!degradations('codex').some((d) => /fan-out/.test(d.from)));
|
|
87
|
+
ok('degradations(codex) still lists the per-activity model split collapse',
|
|
88
|
+
degradations('codex').some((d) => /model split/.test(d.from)));
|
|
89
|
+
ok('degradations(codex) model-split entry points at models.codex.* recovery',
|
|
90
|
+
degradations('codex').some((d) => /model split/.test(d.from) && /models\.codex/.test(d.to)));
|
|
91
|
+
ok('degradations(codex) no longer lists lifecycle hooks (it has a native hook bus)',
|
|
92
|
+
!degradations('codex').some((d) => /lifecycle hooks/.test(d.from)));
|
|
93
|
+
|
|
94
|
+
// --- modelsCollapseNotice ---
|
|
95
|
+
ok('modelsCollapseNotice(claude, models set) is null (has toolkit)',
|
|
96
|
+
modelsCollapseNotice('claude', { models: { execute: 'claude-sonnet-5' } }) === null);
|
|
97
|
+
ok('modelsCollapseNotice(codex, no models) is null',
|
|
98
|
+
modelsCollapseNotice('codex', {}) === null);
|
|
99
|
+
ok('modelsCollapseNotice(codex, empty models) is null',
|
|
100
|
+
modelsCollapseNotice('codex', { models: {} }) === null);
|
|
101
|
+
ok('modelsCollapseNotice(codex, nullish/empty values) is null',
|
|
102
|
+
modelsCollapseNotice('codex', { models: { plan: '', execute: null } }) === null);
|
|
103
|
+
ok('modelsCollapseNotice(codex, models set) names the set keys', (() => {
|
|
104
|
+
const msg = modelsCollapseNotice('codex', { models: { plan: 'x', execute: 'y' } });
|
|
105
|
+
return typeof msg === 'string' && msg.includes('plan') && msg.includes('execute') && msg.includes('Codex');
|
|
106
|
+
})());
|
|
107
|
+
ok('modelsCollapseNotice(codex, undefined config) is null',
|
|
108
|
+
modelsCollapseNotice('codex') === null);
|
|
109
|
+
ok('modelsCollapseNotice(claude, models set) is null (Claude honours the keys)',
|
|
110
|
+
modelsCollapseNotice('claude', { models: { plan: 'claude-opus-5' } }) === null);
|
|
111
|
+
ok('modelsCollapseNotice(codex, only Claude models.* set) mentions models.codex', (() => {
|
|
112
|
+
const msg = modelsCollapseNotice('codex', { models: { plan: 'x', execute: 'y' } });
|
|
113
|
+
return typeof msg === 'string' && msg.includes('models.codex');
|
|
114
|
+
})());
|
|
115
|
+
ok('modelsCollapseNotice is null once models.codex.* is set even if Claude models.* also set',
|
|
116
|
+
modelsCollapseNotice('codex', { models: { plan: 'claude-opus-5', codex: { plan: 'gpt-5.6-sol' } } }) === null);
|
|
117
|
+
ok('modelsCollapseNotice: lone codex namespace, no Claude id, is null (k !== "codex" guard)',
|
|
118
|
+
modelsCollapseNotice('codex', { models: { codex: { execute: 'gpt-5.6-terra' } } }) === null);
|
|
119
|
+
// Exercises the k !== 'codex' guard directly: a lone codex namespace whose
|
|
120
|
+
// values are all blank leaves codexSet empty, so control reaches the Claude-key
|
|
121
|
+
// filter, where the bare `codex` object key must NOT be counted (String({}) is
|
|
122
|
+
// non-empty). Without the guard this returns a spurious notice instead of null.
|
|
123
|
+
ok('modelsCollapseNotice: lone all-blank codex namespace, no Claude id, is null (fails without the k !== "codex" guard)',
|
|
124
|
+
modelsCollapseNotice('codex', { models: { codex: { plan: '' } } }) === null);
|
|
125
|
+
ok('modelsCollapseNotice: codex namespace with only empty/nullish values does not silence a real Claude-id notice', (() => {
|
|
126
|
+
const msg = modelsCollapseNotice('codex', { models: { plan: 'claude-opus-5', codex: { plan: '' } } });
|
|
127
|
+
return typeof msg === 'string' && msg.includes('plan') && !msg.includes('codex)');
|
|
128
|
+
})());
|
|
129
|
+
|
|
130
|
+
// --- updateNotice ---
|
|
131
|
+
// Both hosts now have a hook bus (hookBus !== 'none'), so updateNotice never
|
|
132
|
+
// fires for either: the SessionStart hook + (on Codex) its stdout
|
|
133
|
+
// additionalContext handle the notification instead.
|
|
134
|
+
ok('updateNotice(codex, update available) is null (codex has a hook bus now)',
|
|
135
|
+
updateNotice('codex', { updateAvailable: true, latest: '9.9.9', installed: '0.0.1' }) === null);
|
|
136
|
+
ok('updateNotice(codex, no update available) is null',
|
|
137
|
+
updateNotice('codex', { updateAvailable: false }) === null);
|
|
138
|
+
ok('updateNotice(codex, null cache) is null',
|
|
139
|
+
updateNotice('codex', null) === null);
|
|
140
|
+
ok('updateNotice(claude, update available) is null (claude has a hook bus)',
|
|
141
|
+
updateNotice('claude', { updateAvailable: true, latest: '0.3.0' }) === null);
|
|
142
|
+
|
|
143
|
+
// --- detectHost ---
|
|
144
|
+
ok('detectHost: CODEX_SANDBOX signals codex', detectHost({ env: { CODEX_SANDBOX: 'seatbelt' } }) === 'codex');
|
|
145
|
+
ok('detectHost: CODEX_SANDBOX_NETWORK_DISABLED signals codex', detectHost({ env: { CODEX_SANDBOX_NETWORK_DISABLED: '1' } }) === 'codex');
|
|
146
|
+
ok('detectHost: CODEX_HOME with marker file signals codex', detectHost({
|
|
147
|
+
env: { CODEX_HOME: '/c' },
|
|
148
|
+
fileExists: (p) => p === '/c/config.toml',
|
|
149
|
+
}) === 'codex');
|
|
150
|
+
ok('detectHost: CODEX_HOME without marker file falls back to claude', detectHost({
|
|
151
|
+
env: { CODEX_HOME: '/c' },
|
|
152
|
+
fileExists: () => false,
|
|
153
|
+
}) === 'claude');
|
|
154
|
+
ok('detectHost: no signals falls back to claude', detectHost({ env: {} }) === 'claude');
|
|
155
|
+
ok('detectHost: never throws, degrades to claude', detectHost({
|
|
156
|
+
env: new Proxy({}, { get() { throw new Error('boom'); } }),
|
|
157
|
+
}) === 'claude');
|
|
158
|
+
|
|
159
|
+
// --- codexVersionGate (install-time floor, #27/#28) ---
|
|
160
|
+
ok('CODEX_MIN_VERSION is 0.145.0', CODEX_MIN_VERSION === '0.145.0');
|
|
161
|
+
ok('parseVersion extracts a dotted triple', (() => { const v = parseVersion('codex-cli 0.149.0-alpha'); return v[0] === 0 && v[1] === 149 && v[2] === 0; })());
|
|
162
|
+
ok('parseVersion returns null when absent', parseVersion('no version here') === null);
|
|
163
|
+
ok('codexVersionGate at the floor proceeds', codexVersionGate('0.145.0').ok === true && codexVersionGate('codex-cli 0.145.0').proceed === true);
|
|
164
|
+
ok('codexVersionGate above the floor proceeds cleanly', (() => { const g = codexVersionGate('0.149.0'); return g.ok === true && g.proceed === true && g.undetermined === false; })());
|
|
165
|
+
ok('codexVersionGate below the floor (patch) refuses', (() => { const g = codexVersionGate('0.144.1'); return g.ok === false && g.proceed === false && /below/.test(g.message); })());
|
|
166
|
+
ok('codexVersionGate well below the floor (minor) refuses', codexVersionGate('0.120.0').proceed === false);
|
|
167
|
+
ok('codexVersionGate unparseable is undetermined but proceeds (fail-open)', (() => { const g = codexVersionGate('weird output'); return g.proceed === true && g.undetermined === true && typeof g.message === 'string'; })());
|
|
168
|
+
ok('codexVersionGate null (codex absent) proceeds with a warning', (() => { const g = codexVersionGate(null); return g.proceed === true && g.undetermined === true && typeof g.message === 'string'; })());
|
|
169
|
+
ok('codexVersionGate honours an explicit higher floor', codexVersionGate('0.150.0', '0.200.0').proceed === false);
|
|
170
|
+
ok('codexVersionGate never throws on an unparseable floor (proceeds)', (() => { const g = codexVersionGate('0.149.0', 'not-a-version'); return g.proceed === true; })());
|
|
171
|
+
|
|
172
|
+
console.log(`✓ host-descriptor: ${passed} assertions passed`);
|