@marcomigozzi/trailhead 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.it.md +2 -2
  2. package/README.md +2 -2
  3. package/bin/lib/install-verify.js +257 -0
  4. package/bin/lib/install-verify.test.js +379 -0
  5. package/bin/trailhead.js +86 -5
  6. package/bin/trailhead.test.js +500 -4
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  10. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  11. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  12. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  13. package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
  14. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
  15. package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
  16. package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  18. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +9 -6
  20. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  21. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  22. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  23. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
  24. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  25. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  31. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  34. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  35. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  36. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead commit-msg hook (git). Host-independent guardrail: installed into
4
+ // .git/hooks/commit-msg at repo first-use so trailhead's commit discipline is
5
+ // enforced on EVERY `git commit`, whatever agent host drives it (Codex has no
6
+ // hook bus; this git hook is how the commit-guard survives there). Decision #16.
7
+ //
8
+ // Self-contained by design: detached from the trailhead install, it inlines the
9
+ // same two rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
10
+ // lib's test drives this hook end-to-end so the two copies cannot drift.
11
+ //
12
+ // git passes the commit-message file path as argv[2]. On a violation: print the
13
+ // reason to stderr and exit 1 (git aborts the commit). Otherwise exit 0.
14
+ // Crash-safe: any unexpected error exits 0 (never wedge a commit).
15
+
16
+ const fs = require('fs');
17
+
18
+ const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
19
+ const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
20
+ const CO_AUTHORED = /co-authored-by\s*:/i;
21
+ const SCISSORS = '# ------------------------ >8';
22
+
23
+ function messageRegion(raw) {
24
+ const text = String(raw == null ? '' : raw);
25
+ const i = text.indexOf(SCISSORS);
26
+ return i === -1 ? text : text.slice(0, i);
27
+ }
28
+ function subjectOf(raw) {
29
+ for (const line of messageRegion(raw).split('\n')) {
30
+ if (line.trim() === '' || line.startsWith('#')) continue;
31
+ return line.trim();
32
+ }
33
+ return '';
34
+ }
35
+
36
+ try {
37
+ const file = process.argv[2];
38
+ const raw = file ? fs.readFileSync(file, 'utf8') : '';
39
+ const region = messageRegion(raw);
40
+
41
+ if (CO_AUTHORED.test(region)) {
42
+ process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
43
+ process.exit(1);
44
+ }
45
+ const subject = subjectOf(raw);
46
+ if (subject && !CONVENTIONAL.test(subject)) {
47
+ process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
48
+ process.exit(1);
49
+ }
50
+ if (subject.length > 72) {
51
+ process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
52
+ process.exit(1);
53
+ }
54
+ } catch {
55
+ process.exit(0); // never wedge a commit on an unexpected error
56
+ }
57
+ process.exit(0);
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+ // model-defaults-review.js: quando un aggiornamento sposta i default dei
3
+ // tier (plugins/trailhead/hooks/lib/model-defaults.json), offre la revisione
4
+ // dei models.* / models.codex.* pinnati che sono rimasti ai vecchi default.
5
+ // check() e' di sola lettura: non scrive mai l'ack, quindi ripropone la
6
+ // notice finche' qualcuno non chiama ack(cwd) (dopo che l'utente ha risposto
7
+ // a una delle tre scelte). Non lancia mai eccezioni dagli entry point
8
+ // pubblici: ogni funzione degrada a null/[]/false in caso di dato mancante
9
+ // o malformato. Entry point condiviso anche dagli host senza hook bus (CLI
10
+ // "check"/"ack" in fondo al file).
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const DATA_FILE = path.join(__dirname, 'model-defaults.json');
17
+ const HOSTS = ['claude', 'codex'];
18
+ const TIERS = ['strong', 'standard', 'fast'];
19
+
20
+ function isPlainObject(v) {
21
+ return !!v && typeof v === 'object' && !Array.isArray(v);
22
+ }
23
+
24
+ function loadData() {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
27
+ } catch {
28
+ return { schema: 1, history: [] };
29
+ }
30
+ }
31
+
32
+ function currentEntry(data) {
33
+ const history = (data && data.history) || [];
34
+ return history[history.length - 1] || null;
35
+ }
36
+
37
+ // Confronto semver minimale: true se a < b. Ignora pre-release, tollera "v".
38
+ function semverLt(a, b) {
39
+ const clean = (v) => String(v || '').replace(/^v/, '').split('-')[0];
40
+ const pa = clean(a).split('.').map(Number);
41
+ const pb = clean(b).split('.').map(Number);
42
+ for (let i = 0; i < 3; i++) {
43
+ const x = pa[i] || 0;
44
+ const y = pb[i] || 0;
45
+ if (x !== y) return x < y;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ // Trova il tier T a cui un id e' storicamente appartenuto per un host dato:
51
+ // T's default in una entry PIU' VECCHIA della corrente, oppure elencato nei
52
+ // supersedes[host][T] di una qualsiasi entry (corrente inclusa). Se piu'
53
+ // entry corrispondono, vince quella con indice piu' alto (piu' recente).
54
+ function findTier(data, host, id, current) {
55
+ const history = data.history || [];
56
+ let best = null; // { tier, index }
57
+ history.forEach((entry, index) => {
58
+ if (entry !== current && entry[host]) {
59
+ for (const tier of TIERS) {
60
+ if (entry[host][tier] === id) {
61
+ if (!best || index > best.index) best = { tier, index };
62
+ }
63
+ }
64
+ }
65
+ if (entry.supersedes && entry.supersedes[host]) {
66
+ for (const tier of TIERS) {
67
+ const list = entry.supersedes[host][tier] || [];
68
+ if (list.includes(id)) {
69
+ if (!best || index > best.index) best = { tier, index };
70
+ }
71
+ }
72
+ }
73
+ });
74
+ return best ? best.tier : null;
75
+ }
76
+
77
+ function isSkippable(id) {
78
+ return !id || typeof id !== 'string' || id.trim() === '' || id === 'inherit session';
79
+ }
80
+
81
+ // Valuta un singolo pin (id) per un host e ritorna { from, to } se stale,
82
+ // null altrimenti (id sconosciuto o gia' allineato al default corrente).
83
+ function evaluatePin(data, host, id, current) {
84
+ if (isSkippable(id)) return null;
85
+ const tier = findTier(data, host, id, current);
86
+ if (!tier) return null;
87
+ const to = current[host][tier];
88
+ if (to === id) return null;
89
+ return { from: id, to };
90
+ }
91
+
92
+ function stalePins(config, data) {
93
+ const out = [];
94
+ const current = currentEntry(data);
95
+ if (!current) return out;
96
+ const models = (config && config.models) || {};
97
+
98
+ for (const key of Object.keys(models)) {
99
+ if (key === 'codex') continue;
100
+ const result = evaluatePin(data, 'claude', models[key], current);
101
+ if (result) out.push({ key: `models.${key}`, from: result.from, to: result.to });
102
+ }
103
+
104
+ // Un models.codex malformato (non un oggetto piano) non produce pin: come
105
+ // se fosse assente, invece di iterare i suoi indici/caratteri.
106
+ const codex = isPlainObject(models.codex) ? models.codex : {};
107
+ for (const key of Object.keys(codex)) {
108
+ const raw = codex[key];
109
+ const id = raw && typeof raw === 'object' ? raw.model : raw;
110
+ const result = evaluatePin(data, 'codex', id, current);
111
+ if (result) out.push({ key: `models.codex.${key}`, from: result.from, to: result.to });
112
+ }
113
+
114
+ return out;
115
+ }
116
+
117
+ // Ritorna una NUOVA config con i pin stale aggiornati; non muta l'input.
118
+ function applyUpdate(config, stale) {
119
+ const next = { ...config, models: { ...((config && config.models) || {}) } };
120
+ if (next.models.codex) next.models.codex = { ...next.models.codex };
121
+
122
+ for (const s of stale) {
123
+ if (s.key.startsWith('models.codex.')) {
124
+ const k = s.key.slice('models.codex.'.length);
125
+ const cur = next.models.codex[k];
126
+ next.models.codex[k] = cur && typeof cur === 'object' ? { ...cur, model: s.to } : s.to;
127
+ } else {
128
+ const k = s.key.slice('models.'.length);
129
+ next.models[k] = s.to;
130
+ }
131
+ }
132
+ return next;
133
+ }
134
+
135
+ // Unisce piu' layer di config (dal piu' generico al piu' specifico, es.
136
+ // [global, project]) in una config effettiva pura: nessun input viene
137
+ // mutato. La precedenza e' per PRESENZA della chiave, mai per verita' del
138
+ // valore: un layer piu' specifico che imposta una chiave (anche a null, ''
139
+ // o "inherit session") vince sempre, e il valore del layer sotto non
140
+ // riaffiora mai. models.codex e' unito chiave per chiave solo quando sia
141
+ // l'accumulo corrente sia il valore in arrivo sono oggetti piani; altrimenti
142
+ // il valore del layer piu' specifico rimpiazza l'intero models.codex (un
143
+ // models.codex malformato nel progetto produce quindi zero pin codex, non
144
+ // un fallback al globale). Ogni layer il cui `models` non e' un oggetto
145
+ // piano (null, array, stringa) non contribuisce, come se fosse assente.
146
+ // Ritorna { config, sources }, dove sources mappa ogni chiave vincente
147
+ // (es. "models.plan", "models.codex.plan") a { layer, file } di chi l'ha
148
+ // decisa, cosi' la notice puo' stampare il path reale.
149
+ function effectiveModels(layers) {
150
+ let models = {};
151
+ let codex; // undefined finche' nessun layer tocca models.codex
152
+ const sources = {};
153
+
154
+ for (const layer of layers || []) {
155
+ const cfgModels = layer && layer.config && isPlainObject(layer.config.models) ? layer.config.models : null;
156
+ if (!cfgModels) continue;
157
+
158
+ for (const key of Object.keys(cfgModels)) {
159
+ if (key === 'codex') continue;
160
+ models = { ...models, [key]: cfgModels[key] };
161
+ sources[`models.${key}`] = { layer: layer.layer, file: layer.file };
162
+ }
163
+
164
+ if (Object.prototype.hasOwnProperty.call(cfgModels, 'codex')) {
165
+ const incoming = cfgModels.codex;
166
+ if (isPlainObject(incoming) && isPlainObject(codex)) {
167
+ for (const k of Object.keys(incoming)) {
168
+ codex = { ...codex, [k]: incoming[k] };
169
+ sources[`models.codex.${k}`] = { layer: layer.layer, file: layer.file };
170
+ }
171
+ } else {
172
+ for (const k of Object.keys(sources)) {
173
+ if (k.startsWith('models.codex.')) delete sources[k];
174
+ }
175
+ codex = isPlainObject(incoming) ? { ...incoming } : incoming;
176
+ if (isPlainObject(codex)) {
177
+ for (const k of Object.keys(codex)) {
178
+ sources[`models.codex.${k}`] = { layer: layer.layer, file: layer.file };
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ const outModels = { ...models };
186
+ if (codex !== undefined) outModels.codex = codex;
187
+ return { config: { models: outModels }, sources };
188
+ }
189
+
190
+ function reviewOffer({ config, data, ack, sources }) {
191
+ const current = currentEntry(data);
192
+ if (!current) return null;
193
+ const stale = stalePins(config, data).map((s) => {
194
+ const source = sources && sources[s.key];
195
+ return source ? { ...s, source } : s;
196
+ });
197
+ if (!stale.length) return null;
198
+ if (ack && !semverLt(ack, current.since)) return null;
199
+ return { since: current.since, stale, notice: reviewNotice(stale, current.since) };
200
+ }
201
+
202
+ function sourceSuffix(source) {
203
+ if (!source) return ' (new default)';
204
+ const layerName = source.layer === 'global' ? 'the global config' : 'the project config';
205
+ return ` (new default; from ${layerName}, ${source.file})`;
206
+ }
207
+
208
+ function reviewNotice(stale, since) {
209
+ const lines = stale.map((s) => `- ${s.key}: ${s.from} -> ${s.to}${sourceSuffix(s.source)}`);
210
+ return [
211
+ `trailhead's default models changed in ${since}. The following pinned model(s) are older than the new default:`,
212
+ ...lines,
213
+ '',
214
+ 'Offer the user, once, without blocking their current request, three choices:',
215
+ '1) update those keys to the new defaults (edit each key in the config file it comes from, with exactly the values above; keep any codex effort unchanged),',
216
+ '2) keep them as they are (nothing to do; trailhead will not ask again for these defaults once acknowledged),',
217
+ '3) open /trailhead:config to pick per key.',
218
+ 'This offer reappears every time it is checked until acknowledged: run the ack instruction below only after the user has picked one of the three choices above.',
219
+ ].join('\n');
220
+ }
221
+
222
+ // Paragrafo con l'istruzione pronta all'uso per acquisire l'ack, appesa alla
223
+ // notice da check(). Nomina il path assoluto di questo lib e la root (la
224
+ // root del progetto trovata, oppure il cwd nel caso solo-globale), cosi'
225
+ // l'agente puo' eseguirla cosi' com'e'.
226
+ function ackInstruction(root) {
227
+ return `Once the user has answered, run: node "${__filename}" ack "${root}"`;
228
+ }
229
+
230
+ // --- I/O ---------------------------------------------------------------
231
+ const ACK_FILE = path.join('.trailhead', 'model-defaults-ack');
232
+ const CONFIG_FILE = path.join('.trailhead', 'config.json');
233
+
234
+ // Dir base della config globale: stessa risoluzione di trailhead-check-update.js.
235
+ function globalConfigDir() {
236
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
237
+ }
238
+
239
+ function globalConfigFile() {
240
+ return path.join(globalConfigDir(), 'trailhead', 'config.json');
241
+ }
242
+
243
+ // L'ack globale vive accanto al config.json globale, senza nidificare
244
+ // ".trailhead" (che e' una convenzione solo di progetto).
245
+ function globalAckFile() {
246
+ return path.join(globalConfigDir(), 'trailhead', 'model-defaults-ack');
247
+ }
248
+
249
+ // Mancante o malformato -> {}: non deve mai bloccare la review di progetto.
250
+ function loadGlobalConfig() {
251
+ try {
252
+ const raw = JSON.parse(fs.readFileSync(globalConfigFile(), 'utf8'));
253
+ return isPlainObject(raw) ? raw : {};
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+
259
+ function findProjectRoot(cwd) {
260
+ let dir = path.resolve(cwd || '.');
261
+ for (;;) {
262
+ if (fs.existsSync(path.join(dir, CONFIG_FILE))) return dir;
263
+ const parent = path.dirname(dir);
264
+ if (parent === dir) return null;
265
+ dir = parent;
266
+ }
267
+ }
268
+
269
+ function readAckFile(file) {
270
+ try {
271
+ return fs.readFileSync(file, 'utf8').trim() || null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ function writeAckFile(file, since) {
278
+ try {
279
+ fs.mkdirSync(path.dirname(file), { recursive: true });
280
+ fs.writeFileSync(`${file}.tmp`, since);
281
+ fs.renameSync(`${file}.tmp`, file);
282
+ } catch {
283
+ /* mai far fallire il chiamante per un ack non scrivibile */
284
+ }
285
+ }
286
+
287
+ function readAck(root) {
288
+ return readAckFile(path.join(root, ACK_FILE));
289
+ }
290
+
291
+ function writeAck(root, since) {
292
+ writeAckFile(path.join(root, ACK_FILE), since);
293
+ }
294
+
295
+ // Di sola lettura: non scrive mai l'ack. Ripropone la stessa notice finche'
296
+ // non arriva un ack esplicito via ack(cwd). Valuta i pin EFFETTIVI: la
297
+ // config globale unita sotto quella di progetto (progetto vince chiave per
298
+ // chiave); senza un progetto sopra cwd, valuta la sola config globale.
299
+ function check(cwd) {
300
+ try {
301
+ const resolvedCwd = path.resolve(cwd || '.');
302
+ const root = findProjectRoot(cwd);
303
+ const data = loadData();
304
+ const globalFile = globalConfigFile();
305
+ const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig() };
306
+
307
+ if (root) {
308
+ let projectConfig;
309
+ try {
310
+ projectConfig = JSON.parse(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
311
+ } catch {
312
+ return null;
313
+ }
314
+ const projectLayer = {
315
+ layer: 'project',
316
+ file: path.join(root, CONFIG_FILE),
317
+ config: isPlainObject(projectConfig) ? projectConfig : {},
318
+ };
319
+ const { config, sources } = effectiveModels([globalLayer, projectLayer]);
320
+ const ack = readAck(root);
321
+ const offer = reviewOffer({ config, data, ack, sources });
322
+ if (!offer) return null;
323
+ return `${offer.notice}\n${ackInstruction(root)}`;
324
+ }
325
+
326
+ // Nessun progetto sopra cwd: valuta la sola config globale, se esiste.
327
+ if (!fs.existsSync(globalFile)) return null;
328
+ const { config, sources } = effectiveModels([globalLayer]);
329
+ const ack = readAckFile(globalAckFile());
330
+ const offer = reviewOffer({ config, data, ack, sources });
331
+ if (!offer) return null;
332
+ return `${offer.notice}\n${ackInstruction(resolvedCwd)}`;
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ // Scrive l'ack, con il "since" corrente dei default. Va chiamata SOLO dopo
339
+ // che l'utente ha risposto a una delle tre scelte della notice. Progetto
340
+ // trovato sopra cwd -> ack di progetto; altrimenti, se esiste una config
341
+ // globale -> ack globale. True se un target e' stato trovato e la scrittura
342
+ // tentata (un fallimento silenzioso non e' riportato, comportamento
343
+ // invariato); false se nessun target esiste. Non lancia mai eccezioni.
344
+ function ack(cwd) {
345
+ try {
346
+ const root = findProjectRoot(cwd);
347
+ const data = loadData();
348
+ const current = currentEntry(data);
349
+ if (!current) return false;
350
+ if (root) {
351
+ writeAck(root, current.since);
352
+ return true;
353
+ }
354
+ if (fs.existsSync(globalConfigFile())) {
355
+ writeAckFile(globalAckFile(), current.since);
356
+ return true;
357
+ }
358
+ return false;
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ module.exports = {
365
+ loadData,
366
+ currentEntry,
367
+ stalePins,
368
+ applyUpdate,
369
+ semverLt,
370
+ effectiveModels,
371
+ reviewOffer,
372
+ reviewNotice,
373
+ findProjectRoot,
374
+ globalConfigDir,
375
+ globalConfigFile,
376
+ globalAckFile,
377
+ readAckFile,
378
+ writeAckFile,
379
+ readAck,
380
+ writeAck,
381
+ check,
382
+ ack,
383
+ };
384
+
385
+ // CLI: entry point condiviso per gli host senza hook bus.
386
+ if (require.main === module) {
387
+ const [cmd, cwdArg] = process.argv.slice(2);
388
+ try {
389
+ if (cmd === 'check') {
390
+ const notice = check(cwdArg || process.cwd());
391
+ if (notice) process.stdout.write(notice + '\n');
392
+ } else if (cmd === 'ack') {
393
+ ack(cwdArg || process.cwd());
394
+ }
395
+ } catch {
396
+ /* mai fallire da CLI */
397
+ }
398
+ process.exit(0);
399
+ }