@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.
- package/README.it.md +354 -0
- package/README.md +18 -10
- package/bin/lib/codex-projection.js +400 -0
- package/bin/lib/codex-projection.test.js +252 -0
- package/bin/lib/host-descriptor.js +404 -0
- package/bin/lib/host-descriptor.test.js +172 -0
- package/bin/trailhead.js +454 -66
- package/bin/trailhead.test.js +274 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +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/skills/trailhead/SKILL.md +13 -11
- package/plugins/trailhead/skills/trailhead/references/capture.md +3 -1
- package/plugins/trailhead/skills/trailhead/references/configuration.md +7 -1
- package/plugins/trailhead/skills/trailhead/references/teamwork.md +3 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/prototype.md +1 -1
- package/plugins/trailhead/templates/statusline-trailhead.sh +40 -8
- package/plugins/trailhead/templates/trailhead-commit-msg +57 -0
|
@@ -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`);
|