@marcomigozzi/trailhead 0.16.0 → 0.16.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 (39) hide show
  1. package/README.it.md +10 -8
  2. package/README.md +10 -8
  3. package/bin/config-dir-templates-docs.test.js +52 -0
  4. package/bin/decision-builds-docs.test.js +75 -0
  5. package/bin/fog-vs-ticket-docs.test.js +56 -0
  6. package/bin/lib/codex-projection.js +8 -1
  7. package/bin/lib/codex-projection.test.js +23 -0
  8. package/bin/lib/host-descriptor.js +14 -1
  9. package/bin/lib/host-descriptor.test.js +18 -0
  10. package/bin/lib/runtime-command-literals.test.js +184 -0
  11. package/bin/report-status-docs.test.js +1 -1
  12. package/package.json +1 -1
  13. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  14. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  15. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  16. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  17. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  18. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  19. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  20. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  21. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  22. package/plugins/trailhead/hooks/mod/README.md +8 -3
  23. package/plugins/trailhead/hooks/mod/map-pane.tsx +52 -28
  24. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  25. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  26. package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -2
  27. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  28. package/plugins/trailhead/skills/_shared/teamwork.md +3 -3
  29. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  31. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  32. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  34. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  35. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -3
  36. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -0
  37. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  38. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  39. package/plugins/trailhead/tests/map-pane-ui.test.ts +179 -2
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // Test statico: nessun literal di comando Claude (`/trailhead:<verb>` o `/trailhead`
3
+ // nudo) dentro stringhe/template del codice runtime. Gli hook lib e i template
4
+ // vengono copiati byte-per-byte su Codex e convertToCodex riscrive solo il
5
+ // markdown, quindi un literal qui arriverebbe cosi' com'e' a un utente Codex.
6
+ // Usare commandForm(host, verb) (host-descriptor o helper locale della lib).
7
+ // Run: node runtime-command-literals.test.js. Nessun framework: assert semplici.
8
+ const assert = require('assert');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const ROOT = path.resolve(__dirname, '..', '..');
13
+ const SCAN_DIRS = [
14
+ { dir: 'plugins/trailhead/hooks', skip: ['plugins/trailhead/hooks/mod'] },
15
+ { dir: 'plugins/trailhead/templates', skip: [] },
16
+ { dir: 'bin', skip: [] },
17
+ ];
18
+
19
+ // Eccezioni motivate: file (relativo alla root) + sottostringa della riga.
20
+ // Niente numeri di riga, cosi' le modifiche attorno non rompono il test.
21
+ const ALLOWLIST = [
22
+ {
23
+ file: 'plugins/trailhead/templates/trailhead-dashboard.js',
24
+ contains: 'Regenerate any time with',
25
+ reason: 'corpo del dashboard: artefatto condiviso host-neutral scritto nell\'issue, non un messaggio runtime per host',
26
+ },
27
+ {
28
+ file: 'bin/trailhead.js',
29
+ contains: '(/trailhead:*)',
30
+ reason: 'banner dell\'installer Claude-only (il ramo Codex stampa il proprio banner)',
31
+ },
32
+ {
33
+ file: 'bin/trailhead.js',
34
+ contains: 'then run /trailhead to start',
35
+ reason: 'banner dell\'installer Claude-only',
36
+ },
37
+ {
38
+ file: 'bin/lib/codex-projection.js',
39
+ contains: 'Never \\`/trailhead:<verb>\\`',
40
+ reason: 'testo del SKILL.md Codex: nomina la forma Claude proprio per vietarla all\'agente',
41
+ },
42
+ {
43
+ file: 'bin/lib/host-descriptor.js',
44
+ contains: "commandPrefix: '/trailhead:'",
45
+ reason: 'dato del descriptor Claude da cui commandForm produce la forma Claude',
46
+ },
47
+ {
48
+ file: 'plugins/trailhead/hooks/lib/model-defaults-review.js',
49
+ contains: '`/trailhead:${verb}`',
50
+ reason: 'helper commandForm della lib: l\'unico punto che produce la forma Claude',
51
+ },
52
+ ];
53
+
54
+ // `/trailhead:` (anche seguito da `${`, `*`, `<verb>` o concatenato) oppure
55
+ // `/trailhead` nudo. Non conta dentro un path: non preceduto da parola, `.`,
56
+ // `~`, `\`, `/`, `-` e non seguito da parola, `:`, `/`, `\`, `-`.
57
+ const COMMAND_RE = /(?<![\w.~\\/-])\/trailhead(?::|(?![\w:/\\-]))/g;
58
+
59
+ function listJs(dir, skip) {
60
+ const out = [];
61
+ for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
62
+ const rel = `${dir}/${e.name}`;
63
+ if (e.isDirectory()) {
64
+ if (e.name === 'node_modules' || skip.includes(rel)) continue;
65
+ out.push(...listJs(rel, skip));
66
+ } else if (e.name.endsWith('.js') && !e.name.endsWith('.test.js')) {
67
+ out.push(rel);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Scanner minimale a stati sull'intero file: marca i caratteri che stanno
74
+ // dentro stringhe ('...', "...") e template (`...`, anche su piu' righe, con i
75
+ // loro ${...}); commenti e literal regex sono saltati. Non e' un parser JS
76
+ // completo: una regex e' riconosciuta dal carattere significativo precedente.
77
+ const REGEX_PREV = new Set(['', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '<', '>', '~', '^']);
78
+ function literalMask(text) {
79
+ const mask = new Uint8Array(text.length);
80
+ let i = 0;
81
+ let prev = '';
82
+ while (i < text.length) {
83
+ const c = text[i];
84
+ const n = text[i + 1];
85
+ if (c === '/' && n === '/') {
86
+ while (i < text.length && text[i] !== '\n') i++;
87
+ } else if (c === '/' && n === '*') {
88
+ const end = text.indexOf('*/', i + 2);
89
+ i = end === -1 ? text.length : end + 2;
90
+ } else if (c === '"' || c === "'" || c === '`') {
91
+ mask[i] = 1;
92
+ i++;
93
+ while (i < text.length && text[i] !== c) {
94
+ if (text[i] === '\\') { mask[i] = 1; i++; }
95
+ if (c !== '`' && text[i] === '\n') break;
96
+ mask[i] = 1;
97
+ i++;
98
+ }
99
+ if (i < text.length) mask[i] = 1;
100
+ i++;
101
+ prev = c;
102
+ } else if (c === '/' && (REGEX_PREV.has(prev) || prev === 'return')) {
103
+ i++;
104
+ let inClass = false;
105
+ while (i < text.length && text[i] !== '\n' && (inClass || text[i] !== '/')) {
106
+ if (text[i] === '\\') i++;
107
+ else if (text[i] === '[') inClass = true;
108
+ else if (text[i] === ']') inClass = false;
109
+ i++;
110
+ }
111
+ i++;
112
+ prev = ')';
113
+ } else {
114
+ if (!/\s/.test(c)) {
115
+ const word = /[A-Za-z_$]/.test(c) ? text.slice(i).match(/^[\w$]+/)[0] : c;
116
+ prev = word === 'return' ? 'return' : word.length > 1 ? 'a' : c;
117
+ i += word.length;
118
+ continue;
119
+ }
120
+ i++;
121
+ }
122
+ }
123
+ return mask;
124
+ }
125
+
126
+ const used = new Set();
127
+
128
+ function findViolations(rel, text) {
129
+ const out = [];
130
+ const mask = literalMask(text);
131
+ const lines = text.split('\n');
132
+ const starts = [];
133
+ let off = 0;
134
+ for (const l of lines) { starts.push(off); off += l.length + 1; }
135
+ for (const m of text.matchAll(COMMAND_RE)) {
136
+ if (!mask[m.index]) continue;
137
+ let ln = starts.findIndex((st, k) => k === starts.length - 1 || starts[k + 1] > m.index);
138
+ const line = lines[ln];
139
+ const idx = ALLOWLIST.findIndex((a) => a.file === rel && line.includes(a.contains));
140
+ if (idx >= 0) { used.add(idx); continue; }
141
+ out.push(`${rel}:${ln + 1}: ${line.trim()}`);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ let passed = 0;
147
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
148
+
149
+ // Sanity del rilevatore: deve fermare il literal originale e ignorare i path.
150
+ ok('detector flags the original review-offer literal',
151
+ findViolations('x.js', " '3) open /trailhead:config to pick per key.',").length === 1);
152
+ ok('detector flags a bare /trailhead in a literal',
153
+ findViolations('x.js', "console.log('run /trailhead to start');").length === 1);
154
+ ok('detector ignores comment lines', findViolations('x.js', '// run /trailhead:update').length === 0);
155
+ ok('detector ignores path segments',
156
+ findViolations('x.js', "path.join('skills/trailhead/hooks', 'refs/heads/trailhead/t1')").length === 0);
157
+ ok('detector flags a concatenated literal', findViolations('x.js', "const c = '/trailhead:' + v;").length === 1);
158
+ ok('detector flags a template literal with interpolation', findViolations('x.js', 'const c = `/trailhead:${v}`;').length === 1);
159
+ ok('detector flags a literal on a later line of a multi-line template',
160
+ findViolations('x.js', 'const t = `first line\nrun /trailhead:config now`;').length === 1);
161
+ ok('detector flags the glob and placeholder forms',
162
+ findViolations('x.js', "a('/trailhead:*'); b('/trailhead:<verb>');").length === 2);
163
+ ok('detector ignores a regex literal with an escaped slash',
164
+ findViolations('x.js', "const R = /^refs\\/heads\\/trailhead\\/t(\\d+)$/; const q = /['\"]/;").length === 0);
165
+ ok('detector ignores the host-neutral helper form',
166
+ findViolations('x.js', 'const c = commandForm(host, "config");').length === 0);
167
+
168
+ // Le eccezioni in allowlist devono ancora corrispondere a qualcosa.
169
+ for (const a of ALLOWLIST) {
170
+ ok(`allowlist file exists: ${a.file}`, fs.existsSync(path.join(ROOT, a.file)));
171
+ ok(`allowlist reason given: ${a.file}`, a.reason.length > 0);
172
+ }
173
+
174
+ const files = SCAN_DIRS.flatMap(({ dir, skip }) => listJs(dir, skip));
175
+ ok('scan covers hook libs, templates and bin', files.length > 20);
176
+ const violations = files.flatMap((rel) => findViolations(rel, fs.readFileSync(path.join(ROOT, rel), 'utf8')));
177
+ assert.deepStrictEqual(violations, [],
178
+ `literal di comando Claude in codice runtime (usa commandForm):\n${violations.join('\n')}`);
179
+ passed++;
180
+ const unused = ALLOWLIST.filter((_, i) => !used.has(i)).map((a) => `${a.file}: ${a.contains}`);
181
+ assert.deepStrictEqual(unused, [], `voci di allowlist mai usate (da rimuovere):\n${unused.join('\n')}`);
182
+ passed++;
183
+
184
+ console.log(`runtime-command-literals.test.js: ${passed} assertions passed`);
@@ -48,7 +48,7 @@ ok('the checker command with its technique flag and a report file',
48
48
  ok('the three templates dirs, in order',
49
49
  (() => {
50
50
  const a = para.indexOf('${CLAUDE_PLUGIN_ROOT}/templates/');
51
- const b = para.indexOf('~/.claude/trailhead/templates/');
51
+ const b = para.indexOf('${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/');
52
52
  const c = para.indexOf('~/.codex/skills/trailhead/templates/');
53
53
  return a !== -1 && b > a && c > b;
54
54
  })());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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.16.0",
3
+ "version": "0.16.1",
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. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",
@@ -19,7 +19,7 @@ Never `cd` (or `pushd`/`env -C`) into a directory first; the full rule is in `_s
19
19
  <!-- trailhead:agent-block reviewing -->
20
20
  **Return contract.** Return the complete report as your final message. Do not post it anywhere (no issue comment, no PR comment): the orchestrator posts the `VERIFY` comment after it checks the status line.
21
21
  - The first line of the report is the technique's `status:` line, verbatim: `status: <token>`, with nothing before it and no other `status:` line.
22
- - trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive`.
23
- - trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n>`.
22
+ - trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive`; each claim line carries `class: <met|unmet|partial|inconclusive>`, and `met` lists no `unmet`, `partial` or `inconclusive` claim.
23
+ - trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n>`; `clean` with a non-zero `critical` count requires a ledger line closing every Critical (`fixed`, `deferred` or `accepted`), none `open`.
24
24
  - Read-only: never Edit/Write, never commit.
25
25
  <!-- /trailhead:agent-block reviewing -->
@@ -19,7 +19,7 @@ Never `cd` (or `pushd`/`env -C`) into a directory first; the full rule is in `_s
19
19
  <!-- trailhead:agent-block reviewing -->
20
20
  **Return contract.** Return the complete report as your final message. Do not post it anywhere (no issue comment, no PR comment): the orchestrator posts the `VERIFY` comment after it checks the status line.
21
21
  - The first line of the report is the technique's `status:` line, verbatim: `status: <token>`, with nothing before it and no other `status:` line.
22
- - trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive`.
23
- - trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n>`.
22
+ - trailhead-verify: `<token>` is one of `met` | `gaps_found` | `inconclusive`; each claim line carries `class: <met|unmet|partial|inconclusive>`, and `met` lists no `unmet`, `partial` or `inconclusive` claim.
23
+ - trailhead-code-review: `<token>` is one of `clean` | `issues_found`, and line 2 is the counts line `critical: <n> · warning: <n> · info: <n>`; `clean` with a non-zero `critical` count requires a ledger line closing every Critical (`fixed`, `deferred` or `accepted`), none `open`.
24
24
  - Read-only: never Edit/Write, never commit.
25
25
  <!-- /trailhead:agent-block reviewing -->
@@ -17,6 +17,25 @@ const DATA_FILE = path.join(__dirname, 'model-defaults.json');
17
17
  const HOSTS = ['claude', 'codex'];
18
18
  const TIERS = ['strong', 'standard', 'fast'];
19
19
 
20
+ // Host: la lib viene copiata byte-per-byte su Codex (sotto
21
+ // <codexHome>/skills/trailhead/hooks/lib), quindi l'host si deduce dal path
22
+ // installato, senza env (stessa idea di trailhead-check-update.js). Un
23
+ // argomento `host` esplicito sovrascrive la rilevazione (test, host futuri).
24
+ const CODEX_LIB_RE = /[\\/]skills[\\/]trailhead[\\/]hooks[\\/]lib$/;
25
+
26
+ function detectHost(dir = __dirname) {
27
+ return CODEX_LIB_RE.test(dir) ? 'codex' : 'claude';
28
+ }
29
+
30
+ function normalizeHost(host) {
31
+ return HOSTS.includes(host) ? host : detectHost();
32
+ }
33
+
34
+ // Forma del comando per host: `/trailhead:<verb>` su Claude, `$trailhead <verb>` su Codex.
35
+ function commandForm(host, verb) {
36
+ return normalizeHost(host) === 'codex' ? `$trailhead ${verb}` : `/trailhead:${verb}`;
37
+ }
38
+
20
39
  function isPlainObject(v) {
21
40
  return !!v && typeof v === 'object' && !Array.isArray(v);
22
41
  }
@@ -187,7 +206,7 @@ function effectiveModels(layers) {
187
206
  return { config: { models: outModels }, sources };
188
207
  }
189
208
 
190
- function reviewOffer({ config, data, ack, sources }) {
209
+ function reviewOffer({ config, data, ack, sources, host }) {
191
210
  const current = currentEntry(data);
192
211
  if (!current) return null;
193
212
  const stale = stalePins(config, data).map((s) => {
@@ -196,7 +215,7 @@ function reviewOffer({ config, data, ack, sources }) {
196
215
  });
197
216
  if (!stale.length) return null;
198
217
  if (ack && !semverLt(ack, current.since)) return null;
199
- return { since: current.since, stale, notice: reviewNotice(stale, current.since) };
218
+ return { since: current.since, stale, notice: reviewNotice(stale, current.since, host) };
200
219
  }
201
220
 
202
221
  function sourceSuffix(source) {
@@ -205,7 +224,7 @@ function sourceSuffix(source) {
205
224
  return ` (new default; from ${layerName}, ${source.file})`;
206
225
  }
207
226
 
208
- function reviewNotice(stale, since) {
227
+ function reviewNotice(stale, since, host) {
209
228
  const lines = stale.map((s) => `- ${s.key}: ${s.from} -> ${s.to}${sourceSuffix(s.source)}`);
210
229
  return [
211
230
  `trailhead's default models changed in ${since}. The following pinned model(s) are older than the new default:`,
@@ -214,7 +233,7 @@ function reviewNotice(stale, since) {
214
233
  'Offer the user, once, without blocking their current request, three choices:',
215
234
  '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
235
  '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.',
236
+ `3) open ${commandForm(host, 'config')} to pick per key.`,
218
237
  '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
238
  ].join('\n');
220
239
  }
@@ -231,25 +250,33 @@ function ackInstruction(root) {
231
250
  const ACK_FILE = path.join('.trailhead', 'model-defaults-ack');
232
251
  const CONFIG_FILE = path.join('.trailhead', 'config.json');
233
252
 
234
- // Dir base della config globale: stessa risoluzione di trailhead-check-update.js.
235
- function globalConfigDir() {
253
+ // Dir base della config globale. Claude: CLAUDE_CONFIG_DIR o ~/.claude (come
254
+ // trailhead-check-update.js).
255
+ // Su Codex e' <codexHome>, ricavato dal path installato della lib (4 livelli
256
+ // sopra __dirname: <codexHome>/skills/trailhead/hooks/lib); con host forzato
257
+ // fuori da un'installazione Codex ripiega su CODEX_HOME o ~/.codex.
258
+ function globalConfigDir(host) {
259
+ if (normalizeHost(host) === 'codex') {
260
+ if (detectHost() === 'codex') return path.resolve(__dirname, '..', '..', '..', '..');
261
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
262
+ }
236
263
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
237
264
  }
238
265
 
239
- function globalConfigFile() {
240
- return path.join(globalConfigDir(), 'trailhead', 'config.json');
266
+ function globalConfigFile(host) {
267
+ return path.join(globalConfigDir(host), 'trailhead', 'config.json');
241
268
  }
242
269
 
243
270
  // L'ack globale vive accanto al config.json globale, senza nidificare
244
271
  // ".trailhead" (che e' una convenzione solo di progetto).
245
- function globalAckFile() {
246
- return path.join(globalConfigDir(), 'trailhead', 'model-defaults-ack');
272
+ function globalAckFile(host) {
273
+ return path.join(globalConfigDir(host), 'trailhead', 'model-defaults-ack');
247
274
  }
248
275
 
249
276
  // Mancante o malformato -> {}: non deve mai bloccare la review di progetto.
250
- function loadGlobalConfig() {
277
+ function loadGlobalConfig(host) {
251
278
  try {
252
- const raw = JSON.parse(fs.readFileSync(globalConfigFile(), 'utf8'));
279
+ const raw = JSON.parse(fs.readFileSync(globalConfigFile(host), 'utf8'));
253
280
  return isPlainObject(raw) ? raw : {};
254
281
  } catch {
255
282
  return {};
@@ -296,13 +323,13 @@ function writeAck(root, since) {
296
323
  // non arriva un ack esplicito via ack(cwd). Valuta i pin EFFETTIVI: la
297
324
  // config globale unita sotto quella di progetto (progetto vince chiave per
298
325
  // chiave); senza un progetto sopra cwd, valuta la sola config globale.
299
- function check(cwd) {
326
+ function check(cwd, { host } = {}) {
300
327
  try {
301
328
  const resolvedCwd = path.resolve(cwd || '.');
302
329
  const root = findProjectRoot(cwd);
303
330
  const data = loadData();
304
- const globalFile = globalConfigFile();
305
- const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig() };
331
+ const globalFile = globalConfigFile(host);
332
+ const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig(host) };
306
333
 
307
334
  if (root) {
308
335
  let projectConfig;
@@ -318,7 +345,7 @@ function check(cwd) {
318
345
  };
319
346
  const { config, sources } = effectiveModels([globalLayer, projectLayer]);
320
347
  const ack = readAck(root);
321
- const offer = reviewOffer({ config, data, ack, sources });
348
+ const offer = reviewOffer({ config, data, ack, sources, host });
322
349
  if (!offer) return null;
323
350
  return `${offer.notice}\n${ackInstruction(root)}`;
324
351
  }
@@ -326,8 +353,8 @@ function check(cwd) {
326
353
  // Nessun progetto sopra cwd: valuta la sola config globale, se esiste.
327
354
  if (!fs.existsSync(globalFile)) return null;
328
355
  const { config, sources } = effectiveModels([globalLayer]);
329
- const ack = readAckFile(globalAckFile());
330
- const offer = reviewOffer({ config, data, ack, sources });
356
+ const ack = readAckFile(globalAckFile(host));
357
+ const offer = reviewOffer({ config, data, ack, sources, host });
331
358
  if (!offer) return null;
332
359
  return `${offer.notice}\n${ackInstruction(resolvedCwd)}`;
333
360
  } catch {
@@ -341,7 +368,7 @@ function check(cwd) {
341
368
  // globale -> ack globale. True se un target e' stato trovato e la scrittura
342
369
  // tentata (un fallimento silenzioso non e' riportato, comportamento
343
370
  // invariato); false se nessun target esiste. Non lancia mai eccezioni.
344
- function ack(cwd) {
371
+ function ack(cwd, { host } = {}) {
345
372
  try {
346
373
  const root = findProjectRoot(cwd);
347
374
  const data = loadData();
@@ -351,8 +378,8 @@ function ack(cwd) {
351
378
  writeAck(root, current.since);
352
379
  return true;
353
380
  }
354
- if (fs.existsSync(globalConfigFile())) {
355
- writeAckFile(globalAckFile(), current.since);
381
+ if (fs.existsSync(globalConfigFile(host))) {
382
+ writeAckFile(globalAckFile(host), current.since);
356
383
  return true;
357
384
  }
358
385
  return false;
@@ -370,6 +397,8 @@ module.exports = {
370
397
  effectiveModels,
371
398
  reviewOffer,
372
399
  reviewNotice,
400
+ detectHost,
401
+ commandForm,
373
402
  findProjectRoot,
374
403
  globalConfigDir,
375
404
  globalConfigFile,
@@ -480,6 +480,56 @@ ok('stalePins: a non-object models.codex yields no codex pins (not an exception)
480
480
  ackOut === '' && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
481
481
  }
482
482
 
483
+ // --- host-aware command form e global config (Codex) --------------------
484
+ {
485
+ const stale = [{ key: 'models.plan', from: 'claude-opus-4-8', to: 'claude-opus-5-5' }];
486
+ const codexNotice = reviewNotice(stale, cur.since, 'codex');
487
+ const claudeNotice = reviewNotice(stale, cur.since, 'claude');
488
+ ok('reviewNotice(codex) names $trailhead config', codexNotice.includes('open $trailhead config'));
489
+ ok('reviewNotice(codex) never names the Claude slash form', !codexNotice.includes('/trailhead:'));
490
+ ok('reviewNotice(claude) names /trailhead:config', claudeNotice.includes('open /trailhead:config'));
491
+ ok('reviewNotice default host is claude outside an installed Codex path',
492
+ reviewNotice(stale, cur.since).includes('/trailhead:config'));
493
+ }
494
+
495
+ {
496
+ // Lib copiata sotto un finto <codexHome>/skills/trailhead/hooks/lib/: l'host
497
+ // si deduce dal path, senza env, e la config globale e' <codexHome>/trailhead/.
498
+ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codexhome-'));
499
+ const libDir = path.join(codexHome, 'skills', 'trailhead', 'hooks', 'lib');
500
+ fs.mkdirSync(libDir, { recursive: true });
501
+ for (const f of ['model-defaults-review.js', 'model-defaults.json']) {
502
+ fs.copyFileSync(path.join(__dirname, f), path.join(libDir, f));
503
+ }
504
+ fs.mkdirSync(path.join(codexHome, 'trailhead'), { recursive: true });
505
+ fs.writeFileSync(path.join(codexHome, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
506
+ const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codex-noproj-'));
507
+ const copied = path.join(libDir, 'model-defaults-review.js');
508
+
509
+ const out = execFileSync('node', [copied, 'check', noProjDir], {
510
+ encoding: 'utf8',
511
+ env: { ...process.env, CLAUDE_CONFIG_DIR: BASE_CONFIG_DIR, CODEX_HOME: '' },
512
+ });
513
+ ok('Codex path: check() honours <codexHome>/trailhead/config.json', out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
514
+ ok('Codex path: the notice offers $trailhead config', out.includes('open $trailhead config'));
515
+ ok('Codex path: the notice never names /trailhead:', !out.includes('/trailhead:'));
516
+ ok('Codex path: the notice tags the Codex global config path',
517
+ out.includes(path.join(codexHome, 'trailhead', 'config.json')));
518
+
519
+ execFileSync('node', [copied, 'ack', noProjDir], {
520
+ encoding: 'utf8',
521
+ env: { ...process.env, CLAUDE_CONFIG_DIR: BASE_CONFIG_DIR, CODEX_HOME: '' },
522
+ });
523
+ ok('Codex path: the global ack lands under <codexHome>/trailhead/',
524
+ fs.existsSync(path.join(codexHome, 'trailhead', 'model-defaults-ack')));
525
+
526
+ const mod = require(copied);
527
+ ok('Codex path: globalConfigDir resolves to the codexHome', mod.globalConfigDir() === codexHome);
528
+ ok('Codex path: check() is silenced after the Codex global ack', mod.check(noProjDir) === null);
529
+ ok('Claude behaviour unchanged: globalConfigDir still follows CLAUDE_CONFIG_DIR',
530
+ globalConfigDir() === BASE_CONFIG_DIR);
531
+ }
532
+
483
533
  console.log(`model-defaults-review.test.js: ${passed} assertions passed`);
484
534
 
485
535
  if (PRIOR_CLAUDE_CONFIG_DIR === undefined) {
@@ -16,13 +16,15 @@ const fs = require('fs');
16
16
  const os = require('os');
17
17
 
18
18
  const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-report-status.js');
19
- const { checkReport, TOKENS, CONTRACTS, FAMILY_OF } = require(scriptPath);
19
+ const { checkReport, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES } = require(scriptPath);
20
20
  const techDir = path.resolve(__dirname, '..', '..', 'skills', '_shared', 'techniques');
21
21
 
22
22
  let passed = 0;
23
23
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
24
24
 
25
25
  const K = 'critical: 0 · warning: 1 · info: 2'; // the counts line
26
+ const K1 = 'critical: 1 · warning: 0 · info: 0';
27
+ const K2 = 'critical: 2 · warning: 1 · info: 0';
26
28
  const V = 'verify';
27
29
  const C = 'code-review';
28
30
  const KEYS = ['action', 'verdict', 'problem', 'report', 'fields', 'body'];
@@ -119,6 +121,60 @@ const rows = [
119
121
  ['two counts lines', C, `status: clean\n${K}\nbody\n${K}\n`, ['reask', null, 'counts']],
120
122
  ['two different counts lines', C, `status: clean\n${K}\ncritical: 1 · warning: 1 · info: 2\n`, ['reask', null, 'counts']],
121
123
  ['counts only inside a code fence', C, `status: clean\n\`\`\`\n${K}\n\`\`\`\n`, ['reask', null, 'counts']],
124
+
125
+ // --- verdict vs content: code-review fails closed on an unclosed Critical ---
126
+ ['clean with critical 2 and no ledger (ticket repro)', C, `status: clean\n${K2}\n\n- critical: SQL injection\n`,
127
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\n\n- critical: SQL injection\n`]],
128
+ ['clean, critical 1, C1 fixed', C, `status: clean\n${K1}\nC1 fixed (abc123)\n`, ['accept', 'clean', null]],
129
+ ['clean, critical 1, C1 deferred', C, `status: clean\n${K1}\n- C1: deferred → #12\n`, ['accept', 'clean', null]],
130
+ ['clean, critical 1, C1 accepted', C, `status: clean\n${K1}\nC1 accepted: false positive\n`, ['accept', 'clean', null]],
131
+ ['clean, critical 2, only C1 closed', C, `status: clean\n${K2}\nC1 fixed (a)\n`,
132
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\n`]],
133
+ ['clean, critical 1, C1 open', C, `status: clean\n${K1}\nC1 open\n`,
134
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 open\n`]],
135
+ ['clean, critical 1, C1 fixed and C2 open', C, `status: clean\n${K1}\nC1 fixed (a)\nC2 open\n`,
136
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed (a)\nC2 open\n`]],
137
+ ['clean, critical 1, only a reclassified pointer', C, `status: clean\n${K1}\nC1 reclassified as W3\n`,
138
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 reclassified as W3\n`]],
139
+ ['clean, critical 1, ledger line only inside a fence', C, `status: clean\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`,
140
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`]],
141
+ ['clean, critical 2, a repeated C1 line counts once', C, `status: clean\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`,
142
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`]],
143
+ ['finding prose with open after a closed ledger line stays clean', C, `status: clean\n${K1}\nC1: fixed (abc)\nC1 file.js:3 handler leaves the socket open\n`, ['accept', 'clean', null]],
144
+ ['open redirect prose with a fixed ledger stays clean', C, `status: clean\n${K1}\nC1 fixed (abc)\nC1 open redirect in login.js:9\n`, ['accept', 'clean', null]],
145
+ ['a lone fixed-size prose line does not close the Critical', C, `status: clean\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`,
146
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`]],
147
+ ['bold id and en-dash separator ledger line', C, `status: clean\n${K1}\n- **C1** – fixed (abc)\n`, ['accept', 'clean', null]],
148
+ ['verify subclass is not a class line', V, 'status: met\n- a, subclass: unmet\n', ['accept', 'met', null]],
149
+ ['an unparsed open line for an uncounted Critical fails closed', C, `status: clean\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`,
150
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`]],
151
+ ['em dash separator closes the Critical', C, `status: clean\n${K1}\nC1 ${String.fromCharCode(0x2014)} fixed (abc)\n`, ['accept', 'clean', null]],
152
+ ['still open fails closed', C, `status: clean\n${K1}\nC1: still open\nC2 fixed (a)\n`,
153
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1: still open\nC2 fixed (a)\n`]],
154
+ ['bracketed open fails closed', C, `status: clean\n${K1}\nC1 [open]\nC2 fixed (a)\n`,
155
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 [open]\nC2 fixed (a)\n`]],
156
+ ['a reclassified Critical does not gate the rest', C, `status: clean\n${K1}\nC1 reclassified as W3\nC2 fixed (a)\n`, ['accept', 'clean', null]],
157
+ ['clean, critical 0, warnings open never gate', C, `status: clean\n${K}\nW1 open\n`, ['accept', 'clean', null]],
158
+ ['issues_found is never changed', C, `status: issues_found\n${K1}\nC1 fixed (a)\n`, ['accept', 'issues_found', null]],
159
+
160
+ // --- verdict vs content: verify raises the tag to the highest claim class ---
161
+ ['verify met with a met claim', V, 'status: met\n- claim 1, class: met\n', ['accept', 'met', null]],
162
+ ['verify met with an unmet claim', V, 'status: met\n- Claim 2: login wired, class: unmet\n',
163
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- Claim 2: login wired, class: unmet\n']],
164
+ ['verify met with a partial claim', V, 'status: met\n- claim 1, class: partial\n',
165
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- claim 1, class: partial\n']],
166
+ ['verify met with an inconclusive claim', V, 'status: met\n- claim 1, class: inconclusive\n reason: unrunnable-check\n',
167
+ ['normalise', 'inconclusive', null, 'status: inconclusive\n- claim 1, class: inconclusive\n reason: unrunnable-check\n']],
168
+ ['verify met with inconclusive and unmet', V, 'status: met\n- a, class: inconclusive\n- b, class: unmet\n',
169
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- a, class: inconclusive\n- b, class: unmet\n']],
170
+ ['verify inconclusive with an unmet claim', V, 'status: inconclusive\n- b, class: unmet\n',
171
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- b, class: unmet\n']],
172
+ ['verify gaps_found with only met claims stays', V, 'status: gaps_found\n- a, class: met\n', ['accept', 'gaps_found', null]],
173
+ ['verify inconclusive with only met claims stays', V, 'status: inconclusive\n- a, class: met\n', ['accept', 'inconclusive', null]],
174
+ ['verify class line inside a fence is ignored', V, 'status: met\n```\n- a, class: unmet\n```\n', ['accept', 'met', null]],
175
+ ['verify class line inside a blockquote is ignored', V, 'status: met\n> - a, class: unmet\n', ['accept', 'met', null]],
176
+ ['verify upper-case class counts', V, 'status: met\n- a, Class: UNMET\n',
177
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- a, Class: UNMET\n']],
122
178
  ];
123
179
 
124
180
  for (const [name, technique, input, expected] of rows) {
@@ -274,6 +330,17 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
274
330
  ok(`contracts: technique ${technique} maps to the family of trailhead-${technique} in the sync script`, FAMILIES[`trailhead-${technique}`] === family);
275
331
  ok(`contracts: TOKENS.${technique} are the ${family} tokens`, JSON.stringify(TOKENS[technique]) === JSON.stringify(CONTRACTS[family].tokens));
276
332
  }
333
+ // the reviewing block names the verify class vocabulary and the closing dispositions
334
+ const bullet = (block, prefix) => block.split('\n').find((l) => l.startsWith(`- ${prefix}:`)) || '';
335
+ const classTokens = (text) => [...text.matchAll(/class: <([a-z|]+)>/g)].flatMap((m) => m[1].split('|'));
336
+ ok('contracts: reviewing verify bullet names the checker class vocabulary',
337
+ JSON.stringify(classTokens(bullet(blocks.reviewing, 'trailhead-verify'))) === JSON.stringify(VERIFY_CLASSES));
338
+ const crBullet = bullet(blocks.reviewing, 'trailhead-code-review');
339
+ ok('contracts: reviewing code-review bullet names the closing dispositions',
340
+ ['fixed', 'deferred', 'accepted'].every((d) => new RegExp(`\\x60${d}\\x60`).test(crBullet)));
341
+ const verifyDoc = fs.readFileSync(path.join(techDir, 'verify.md'), 'utf8');
342
+ ok('contracts: verify.md names the same class vocabulary',
343
+ classTokens(verifyDoc.replace(/class: ([a-z]+(?: \| [a-z]+)+)/g, (m, g) => `class: <${g.replace(/ \| /g, '|')}>`)).join() === VERIFY_CLASSES.join());
277
344
  ok('contracts: every non-review agent has a technique here',
278
345
  Object.entries(FAMILIES).filter(([, f]) => f !== 'reviewing').every(([a]) => FAMILY_OF[a.replace(/^trailhead-/, '')] !== undefined));
279
346
  }
@@ -633,6 +633,23 @@ const SEARCH_VERB_CONFIG = {
633
633
  awk: { patternFirst: true, valueFlags: new Set(['-f', '-v']), patternFlags: new Set() },
634
634
  };
635
635
 
636
+ // #316: the -f / --file operand of a flag token, or null when the token is not one.
637
+ // Forms: `-f V`, `-fV`, a short cluster whose FIRST value-taking letter is `f`
638
+ // (`-if.env`; after another value letter the rest is that flag's value, so
639
+ // `-ef.env` is a pattern), `--file V`, `--file=V`. `consumed` = extra tokens used.
640
+ function fileFlagOperand(t, next, config) {
641
+ if (t === '--file') return { value: next, consumed: 1 };
642
+ if (t.startsWith('--file=')) return { value: t.slice('--file='.length), consumed: 0 };
643
+ if (t.startsWith('--')) return null;
644
+ for (let i = 1; i < t.length; i++) {
645
+ if (!config.valueFlags.has(`-${t[i]}`)) continue;
646
+ if (t[i] !== 'f') return null;
647
+ const rest = t.slice(i + 1);
648
+ return rest ? { value: rest, consumed: 0 } : { value: next, consumed: 1 };
649
+ }
650
+ return null;
651
+ }
652
+
636
653
  // Walk a verb's argument tokens (verb itself already stripped), skipping
637
654
  // flags (and their separate-token values), an explicit `--` end-of-options
638
655
  // marker (which must not itself swallow the next real positional), and the
@@ -647,6 +664,14 @@ function scanArgsSkippingPattern(args, config, isMatch) {
647
664
  const t = args[j];
648
665
  if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
649
666
  if (!afterDashDash && t.startsWith('-') && t !== '-') {
667
+ // #316: the value of -f / --file is a FILE the verb reads, so it is tested.
668
+ const file = fileFlagOperand(t, args[j + 1], config);
669
+ if (file) {
670
+ if (file.value !== undefined && file.value !== '' && isMatch(file.value)) return file.value;
671
+ patternConsumed = true; // the pattern/script comes from that file, so positionals are FILEs
672
+ j += file.consumed;
673
+ continue;
674
+ }
650
675
  if (config.valueFlags.has(t)) {
651
676
  j++; // consume its separate-token value
652
677
  if (config.patternFlags && config.patternFlags.has(t)) patternConsumed = true;
@@ -136,5 +136,26 @@ eq('edge threshold', pct(1000, edge), 100);
136
136
  // finestra minuscola: soglia mai sotto 1
137
137
  eq('tiny window guard', pct(5, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '10' } }), 100);
138
138
 
139
- fs.rmSync(root, { recursive: true, force: true });
139
+ // --- credenziali: il blocco usage legge .credentials.json da CLAUDE_CONFIG_DIR (#320) ---
140
+ // Segnale osservabile: la dir cache viene creata solo se il guard sulle credenziali passa.
141
+ function usageReached({ withCreds }) {
142
+ const credCfg = path.join(root, withCreds ? 'cfg-creds' : 'cfg-nocreds');
143
+ const cache = path.join(root, withCreds ? 'cache-creds' : 'cache-nocreds');
144
+ fs.mkdirSync(credCfg, { recursive: true });
145
+ fs.mkdirSync(cache, { recursive: true });
146
+ if (withCreds) {
147
+ fs.writeFileSync(path.join(credCfg, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: 'dummy' } }));
148
+ }
149
+ const env = { ...process.env, NO_COLOR: '1', HOME: home, CLAUDE_CONFIG_DIR: credCfg, XDG_CACHE_HOME: cache,
150
+ USAGE_API_TIMEOUT: '1', HTTPS_PROXY: 'http://127.0.0.1:9', https_proxy: 'http://127.0.0.1:9' };
151
+ delete env.TRAILHEAD_STATUSLINE_USAGE;
152
+ const input = { model: { id: 'claude-opus-5-5', display_name: 'Test' }, workspace: { project_dir: proj, current_dir: proj } };
153
+ spawnSync('bash', [script, '--context-only'], { input: JSON.stringify(input), env, encoding: 'utf8' });
154
+ return fs.existsSync(path.join(cache, 'trailhead-statusline'));
155
+ }
156
+ ok('usage block reached with creds under CLAUDE_CONFIG_DIR', usageReached({ withCreds: true }));
157
+ ok('usage block skipped without creds', !usageReached({ withCreds: false }));
158
+
159
+ // retry: il python in background puo' ancora scrivere nella cache mentre si pulisce
160
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
140
161
  console.log(`statusline-context.test.js: ${passed} assertions passed`);
@@ -62,6 +62,11 @@ const WRAPPER_VALUE_FLAGS = {
62
62
  nohup: [],
63
63
  };
64
64
 
65
+ // Is `word` (as written, quotes allowed) a command wrapper from the list above? #316
66
+ function isWrapperWord(word) {
67
+ return Object.prototype.hasOwnProperty.call(WRAPPER_VALUE_FLAGS, dequote(word));
68
+ }
69
+
65
70
  // Index just past a wrapper's own flags (and assignments), from the wrapper at toks[k].
66
71
  function skipWrapper(toks, k) {
67
72
  const w = dequote(toks[k]);
@@ -342,4 +347,4 @@ function unmodelledReason(guard, what, hit, redact) {
342
347
  `interpret with certainty, so it cannot verify ${what}. Rewrite it in a simple form: ${hit.hint}.`;
343
348
  }
344
349
 
345
- module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason };
350
+ module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason, isWrapperWord };