@marcomigozzi/trailhead 0.2.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 +36 -12
- 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/commands/adopt.md +1 -1
- package/plugins/trailhead/commands/dashboard.md +6 -0
- package/plugins/trailhead/commands/quick.md +6 -0
- package/plugins/trailhead/commands/seed.md +1 -1
- package/plugins/trailhead/commands/todo.md +1 -1
- package/plugins/trailhead/commands/whiteboard.md +6 -0
- 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 +104 -30
- package/plugins/trailhead/skills/trailhead/references/capture.md +6 -0
- 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/code-review.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/trailhead/references/techniques/debug.md +2 -0
- 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/skills/trailhead/references/techniques/research.md +1 -1
- package/plugins/trailhead/templates/statusline-trailhead.sh +40 -8
- package/plugins/trailhead/templates/trailhead-commit-msg +57 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Integration tests for trailhead.js (the installer). Run: node trailhead.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of host-descriptor.test.js.
|
|
4
|
+
// Drives the real CLI via child_process against fresh temp dirs, passing an
|
|
5
|
+
// explicit --codex/--claude flag (or a controlled $PATH for the auto-detect
|
|
6
|
+
// cases) plus --dir= so no TTY prompt can fire.
|
|
7
|
+
//
|
|
8
|
+
// Since #25, BOTH codex and claude installs produce <dir>/skills/trailhead/
|
|
9
|
+
// SKILL.md, so that path alone no longer tells them apart. Distinguish by:
|
|
10
|
+
// codex has NO commands/ dir and NO settings.json (still Claude-only), but
|
|
11
|
+
// (since #29) DOES have hooks.json + skills/trailhead/hooks/ + config.toml,
|
|
12
|
+
// and its SKILL.md STARTS WITH <codex_skill_adapter> plus a
|
|
13
|
+
// skills/trailhead/agents/openai.yaml; claude HAS commands/trailhead/,
|
|
14
|
+
// settings.json, hooks/, and its SKILL.md does NOT start with the adapter
|
|
15
|
+
// header.
|
|
16
|
+
const assert = require('assert');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
24
|
+
|
|
25
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
26
|
+
const installerPath = path.join(repoRoot, 'bin', 'trailhead.js');
|
|
27
|
+
const tmpDirs = [];
|
|
28
|
+
|
|
29
|
+
function mktmp() {
|
|
30
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'th-'));
|
|
31
|
+
tmpDirs.push(d);
|
|
32
|
+
return d;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// process.execPath (not the bare 'node') so a test can hand a stripped-down
|
|
36
|
+
// $PATH to exercise the auto-detect branch without losing the node binary.
|
|
37
|
+
function runInstaller(extraArgs, opts = {}) {
|
|
38
|
+
const env = opts.env ? { ...process.env, ...opts.env } : process.env;
|
|
39
|
+
const cwd = opts.cwd || repoRoot;
|
|
40
|
+
return execFileSync(process.execPath, [installerPath, ...extraArgs], { cwd, stdio: 'pipe', env });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A dir holding an executable stub for each named host CLI, so detectInstalledHosts
|
|
44
|
+
// finds them when this dir is the whole $PATH.
|
|
45
|
+
function fakeBinDir(names) {
|
|
46
|
+
const dir = mktmp();
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
const p = path.join(dir, name);
|
|
49
|
+
fs.writeFileSync(p, '#!/bin/sh\n');
|
|
50
|
+
fs.chmodSync(p, 0o755);
|
|
51
|
+
}
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- codex install -----------------------------------------------------------
|
|
56
|
+
const codexDir = mktmp();
|
|
57
|
+
runInstaller([`--codex`, `--dir=${codexDir}`]);
|
|
58
|
+
|
|
59
|
+
const skillMainPath = path.join(codexDir, 'skills', 'trailhead', 'SKILL.md');
|
|
60
|
+
ok('codex: SKILL.md exists', fs.existsSync(skillMainPath));
|
|
61
|
+
const skillMainContent = fs.readFileSync(skillMainPath, 'utf8');
|
|
62
|
+
// #40: the YAML frontmatter must stay at the very top (Codex only registers a
|
|
63
|
+
// skill when `name:`/`description:` are the first bytes); the adapter header is
|
|
64
|
+
// injected AFTER the frontmatter, not above it.
|
|
65
|
+
ok('codex: SKILL.md starts with the YAML frontmatter', skillMainContent.startsWith('---\n'));
|
|
66
|
+
ok('codex: SKILL.md frontmatter precedes the adapter header',
|
|
67
|
+
skillMainContent.indexOf('name: trailhead') < skillMainContent.indexOf('<codex_skill_adapter>'));
|
|
68
|
+
ok('codex: SKILL.md still carries the adapter header', skillMainContent.includes('<codex_skill_adapter>'));
|
|
69
|
+
// The adapter header itself (mandated EXACT text) intentionally contrasts the
|
|
70
|
+
// $trailhead form with the old slash-namespace form ("Never /trailhead:<verb>"),
|
|
71
|
+
// so it legitimately contains that substring once. Scope the check to the
|
|
72
|
+
// converted engine body, after the header, which is where the command-surface
|
|
73
|
+
// rewrite actually applies.
|
|
74
|
+
const engineBody = skillMainContent.slice(skillMainContent.indexOf('</codex_skill_adapter>'));
|
|
75
|
+
ok('codex: SKILL.md engine body has no /trailhead: substring', !engineBody.includes('/trailhead:'));
|
|
76
|
+
|
|
77
|
+
ok('codex: references/techniques/grilling.md exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'references', 'techniques', 'grilling.md')));
|
|
78
|
+
|
|
79
|
+
const agentsYamlPath = path.join(codexDir, 'skills', 'trailhead', 'agents', 'openai.yaml');
|
|
80
|
+
ok('codex: agents/openai.yaml exists', fs.existsSync(agentsYamlPath));
|
|
81
|
+
ok('codex: agents/openai.yaml disallows implicit invocation', fs.readFileSync(agentsYamlPath, 'utf8').includes('allow_implicit_invocation: false'));
|
|
82
|
+
|
|
83
|
+
const codexTemplate = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-commit-msg');
|
|
84
|
+
ok('codex: commit-msg template projected', fs.existsSync(codexTemplate));
|
|
85
|
+
ok('codex: commit-msg template is verbatim (not converted)',
|
|
86
|
+
fs.readFileSync(codexTemplate, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg'), 'utf8'));
|
|
87
|
+
|
|
88
|
+
ok('codex: no commands dir', !fs.existsSync(path.join(codexDir, 'commands')));
|
|
89
|
+
ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.json')));
|
|
90
|
+
// #46: per-verb discoverability now projects one Codex SKILL per verb
|
|
91
|
+
// (skills/trailhead-<verb>/), invocable as $trailhead-<verb>. The old
|
|
92
|
+
// ~/.codex/prompts/ shims never surfaced as slash commands on Codex.
|
|
93
|
+
ok('codex: per-verb skills/trailhead-work/SKILL.md exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead-work', 'SKILL.md')));
|
|
94
|
+
ok('codex: per-verb skills/trailhead-bug/SKILL.md exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead-bug', 'SKILL.md')));
|
|
95
|
+
ok('codex: trailhead-work SKILL.md frontmatter is at byte 0', fs.readFileSync(path.join(codexDir, 'skills', 'trailhead-work', 'SKILL.md'), 'utf8').startsWith('---\nname: trailhead-work\n'));
|
|
96
|
+
ok('codex: trailhead-work skill delegates to $trailhead work', fs.readFileSync(path.join(codexDir, 'skills', 'trailhead-work', 'SKILL.md'), 'utf8').includes('$trailhead work'));
|
|
97
|
+
ok('codex: per-verb skill is explicit-only', fs.readFileSync(path.join(codexDir, 'skills', 'trailhead-work', 'agents', 'openai.yaml'), 'utf8').includes('allow_implicit_invocation: false'));
|
|
98
|
+
ok('codex: no ~/.codex/prompts shims left (old mechanism dropped)', !fs.existsSync(path.join(codexDir, 'prompts', 'trailhead.md')) && !fs.existsSync(path.join(codexDir, 'prompts', 'trailhead-work.md')));
|
|
99
|
+
|
|
100
|
+
// --- codex hooks (#29) --------------------------------------------------------
|
|
101
|
+
const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
|
|
102
|
+
ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
|
|
103
|
+
const codexHooksJson = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
|
|
104
|
+
const codexHooksJsonStr = JSON.stringify(codexHooksJson);
|
|
105
|
+
ok('codex: hooks.json registers commit-guard under PreToolUse', codexHooksJsonStr.includes('trailhead-commit-guard.js') &&
|
|
106
|
+
(codexHooksJson.hooks.PreToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-commit-guard.js'))));
|
|
107
|
+
ok('codex: hooks.json registers secret-guard under PreToolUse', (codexHooksJson.hooks.PreToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-secret-guard.js'))));
|
|
108
|
+
ok('codex: hooks.json registers injection-scanner under PostToolUse', (codexHooksJson.hooks.PostToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-issue-injection-scanner.js'))));
|
|
109
|
+
ok('codex: hooks.json registers check-update under SessionStart', (codexHooksJson.hooks.SessionStart || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-check-update.js'))));
|
|
110
|
+
|
|
111
|
+
ok('codex: skills/trailhead/hooks/trailhead-secret-guard.js exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-secret-guard.js')));
|
|
112
|
+
ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-guard require target)',
|
|
113
|
+
fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
|
|
114
|
+
|
|
115
|
+
const codexConfigTomlPath = path.join(codexDir, 'config.toml');
|
|
116
|
+
ok('codex: config.toml exists', fs.existsSync(codexConfigTomlPath));
|
|
117
|
+
ok('codex: config.toml enables hooks feature', fs.readFileSync(codexConfigTomlPath, 'utf8').includes('hooks = true'));
|
|
118
|
+
// v2 is gated on projected pins: this install (repoRoot, no models.codex.*)
|
|
119
|
+
// projects none, so multi_agent_v2 stays OFF (base multi_agent still fans out;
|
|
120
|
+
// adapter §D expects no trailhead agent_type registry in the no-pins case).
|
|
121
|
+
ok('codex: config.toml does NOT enable multi_agent_v2 when no models.codex.* pins are projected',
|
|
122
|
+
!fs.readFileSync(codexConfigTomlPath, 'utf8').includes('multi_agent_v2 = true'));
|
|
123
|
+
|
|
124
|
+
// --- codex agent TOML projection (#38) -----------------------------------------
|
|
125
|
+
// repoRoot's own .trailhead/config.json has no models.codex.*, so a plain
|
|
126
|
+
// install (cwd: repoRoot, no models.codex anywhere) projects no agent TOMLs.
|
|
127
|
+
ok('codex: no trailhead-*.toml under agents/ when models.codex.* is unset',
|
|
128
|
+
!fs.existsSync(path.join(codexDir, 'agents')) ||
|
|
129
|
+
!fs.readdirSync(path.join(codexDir, 'agents')).some((f) => /^trailhead-.*\.toml$/.test(f)));
|
|
130
|
+
|
|
131
|
+
// --- codex migration (#46): install over old prompt shims sweeps them --------
|
|
132
|
+
const migCodexDir = mktmp();
|
|
133
|
+
fs.mkdirSync(path.join(migCodexDir, 'prompts'), { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(migCodexDir, 'prompts', 'trailhead.md'), 'stale bare shim\n');
|
|
135
|
+
fs.writeFileSync(path.join(migCodexDir, 'prompts', 'trailhead-work.md'), 'stale verb shim\n');
|
|
136
|
+
runInstaller([`--codex`, `--dir=${migCodexDir}`]);
|
|
137
|
+
ok('codex migration: stale prompts/trailhead.md swept on install', !fs.existsSync(path.join(migCodexDir, 'prompts', 'trailhead.md')));
|
|
138
|
+
ok('codex migration: stale prompts/trailhead-work.md swept on install', !fs.existsSync(path.join(migCodexDir, 'prompts', 'trailhead-work.md')));
|
|
139
|
+
ok('codex migration: per-verb skill projected in its place', fs.existsSync(path.join(migCodexDir, 'skills', 'trailhead-work', 'SKILL.md')));
|
|
140
|
+
|
|
141
|
+
// --- codex uninstall (same tmp) ----------------------------------------------
|
|
142
|
+
runInstaller([`--codex`, `--dir=${codexDir}`, '--uninstall']);
|
|
143
|
+
ok('codex uninstall: skills/trailhead gone', !fs.existsSync(path.join(codexDir, 'skills', 'trailhead')));
|
|
144
|
+
ok('codex uninstall: legacy trailhead engine dir gone', !fs.existsSync(path.join(codexDir, 'trailhead')));
|
|
145
|
+
ok('codex uninstall: per-verb skills/trailhead-work gone', !fs.existsSync(path.join(codexDir, 'skills', 'trailhead-work')));
|
|
146
|
+
ok('codex uninstall: per-verb skills/trailhead-bug gone', !fs.existsSync(path.join(codexDir, 'skills', 'trailhead-bug')));
|
|
147
|
+
ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
|
|
148
|
+
const h = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
|
|
149
|
+
const str = JSON.stringify(h);
|
|
150
|
+
return !str.includes('trailhead-commit-guard.js') && !str.includes('trailhead-secret-guard.js') &&
|
|
151
|
+
!str.includes('trailhead-issue-injection-scanner.js') && !str.includes('trailhead-check-update.js');
|
|
152
|
+
})());
|
|
153
|
+
|
|
154
|
+
// --- claude regression --------------------------------------------------------
|
|
155
|
+
const claudeDir = mktmp();
|
|
156
|
+
runInstaller([`--claude`, `--dir=${claudeDir}`]);
|
|
157
|
+
|
|
158
|
+
const claudeSkillPath = path.join(claudeDir, 'skills', 'trailhead', 'SKILL.md');
|
|
159
|
+
ok('claude: skills/trailhead/SKILL.md exists', fs.existsSync(claudeSkillPath));
|
|
160
|
+
ok('claude: commands/trailhead/work.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'work.md')));
|
|
161
|
+
ok('claude: hooks/trailhead-commit-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-commit-guard.js')));
|
|
162
|
+
ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require target)',
|
|
163
|
+
fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
|
|
164
|
+
// Regression: the commit-guard does require('./lib/commit-message-check.js'), so
|
|
165
|
+
// it only loads if the lib was copied alongside it. Run it with a benign Bash
|
|
166
|
+
// payload and assert it does not crash with a missing-module error.
|
|
167
|
+
ok('claude: trailhead-commit-guard.js loads without MODULE_NOT_FOUND', (() => {
|
|
168
|
+
let out = '';
|
|
169
|
+
try {
|
|
170
|
+
out = String(execFileSync(process.execPath, [path.join(claudeDir, 'hooks', 'trailhead-commit-guard.js')],
|
|
171
|
+
{ input: '{"tool_name":"Bash","tool_input":{"command":"echo hi"}}', stdio: 'pipe' }));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
out = String(e.stdout || '') + String(e.stderr || '');
|
|
174
|
+
}
|
|
175
|
+
return !out.includes('MODULE_NOT_FOUND') && !out.includes('Cannot find module');
|
|
176
|
+
})());
|
|
177
|
+
ok('claude: SKILL.md does not start with the codex adapter header', !fs.readFileSync(claudeSkillPath, 'utf8').startsWith('<codex_skill_adapter>'));
|
|
178
|
+
|
|
179
|
+
// --- claude uninstall: remove trailhead's lib, keep a co-tenant's -------------
|
|
180
|
+
// The Claude hooks/lib dir is shared with other plugins. Drop a fake co-tenant
|
|
181
|
+
// lib next to trailhead's, uninstall, and assert only trailhead's own lib file
|
|
182
|
+
// is removed (never a recursive wipe of the shared dir).
|
|
183
|
+
const coTenantDir = mktmp();
|
|
184
|
+
runInstaller([`--claude`, `--dir=${coTenantDir}`]);
|
|
185
|
+
const coTenantLib = path.join(coTenantDir, 'hooks', 'lib', 'other-plugin-lib.js');
|
|
186
|
+
fs.writeFileSync(coTenantLib, '// not trailhead\n');
|
|
187
|
+
runInstaller([`--claude`, `--dir=${coTenantDir}`, '--uninstall']);
|
|
188
|
+
ok('claude uninstall: trailhead lib removed', !fs.existsSync(path.join(coTenantDir, 'hooks', 'lib', 'commit-message-check.js')));
|
|
189
|
+
ok('claude uninstall: co-tenant lib preserved (no recursive wipe)', fs.existsSync(coTenantLib));
|
|
190
|
+
|
|
191
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
192
|
+
ok('claude: settings.json exists', fs.existsSync(settingsPath));
|
|
193
|
+
const settingsContent = fs.readFileSync(settingsPath, 'utf8');
|
|
194
|
+
ok('claude: settings.json references commit-guard', settingsContent.includes('trailhead-commit-guard.js'));
|
|
195
|
+
|
|
196
|
+
// --- auto-detect: exactly one CLI on $PATH -> install for it, no flag ----------
|
|
197
|
+
// Only a fake `codex` on $PATH: the installer must pick codex on its own.
|
|
198
|
+
const autoCodexDir = mktmp();
|
|
199
|
+
runInstaller([`--dir=${autoCodexDir}`], { env: { PATH: fakeBinDir(['codex']) } });
|
|
200
|
+
const autoCodexSkillPath = path.join(autoCodexDir, 'skills', 'trailhead', 'SKILL.md');
|
|
201
|
+
ok('auto-detect codex: skills/trailhead/SKILL.md exists', fs.existsSync(autoCodexSkillPath));
|
|
202
|
+
ok('auto-detect codex: SKILL.md carries the adapter header (proves codex layout)', fs.readFileSync(autoCodexSkillPath, 'utf8').includes('<codex_skill_adapter>'));
|
|
203
|
+
ok('auto-detect codex: did not install the Claude layout (no commands/trailhead)', !fs.existsSync(path.join(autoCodexDir, 'commands', 'trailhead')));
|
|
204
|
+
ok('auto-detect codex: did not install the Claude layout (no settings.json)', !fs.existsSync(path.join(autoCodexDir, 'settings.json')));
|
|
205
|
+
|
|
206
|
+
// Only a fake `claude` on $PATH: the installer must pick claude on its own.
|
|
207
|
+
const autoClaudeDir = mktmp();
|
|
208
|
+
runInstaller([`--dir=${autoClaudeDir}`], { env: { PATH: fakeBinDir(['claude']) } });
|
|
209
|
+
const autoClaudeSkillPath = path.join(autoClaudeDir, 'skills', 'trailhead', 'SKILL.md');
|
|
210
|
+
ok('auto-detect claude: skills/trailhead/SKILL.md exists', fs.existsSync(autoClaudeSkillPath));
|
|
211
|
+
ok('auto-detect claude: commands/trailhead/work.md exists (proves claude layout)', fs.existsSync(path.join(autoClaudeDir, 'commands', 'trailhead', 'work.md')));
|
|
212
|
+
ok('auto-detect claude: SKILL.md does not start with adapter header', !fs.readFileSync(autoClaudeSkillPath, 'utf8').startsWith('<codex_skill_adapter>'));
|
|
213
|
+
|
|
214
|
+
// --- auto-detect: ambiguous + non-interactive -> claude fallback --------------
|
|
215
|
+
// No CLI on $PATH and non-interactive (piped stdio): must fall back to claude.
|
|
216
|
+
const fallbackDir = mktmp();
|
|
217
|
+
runInstaller([`--dir=${fallbackDir}`], { env: { PATH: fakeBinDir([]) } });
|
|
218
|
+
const fallbackSkillPath = path.join(fallbackDir, 'skills', 'trailhead', 'SKILL.md');
|
|
219
|
+
ok('ambiguous fallback: installs the Claude layout', fs.existsSync(fallbackSkillPath));
|
|
220
|
+
ok('ambiguous fallback: settings.json exists (proves claude layout)', fs.existsSync(path.join(fallbackDir, 'settings.json')));
|
|
221
|
+
ok('ambiguous fallback: SKILL.md does not start with adapter header', !fs.readFileSync(fallbackSkillPath, 'utf8').startsWith('<codex_skill_adapter>'));
|
|
222
|
+
|
|
223
|
+
// --- both --codex and --claude -> hard error ----------------------------------
|
|
224
|
+
let bothRejected = false;
|
|
225
|
+
try {
|
|
226
|
+
runInstaller([`--codex`, `--claude`, `--dir=${mktmp()}`]);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
bothRejected = true;
|
|
229
|
+
}
|
|
230
|
+
ok('conflicting --codex --claude is rejected', bothRejected);
|
|
231
|
+
|
|
232
|
+
// --- legacy --host= is no longer a host selector ------------------------------
|
|
233
|
+
// It must not silently install codex; with no real flag it auto-detects, and
|
|
234
|
+
// here $PATH has only claude, so a stray --host=codex still yields claude.
|
|
235
|
+
const legacyDir = mktmp();
|
|
236
|
+
runInstaller([`--host=codex`, `--dir=${legacyDir}`], { env: { PATH: fakeBinDir(['claude']) } });
|
|
237
|
+
const legacySkillPath = path.join(legacyDir, 'skills', 'trailhead', 'SKILL.md');
|
|
238
|
+
ok('legacy --host=codex is ignored (auto-detect wins)', fs.existsSync(legacySkillPath));
|
|
239
|
+
ok('legacy --host=codex is ignored: settings.json exists (proves claude layout)', fs.existsSync(path.join(legacyDir, 'settings.json')));
|
|
240
|
+
ok('legacy --host=codex is ignored: SKILL.md does not start with adapter header', !fs.readFileSync(legacySkillPath, 'utf8').startsWith('<codex_skill_adapter>'));
|
|
241
|
+
|
|
242
|
+
// --- codex agent TOML projection: a real models.codex.* pin (#38) --------------
|
|
243
|
+
// A temp "project" dir with .trailhead/config.json setting models.codex.execute;
|
|
244
|
+
// running the installer with that dir as cwd (and a separate codex-home tmp dir
|
|
245
|
+
// as --dir=) must project trailhead-execute.toml under <codexHome>/agents/.
|
|
246
|
+
const projectDir = mktmp();
|
|
247
|
+
fs.mkdirSync(path.join(projectDir, '.trailhead'), { recursive: true });
|
|
248
|
+
fs.writeFileSync(
|
|
249
|
+
path.join(projectDir, '.trailhead', 'config.json'),
|
|
250
|
+
JSON.stringify({ models: { codex: { execute: 'gpt-5.6-terra' } } }, null, 2) + '\n'
|
|
251
|
+
);
|
|
252
|
+
const pinCodexHomeDir = mktmp();
|
|
253
|
+
runInstaller([`--codex`, `--dir=${pinCodexHomeDir}`], { cwd: projectDir });
|
|
254
|
+
|
|
255
|
+
const pinnedTomlPath = path.join(pinCodexHomeDir, 'agents', 'trailhead-execute.toml');
|
|
256
|
+
ok('codex: models.codex.execute projects trailhead-execute.toml', fs.existsSync(pinnedTomlPath));
|
|
257
|
+
const pinnedTomlContent = fs.existsSync(pinnedTomlPath) ? fs.readFileSync(pinnedTomlPath, 'utf8') : '';
|
|
258
|
+
ok('codex: trailhead-execute.toml has the pinned model', pinnedTomlContent.includes('model = "gpt-5.6-terra"'));
|
|
259
|
+
ok('codex: trailhead-execute.toml has the right name', pinnedTomlContent.includes('name = "trailhead-execute"'));
|
|
260
|
+
// With a real pin projected, v2 IS enabled so Codex honours the agent_type registry.
|
|
261
|
+
const pinConfigTomlPath = path.join(pinCodexHomeDir, 'config.toml');
|
|
262
|
+
ok('codex: config.toml enables multi_agent_v2 when a models.codex.* pin is projected',
|
|
263
|
+
fs.existsSync(pinConfigTomlPath) && fs.readFileSync(pinConfigTomlPath, 'utf8').includes('multi_agent_v2 = true'));
|
|
264
|
+
|
|
265
|
+
// Uninstall must sweep the projected TOML too, without a --dir= cwd dependency.
|
|
266
|
+
runInstaller([`--codex`, `--dir=${pinCodexHomeDir}`, '--uninstall']);
|
|
267
|
+
ok('codex uninstall: trailhead-execute.toml is gone', !fs.existsSync(pinnedTomlPath));
|
|
268
|
+
|
|
269
|
+
// --- cleanup -------------------------------------------------------------------
|
|
270
|
+
for (const d of tmpDirs) {
|
|
271
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
console.log(`✓ trailhead.js (installer): ${passed} assertions passed`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcomigozzi/trailhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trailhead": "bin/trailhead.js"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trailhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Self-contained: depends on nothing beyond an authenticated gh CLI.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marco Migozzi",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Show the repo dashboard: the pinned index of every open map, the whiteboard, and live counts"
|
|
3
|
+
argument-hint: ""
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Invoke the `trailhead` skill for the **dashboard** action: call the Skill tool with skill name `trailhead` and arguments `dashboard $ARGUMENTS`, then carry out the skill's instructions for that verb (regenerate the pinned `trailhead:dashboard` index issue from the live tracker, creating and pinning it if missing, and show it). Do not re-implement the behaviour here. The skill is the single source of truth.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Work one ticket whole, off the map (whiteboard); grills only if needed, never splits
|
|
3
|
+
argument-hint: "[ticket | \"text\"]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Invoke the `trailhead` skill for the **quick** action: call the Skill tool with skill name `trailhead` and arguments `quick $ARGUMENTS`, then carry out the skill's instructions for that verb (open a `trailhead:whiteboard` ticket from the `"text"`, or take the existing ticket you name, then run the full engine on it without splitting and without map book-keeping). Do not re-implement the behaviour here. The skill is the single source of truth.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Show the whiteboard: loose (map-less) tickets, their frontier and who holds each"
|
|
3
|
+
argument-hint: ""
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Invoke the `trailhead` skill for the **whiteboard** action: call the Skill tool with skill name `trailhead` and arguments `whiteboard $ARGUMENTS`, then carry out the skill's instructions for that verb (render the read-only whiteboard dashboard: the frontier, in-progress, and blocked `trailhead:whiteboard` tickets). Do not re-implement the behaviour here. The skill is the single source of truth.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// commit-message-check.js: the single source of truth for trailhead's commit
|
|
3
|
+
// message rules, shared by two guardrails so they can never drift:
|
|
4
|
+
// - trailhead-commit-guard.js (Claude Code PreToolUse(Bash) hook)
|
|
5
|
+
// - templates/trailhead-commit-msg (host-independent git commit-msg hook,
|
|
6
|
+
// installed into .git/hooks at repo first-use so it runs on ANY host)
|
|
7
|
+
// Two rules: NEVER a Co-Authored-By trailer; a Conventional Commits subject
|
|
8
|
+
// (<type>(scope)?: subject) of at most 72 chars.
|
|
9
|
+
// Pure and dependency-free: takes a commit message string, returns a verdict.
|
|
10
|
+
|
|
11
|
+
const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
|
|
12
|
+
const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
|
|
13
|
+
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
14
|
+
const SCISSORS = '# ------------------------ >8';
|
|
15
|
+
|
|
16
|
+
// The message region: everything before git's --verbose scissors line (the
|
|
17
|
+
// block after it is the diff, not the message). Everything else is kept, so a
|
|
18
|
+
// Co-Authored-By hiding in a body/comment line is still caught.
|
|
19
|
+
function messageRegion(raw) {
|
|
20
|
+
const text = String(raw == null ? '' : raw);
|
|
21
|
+
const i = text.indexOf(SCISSORS);
|
|
22
|
+
return i === -1 ? text : text.slice(0, i);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Subject = first non-empty line of the message region. `opts.stripComments`
|
|
26
|
+
// (default true) also skips git comment (`#`) lines: correct for a commit-msg
|
|
27
|
+
// FILE, where git has not yet stripped its `#` boilerplate. It must be FALSE
|
|
28
|
+
// for a `git commit -m` string, where cleanup=whitespace keeps `#` lines as
|
|
29
|
+
// literal message text, so `#123 wip` is a real (non-conventional) subject and
|
|
30
|
+
// must NOT be treated as a comment. This is the seam the PreToolUse guard and
|
|
31
|
+
// the git hook diverge at (decision #16 defence in depth).
|
|
32
|
+
function subjectOf(raw, opts = {}) {
|
|
33
|
+
const stripComments = opts.stripComments !== false;
|
|
34
|
+
for (const line of messageRegion(raw).split('\n')) {
|
|
35
|
+
if (line.trim() === '') continue;
|
|
36
|
+
if (stripComments && line.startsWith('#')) continue;
|
|
37
|
+
return line.trim();
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// checkCommitMessage(message, opts?) -> { ok: true } | { ok: false, code, reason }
|
|
43
|
+
// opts.stripComments is forwarded to subjectOf (see there): the git commit-msg
|
|
44
|
+
// hook uses the default (file semantics); the PreToolUse `-m` guard passes false.
|
|
45
|
+
function checkCommitMessage(message, opts = {}) {
|
|
46
|
+
const region = messageRegion(message);
|
|
47
|
+
|
|
48
|
+
if (CO_AUTHORED.test(region)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: 'CO_AUTHORED_BY_FORBIDDEN',
|
|
52
|
+
reason: 'trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const subject = subjectOf(message, opts);
|
|
57
|
+
if (subject && !CONVENTIONAL.test(subject)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'CONVENTIONAL_COMMITS_VIOLATION',
|
|
61
|
+
reason: `trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (subject.length > 72) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'COMMIT_SUBJECT_TOO_LONG',
|
|
68
|
+
reason: 'trailhead: commit subject must be 72 characters or less.',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { checkCommitMessage, subjectOf, messageRegion, TYPES, CONVENTIONAL, CO_AUTHORED };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for commit-message-check.js. Run: node commit-message-check.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of trailhead-secret-guard.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Drives the SAME fixtures through two paths:
|
|
6
|
+
// 1. checkCommitMessage() directly (the source of truth).
|
|
7
|
+
// 2. the template hook (templates/trailhead-commit-msg) end-to-end via
|
|
8
|
+
// execFileSync, feeding it the message as a temp file.
|
|
9
|
+
// The two verdicts must always agree: that agreement is the anti-drift
|
|
10
|
+
// guarantee that keeps the Claude Code hook and the git commit-msg hook from
|
|
11
|
+
// ever diverging.
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const { checkCommitMessage } = require('./commit-message-check.js');
|
|
19
|
+
|
|
20
|
+
const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
24
|
+
|
|
25
|
+
const FIXTURES = [
|
|
26
|
+
{ name: 'valid subject', message: 'feat: add thing', expectOk: true },
|
|
27
|
+
{ name: 'valid with scope and body', message: 'fix(installer): x\n\nbody line', expectOk: true },
|
|
28
|
+
{ name: 'non-conventional subject', message: 'add thing', expectOk: false, code: 'CONVENTIONAL_COMMITS_VIOLATION' },
|
|
29
|
+
{ name: 'subject too long', message: 'feat: ' + 'x'.repeat(70), expectOk: false, code: 'COMMIT_SUBJECT_TOO_LONG' },
|
|
30
|
+
{ name: 'co-authored anywhere', message: 'feat: x\n\nCo-authored-by: A <a@b.c>', expectOk: false, code: 'CO_AUTHORED_BY_FORBIDDEN' },
|
|
31
|
+
{ name: 'leading git comment lines then valid subject', message: '# comment\n\nfeat: x', expectOk: true },
|
|
32
|
+
{
|
|
33
|
+
name: 'verbose scissors: co-authored past the scissors is ignored',
|
|
34
|
+
message: 'feat: x\n\n# ------------------------ >8 ------------------------\ndiff with Co-authored-by: junk',
|
|
35
|
+
expectOk: true,
|
|
36
|
+
},
|
|
37
|
+
{ name: 'empty message', message: '', expectOk: true },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// --- unit: checkCommitMessage() directly ---
|
|
41
|
+
for (const f of FIXTURES) {
|
|
42
|
+
const verdict = checkCommitMessage(f.message);
|
|
43
|
+
ok(`checkCommitMessage: ${f.name} -> ok=${f.expectOk}`, verdict.ok === f.expectOk);
|
|
44
|
+
if (!f.expectOk && f.code) {
|
|
45
|
+
ok(`checkCommitMessage: ${f.name} -> code=${f.code}`, verdict.code === f.code);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- end-to-end: drive the template hook on the SAME fixtures ---
|
|
50
|
+
function runTemplateHook(message) {
|
|
51
|
+
const tmpFile = path.join(os.tmpdir(), `trailhead-commit-msg-test-${process.pid}-${Math.random().toString(36).slice(2)}.txt`);
|
|
52
|
+
fs.writeFileSync(tmpFile, message);
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('node', [templatePath, tmpFile]);
|
|
55
|
+
return true; // exit 0 -> allow
|
|
56
|
+
} catch {
|
|
57
|
+
return false; // non-zero exit -> block
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(tmpFile, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const f of FIXTURES) {
|
|
64
|
+
const allowed = runTemplateHook(f.message);
|
|
65
|
+
const expected = checkCommitMessage(f.message).ok;
|
|
66
|
+
ok(`template hook: ${f.name} -> allowed=${expected}`, allowed === expected);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- stripComments seam: the `-m` guard path (stripComments:false) must NOT
|
|
70
|
+
// treat a `#`-leading subject as a comment (git -m keeps `#` lines as text),
|
|
71
|
+
// while the default file-semantics path (strip=true) does. Regression guard for
|
|
72
|
+
// the parity gap the code review caught. ---
|
|
73
|
+
const hashSubject = '#123 wip do stuff';
|
|
74
|
+
ok('no-strip: #-leading subject is a real (non-conventional) subject -> block',
|
|
75
|
+
checkCommitMessage(hashSubject, { stripComments: false }).ok === false);
|
|
76
|
+
ok('no-strip: #-leading subject blocks with the conventional code',
|
|
77
|
+
checkCommitMessage(hashSubject, { stripComments: false }).code === 'CONVENTIONAL_COMMITS_VIOLATION');
|
|
78
|
+
ok('default strip: #-leading line is a comment -> empty subject -> allow',
|
|
79
|
+
checkCommitMessage(hashSubject).ok === true);
|
|
80
|
+
ok('template hook: #-leading message is file-semantics (comment) -> allow',
|
|
81
|
+
runTemplateHook(hashSubject) === true);
|
|
82
|
+
|
|
83
|
+
console.log(`✓ commit-message-check: ${passed} assertions passed`);
|
|
@@ -19,9 +19,14 @@ const THROTTLE_MS = 6 * 60 * 60 * 1000; // ricontrolla al massimo ogni 6h
|
|
|
19
19
|
function configDir() {
|
|
20
20
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
// La cache è per-host: Claude e Codex possono essere installati per lo stesso
|
|
23
|
+
// utente a versioni diverse, quindi non devono condividere throttle e verdetto
|
|
24
|
+
// (altrimenti entro le 6h un host emetterebbe la versione dell'altro). Claude
|
|
25
|
+
// mantiene il file storico (la statusline lo legge lì); Codex ha il suo.
|
|
26
|
+
function cacheFile(host) {
|
|
23
27
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
24
|
-
|
|
28
|
+
const name = host === 'codex' ? 'update-check-codex.json' : 'update-check.json';
|
|
29
|
+
return path.join(base, 'trailhead', name);
|
|
25
30
|
}
|
|
26
31
|
function readJSON(p) {
|
|
27
32
|
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
@@ -46,6 +51,15 @@ function cmp(a, b) {
|
|
|
46
51
|
|
|
47
52
|
// --- rileva canale + versione installata ---
|
|
48
53
|
function detect() {
|
|
54
|
+
// 0) codex: lo script vive sotto <codexHome>/skills/trailhead/hooks/, un
|
|
55
|
+
// percorso robusto e indipendente dall'env (non serve CODEX_HOME).
|
|
56
|
+
const onCodex = /[\\/]skills[\\/]trailhead[\\/]hooks$/.test(__dirname);
|
|
57
|
+
if (onCodex) {
|
|
58
|
+
const ver = (readFileSafe(path.join(__dirname, '..', 'VERSION')) || '').trim();
|
|
59
|
+
if (SEMVER.test(clean(ver))) {
|
|
60
|
+
return { channel: 'codex', version: clean(ver), where: path.join(__dirname, '..'), host: 'codex' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
49
63
|
const cfg = configDir();
|
|
50
64
|
// 1) plugin: elencato in installed_plugins.json
|
|
51
65
|
const ip = readJSON(path.join(cfg, 'plugins', 'installed_plugins.json'));
|
|
@@ -92,32 +106,48 @@ function latestGitHub() {
|
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
function main() {
|
|
95
|
-
const out = cacheFile();
|
|
96
|
-
// throttle: se la cache è fresca (<6h) non richiamare la rete
|
|
97
|
-
try {
|
|
98
|
-
const prev = readJSON(out);
|
|
99
|
-
if (prev && prev.checkedAt && Date.now() - prev.checkedAt < THROTTLE_MS) return;
|
|
100
|
-
} catch { /* ignore */ }
|
|
101
|
-
|
|
102
109
|
const info = detect();
|
|
103
110
|
if (!SEMVER.test(clean(info.version))) return; // versione installata sconosciuta: non azzardare
|
|
111
|
+
const out = cacheFile(info.host); // cache per-host: Codex non eredita il verdetto di Claude
|
|
104
112
|
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
// throttle: se la cache è fresca (<6h) riusa quel verdetto senza richiamare la rete
|
|
114
|
+
let prev = null;
|
|
115
|
+
try { prev = readJSON(out); } catch { prev = null; }
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
let verdict;
|
|
118
|
+
if (prev && prev.checkedAt && Date.now() - prev.checkedAt < THROTTLE_MS) {
|
|
119
|
+
verdict = prev;
|
|
120
|
+
} else {
|
|
121
|
+
const latest = (info.channel === 'npm' || info.channel === 'codex') ? latestNpm() : latestGitHub();
|
|
122
|
+
if (!SEMVER.test(clean(latest))) return; // rete/fonte non disponibile: lascia la cache com'è
|
|
123
|
+
|
|
124
|
+
verdict = {
|
|
125
|
+
channel: info.channel,
|
|
126
|
+
installed: clean(info.version),
|
|
127
|
+
latest: clean(latest),
|
|
128
|
+
updateAvailable: cmp(latest, info.version) > 0,
|
|
129
|
+
where: info.where || '',
|
|
130
|
+
checkedAt: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
134
|
+
fs.writeFileSync(out + '.tmp', JSON.stringify(verdict));
|
|
135
|
+
fs.renameSync(out + '.tmp', out);
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Codex non ha una statusline: l'hook stesso deve segnalare l'update
|
|
140
|
+
// disponibile via additionalContext, su OGNI sessione (non solo quando la
|
|
141
|
+
// finestra di throttle si riapre). Su Claude (info.host è undefined) non
|
|
142
|
+
// scrive mai su stdout: comportamento invariato.
|
|
143
|
+
if (info.host === 'codex' && verdict && verdict.updateAvailable === true && SEMVER.test(clean(verdict.latest))) {
|
|
144
|
+
process.stdout.write(JSON.stringify({
|
|
145
|
+
hookSpecificOutput: {
|
|
146
|
+
hookEventName: 'SessionStart',
|
|
147
|
+
additionalContext: `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`,
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
// esegui in background: non blocca l'avvio della sessione.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
8
8
|
// Self-contained, no dependencies.
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const { checkCommitMessage } = require('./lib/commit-message-check.js');
|
|
11
|
+
|
|
12
12
|
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
13
13
|
|
|
14
14
|
function block(code, reason) {
|
|
@@ -56,14 +56,11 @@ process.stdin.on('end', () => {
|
|
|
56
56
|
|
|
57
57
|
const msg = extractMessage(cmd);
|
|
58
58
|
if (msg) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (subject.length > 72) {
|
|
65
|
-
block('COMMIT_SUBJECT_TOO_LONG', 'trailhead: commit subject must be 72 characters or less.');
|
|
66
|
-
}
|
|
59
|
+
// stripComments:false — this is the literal `-m` value, where git keeps
|
|
60
|
+
// `#` lines as message text (cleanup=whitespace), so a `#…` first line is
|
|
61
|
+
// a real subject to validate, not a comment to skip.
|
|
62
|
+
const verdict = checkCommitMessage(msg, { stripComments: false });
|
|
63
|
+
if (!verdict.ok) block(verdict.code, verdict.reason);
|
|
67
64
|
}
|
|
68
65
|
} catch {
|
|
69
66
|
// fall through to allow
|