@marcomigozzi/trailhead 0.16.0 → 0.17.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.
Files changed (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -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`);
@@ -35,23 +35,31 @@ ok('the paragraph sits next to "Reviews never block the session"',
35
35
  start !== -1 && neverBlock !== -1 && Math.abs(start - neverBlock) < 2500 && start > neverBlock);
36
36
  const para = start === -1 ? '' : techniques.slice(start, techniques.indexOf('\n| Technique |', start));
37
37
  const lc = para.toLowerCase();
38
+ ok('an inline grill round writes no report, so it is not checked',
39
+ para.includes('An inline `grill` round writes no report') && lc.includes('only a dispatched `trailhead-grill` report is checked'));
38
40
  ok('the paragraph section was found and is bounded', para.length > 1000 && !para.includes('| **Grilling**'));
39
41
 
40
42
  ok('it MUST run on every engine-agent report, the two reviews included', /MUST run on every/.test(para)
41
43
  && para.includes('Code review') && para.includes('Goal-backward verification'));
42
44
  ok('it names every technique it covers',
43
- ['plan', 'executor', 'fix', 'debug', 'research', 'codebase-map'].every((t) => para.includes(t)) && lc.includes('every engine-agent report'));
45
+ ['plan', 'executor', 'fix', 'debug', 'research', 'codebase-map', 'grill'].every((t) => para.includes(t)) && lc.includes('every engine-agent report'));
44
46
  ok('the checker line carries fields and body', para.includes('{"action","verdict","problem","report","fields","body"}'));
45
47
  ok('it covers subagent, inline and the lean inline pass', lc.includes('subagent') && lc.includes('inline') && lc.includes('lean inline pass'));
46
48
  ok('the checker command with its technique flag and a report file',
47
- para.includes('node <templates>/trailhead-report-status.js --technique <verify|code-review|plan|executor|fix|debug|research|codebase-map> --file <that dir>/report.md'));
49
+ para.includes('node <templates>/trailhead-report-status.js --technique <verify|code-review|plan|executor|fix|debug|research|codebase-map|grill> --file <that dir>/report.md'));
48
50
  ok('the three templates dirs, in order',
49
51
  (() => {
50
52
  const a = para.indexOf('${CLAUDE_PLUGIN_ROOT}/templates/');
51
- const b = para.indexOf('~/.claude/trailhead/templates/');
53
+ const b = para.indexOf('${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/');
52
54
  const c = para.indexOf('~/.codex/skills/trailhead/templates/');
53
55
  return a !== -1 && b > a && c > b;
54
56
  })());
57
+ ok('a background agent is checked straight from its task output file, no temp dir',
58
+ para.includes('--task-output <that output file>') && lc.includes('once the agent has finished') && lc.includes('no temp dir, no copy and no cleanup'));
59
+ ok('the temp-dir route is for any other report', lc.includes('any other report (inline, foreground, or a host without that file)'));
60
+ ok('each step is its own Bash call, never bash -c, never chained',
61
+ lc.includes('its own bash call') && para.includes('never wrapped in `bash -c` / `sh -c`') && lc.includes('never chained')
62
+ && lc.includes('cannot statically check'));
55
63
  ok('the plugin root dir is skipped when unset', lc.includes('skipped when unset'));
56
64
  ok('a private temp dir from mktemp -d, once per report', para.includes('`mktemp -d`') && lc.includes('once') && lc.includes('0700'));
57
65
  ok('the raw report is written to report.md at the literal printed path with the Write tool',
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // Docs test for #333: the in-session trust rule accepts the same labellers as the label guard
3
+ // (github-actions[bot], TRAILHEAD_LABEL_ALLOWLIST logins, write+ collaborators). Plain asserts.
4
+ // Run: node bin/session-trust-docs.test.js
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+ const skills = path.join(root, 'plugins', 'trailhead', 'skills');
11
+ const tw = fs.readFileSync(path.join(skills, '_shared', 'teamwork.md'), 'utf8');
12
+ const inbox = fs.readFileSync(path.join(skills, 'trailhead-chart', 'references', 'inbox.md'), 'utf8');
13
+ const s = tw.indexOf('## Trust & provenance');
14
+ const e = tw.indexOf('**Repo-side enforcement');
15
+ const trust = s < 0 || e < s ? '' : tw.slice(s, e);
16
+ let passed = 0;
17
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
18
+
19
+ ok('teamwork.md has the trust section', trust !== '');
20
+ ok('bot is a trusted labeller', /`github-actions\[bot\]`[^]*trusted/i.test(trust));
21
+ ok('bot why: GITHUB_TOKEN events trigger no workflow', /GITHUB_TOKEN[^]*(trigger|run)s? no workflow[^]*guard/i.test(trust));
22
+ ok('allowlist read command', trust.includes('gh variable get TRAILHEAD_LABEL_ALLOWLIST'));
23
+ ok('allowlist: not found means empty', /not found[^]*(means|->|is) empty/i.test(trust));
24
+ ok('allowlist: other failure means unknown, not empty', /any other failure[^]*unknown/i.test(trust) && /unknown, not empty/i.test(trust));
25
+ ok('allowlist compare: comma-separated, trimmed, case-insensitive', /comma-separated[^]*trimmed[^]*case-insensitive/i.test(trust));
26
+ ok('unknown allowlist: held off frontier, provenance not checkable', /unknown[^]*held? off the frontier[^]*provenance not checkable/i.test(trust));
27
+ ok('never labelled unverified on a guess', /never[^.]*`trailhead:unverified`[^.]*guess/i.test(trust));
28
+ ok('failed permission lookup is unresolved too', /permission lookup (failed|fails)/i.test(trust) && /`404`[^]*real answer/i.test(trust));
29
+ ok('per-label provenance: latest labeled event of every label', /every `trailhead:\*` label[^]*latest `labeled` event[^]*all[^.]*trusted/i.test(trust));
30
+ ok('one trusted label does not vouch for another', /does not vouch for another/i.test(trust));
31
+ ok('still quarantines other non-write labellers', /`read`\/`none`[^]*not trusted/i.test(trust) && trust.includes('`trailhead:unverified`'));
32
+ ok('session and guard agree', /never strips/i.test(trust));
33
+ ok('inbox: session applies unverified, guard strips', /`trailhead:unverified`[^.]*applied by a session[^]*guard Action strips/i.test(inbox));
34
+ ok('inbox: rogue means outside the trusted labellers', /trusted labellers[^]*Trust rule|Trust rule[^]*trusted labellers/i.test(inbox) && /teamwork\.md#trust--provenance/.test(inbox));
35
+ ok('inbox: jq includes quarantined issues', /\| any\(startswith\("trailhead:"\)\) \| not\) or \(\[\.labels\[\]\.name\] \| any\(\. == "trailhead:unverified"\)\)/.test(inbox));
36
+ ok('no em-dash', !tw.includes(String.fromCharCode(0x2014)) && !inbox.includes(String.fromCharCode(0x2014)));
37
+ console.log(`session-trust-docs.test.js: ${passed} checks passed`);
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Docs test: un ticket aperto a metà lavoro va sulla mappa corrente (o su quella giusta)
3
+ // se il tema è suo; altrimenti si chiede fra mappe aperte e whiteboard, mai whiteboard
4
+ // in silenzio; dopo la creazione si dice dove è stato aperto. I rimandi puntano al file.
5
+ // Plain asserts, no framework. Run: node bin/surfaced-scope-docs.test.js
6
+ const assert = require('assert');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const root = path.resolve(__dirname, '..');
11
+ const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
12
+ let passed = 0;
13
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
14
+ const EM = String.fromCharCode(0x2014);
15
+
16
+ const SK = 'plugins/trailhead/skills';
17
+ const REF = 'Scope that surfaces while working a ticket';
18
+
19
+ // La regola condivisa
20
+ const rule = read(`${SK}/_shared/surfaced-scope.md`);
21
+ ok('rule: titled after the referenced name', rule.startsWith(`# ${REF}`));
22
+ ok('rule: do it, split it, or capture it', /\*\*`split`\*\* X/.test(rule) && /capture and continue/.test(rule));
23
+ ok('rule: Surfaced from lineage line', /`Surfaced from: /.test(rule));
24
+ ok('rule: current map first when the topic is its own', /1\. \*\*The current map[^\n]*no ask/.test(rule));
25
+ ok('rule: another open map when the topic clearly is its', /2\. \*\*Another open map[^\n]*no ask/.test(rule));
26
+ ok('rule: otherwise ask, listing every open map and the whiteboard', /3\. \*\*Otherwise, ask\.\*\*[^\n]*AskUserQuestion[^\n]*every open map[^\n]*whiteboard/.test(rule));
27
+ ok('rule: never the whiteboard silently while a map is open', /Never route to the whiteboard without this ask while any map is open/.test(rule));
28
+ ok('rule: no map open falls to the whiteboard', /4\. \*\*No map open → whiteboard, no ask\*\*/.test(rule));
29
+ ok('rule: auto defers an ask case instead of guessing', /autonomous run[^\n]*rule 3[^\n]*Pending follow-up guard/.test(rule));
30
+ ok('rule: announce the ticket and where it landed', /## Say what you opened, and where/.test(rule) && /\*\*where it landed\*\*/.test(rule));
31
+ ok('rule: no em-dash', !rule.includes(EM));
32
+
33
+ // Ogni rimando al nome porta al file (nessun rimando orfano)
34
+ const referrers = [
35
+ `${SK}/_shared/session-handoff.md`,
36
+ `${SK}/_shared/techniques/code-review.md`,
37
+ `${SK}/trailhead-work/SKILL.md`,
38
+ `${SK}/trailhead-work/references/ticket-engines.md`,
39
+ ];
40
+ for (const rel of referrers) {
41
+ const text = read(rel);
42
+ const lines = text.split('\n').filter((l) => l.includes(REF));
43
+ ok(`${rel}: mentions the rule`, lines.length > 0);
44
+ ok(`${rel}: every mention links surfaced-scope.md`, lines.every((l) => /surfaced-scope\.md/.test(l)));
45
+ ok(`${rel}: no "served by the capture cluster" dangling pointer`, lines.every((l) => !/served by the capture cluster/.test(l)));
46
+ }
47
+
48
+ // Capture e UAT seguono la stessa regola
49
+ const capture = read(`${SK}/trailhead-capture/references/capture.md`);
50
+ // #330: capture.md no longer links the full core; it states the routing inline and
51
+ // capture-core.md carries drift markers on the surfaced-scope.md rules it restates.
52
+ ok('capture: a mid-ticket capture states the surfaced-scope routing inline', /\*\*A capture fired mid-ticket\*\*[^\n]*the current map[^\n]*Surfaced from:/.test(capture));
53
+ ok('capture-core: surfaced-scope.md rules are drift-stamped', /<!-- core: surfaced-scope\.md \|/.test(read(`${SK}/_shared/capture-core.md`)));
54
+ const uat = read(`${SK}/_shared/techniques/acceptance-testing.md`);
55
+ ok('uat: the failing-step follow-up lands per surfaced-scope.md', /On a failing step[^\n]*surfaced-scope\.md/.test(uat));
56
+
57
+ console.log(`surfaced-scope-docs.test.js: ${passed} checks passed`);
package/bin/trailhead.js CHANGED
@@ -160,7 +160,9 @@ function readCodexModels(codexHome) {
160
160
  const globalCfg = readJSON(path.join(codexHome, 'trailhead', 'config.json')) || {};
161
161
  const globalCodex = globalCfg.models && typeof globalCfg.models === 'object' && typeof globalCfg.models.codex === 'object' && globalCfg.models.codex ? globalCfg.models.codex : {};
162
162
  const projectCodex = projectCfg.models && typeof projectCfg.models === 'object' && typeof projectCfg.models.codex === 'object' && projectCfg.models.codex ? projectCfg.models.codex : {};
163
- return { ...globalCodex, ...projectCodex };
163
+ // #323: una layer con plan ma senza grill implica grill = plan, prima del merge.
164
+ const { withImplicitGrill } = require(path.join(SRC, 'hooks', 'lib', 'model-defaults-review.js'));
165
+ return { ...withImplicitGrill(globalCodex), ...withImplicitGrill(projectCodex) };
164
166
  } catch {
165
167
  return {};
166
168
  }
@@ -263,7 +265,7 @@ const ENTRYPOINT_FILES = HOOK_FILES.filter((f) => !BASH_GUARD_MODULES.includes(f
263
265
  // require('./lib/mockup-link.js')). Copied by name, never a recursive
264
266
  // sweep: the Claude hooks/lib dir is shared with other plugins, so we must
265
267
  // not clobber a co-tenant's lib nor ship our own *.test.js files.
266
- const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js', 'body-sources.js', 'unmodelled.js', 'model-defaults-review.js', 'model-defaults.json', 'mockup-link.js'];
268
+ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js', 'body-sources.js', 'unmodelled.js', 'model-defaults-review.js', 'model-defaults.json', 'inherit-session.js', 'model-route.js', 'mockup-link.js', 'gh-auth-preflight.js'];
267
269
  // search-guard is Claude-Code-specific (#169), and so is mockup-link-guard
268
270
  // (it gates the AskUserQuestion tool, which Codex lacks): excluded from both
269
271
  // the Codex hook copy and the Codex verify spec's expected hookFiles, so a
@@ -686,12 +688,12 @@ function installCodex(configDir, { useSymlink }) {
686
688
  featureManual = true;
687
689
  }
688
690
 
689
- // Per-technique agent registry: project ALL 8 committed agents
691
+ // Per-technique agent registry: project ALL 9 committed agents
690
692
  // (readAgentDefs, sourced from plugins/trailhead/agents/*.md) as
691
693
  // trailhead-<technique>.toml under the real ~/.codex/agents/ dir, uniformly.
692
- // A keyed technique (plan/execute/research/review/debug/verify/codebase-map)
694
+ // A keyed technique (plan/execute/research/review/debug/verify/codebase-map/grill)
693
695
  // carries its models.codex.<key> pin (merged project + global config) when
694
- // set, else is pin-less; the 1 keyless agent (fix) is always pin-less.
696
+ // set, else is pin-less; fix tracks the models.codex.execute pin.
695
697
  // Sweep only trailhead's own files first, so a stale pin from a prior
696
698
  // install (or a removed technique) never lingers, without touching the
697
699
  // user's other custom-agent TOMLs.
@@ -60,6 +60,11 @@ const MODEL_DEFAULTS_LIB_CANDIDATES = [
60
60
  '../trailhead/hooks/lib/model-defaults-review.js',
61
61
  ];
62
62
  function assertModelDefaultsLibLayout(label, sharedDir) {
63
+ // #323: model-route.js is resolved with the same two-path lookup (grilling.md).
64
+ const routeResolved = MODEL_DEFAULTS_LIB_CANDIDATES
65
+ .map((c) => path.join(sharedDir, c.replace('model-defaults-review.js', 'model-route.js')))
66
+ .find((p) => fs.existsSync(p));
67
+ ok(`${label}: documented model-route.js lib path resolves`, !!routeResolved);
63
68
  const resolved = MODEL_DEFAULTS_LIB_CANDIDATES
64
69
  .map((c) => path.join(sharedDir, c))
65
70
  .find((p) => fs.existsSync(p));
@@ -234,7 +239,9 @@ ok('codex: trailhead-work/../_shared/substrate.md resolves', fs.existsSync(path.
234
239
  ok('codex: _shared/load-first.md projected', fs.existsSync(path.join(codexDir, 'skills', '_shared', 'load-first.md')));
235
240
  for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
236
241
  ok(`codex: ${cl}/../_shared/load-first.md resolves as a sibling`, fs.existsSync(path.join(codexDir, 'skills', cl, '..', '_shared', 'load-first.md')));
237
- ok(`codex: ${cl} SKILL.md references ../_shared/load-first.md`, fs.readFileSync(path.join(codexDir, 'skills', cl, 'SKILL.md'), 'utf8').includes('../_shared/load-first.md'));
242
+ // #330: the capture cluster loads the compact capture-core.md instead.
243
+ const want = cl === 'trailhead-capture' ? '../_shared/capture-core.md' : '../_shared/load-first.md';
244
+ ok(`codex: ${cl} SKILL.md references ${want}`, fs.readFileSync(path.join(codexDir, 'skills', cl, 'SKILL.md'), 'utf8').includes(want));
238
245
  }
239
246
  // Source-level anti-drift: the shared core-list signature line lives ONLY in
240
247
  // _shared/load-first.md, never duplicated back into a cluster SKILL.md.
@@ -293,7 +300,8 @@ ok('source: load-first.md references ../_shared/ticket-language.md', loadFirstSo
293
300
  const ticketLangAutoSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md'), 'utf8');
294
301
  const ticketLangCaptureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
295
302
  ok('source: auto.md references ticket-language.md', ticketLangAutoSource.includes('ticket-language.md'));
296
- ok('source: capture.md references ticket-language.md', ticketLangCaptureSource.includes('ticket-language.md'));
303
+ // #330: capture.md points at the compact capture-core.md, which is drift-stamped against ticket-language.md.
304
+ ok('source: capture.md references capture-core.md for the ticket language', ticketLangCaptureSource.includes('ticket language rule in `capture-core.md`') && /<!-- core: ticket-language\.md \|/.test(fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'capture-core.md'), 'utf8')));
297
305
  // Source-level anti-drift: the substantive decoupling clause lives in EXACTLY
298
306
  // one file across the whole skills tree, _shared/ticket-language.md. A global
299
307
  // single-occurrence scan (not an enumerated per-file list) so any future doc
@@ -941,6 +949,10 @@ ok('codex: skills/trailhead/hooks/lib/body-sources.js exists (secret-guard requi
941
949
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'body-sources.js')));
942
950
  ok('codex: skills/trailhead/hooks/lib/unmodelled.js exists (guards require target, #227)',
943
951
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'unmodelled.js')));
952
+ ok('codex: skills/trailhead/hooks/lib/inherit-session.js exists (required by model-route and model-defaults-review, #323)',
953
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'inherit-session.js')));
954
+ ok('codex: skills/trailhead/hooks/lib/model-route.js exists (grilling routing helper, #323)',
955
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-route.js')));
944
956
  ok('codex: skills/trailhead/hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
945
957
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults-review.js')));
946
958
  ok('codex: skills/trailhead/hooks/lib/model-defaults.json exists (#186)',
@@ -960,6 +972,8 @@ ok('codex: skills/trailhead/hooks/trailhead-mockup-link-stop.js exists',
960
972
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-mockup-link-stop.js')));
961
973
  ok('codex: skills/trailhead/hooks/lib/mockup-link.js exists (mockup-link-stop require target)',
962
974
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'mockup-link.js')));
975
+ ok('codex: skills/trailhead/hooks/lib/gh-auth-preflight.js exists (check-update require target)',
976
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-auth-preflight.js')));
963
977
  ok('codex: skills/trailhead/hooks/trailhead-mockup-link-guard.js does NOT exist',
964
978
  !fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-mockup-link-guard.js')));
965
979
  ok('codex: skills/trailhead/hooks/lib/shell-scan.js still exists (secret-read-guard require target)',
@@ -979,8 +993,15 @@ const noPinFixTomlPath = path.join(codexAgentsDirPath, 'trailhead-fix.toml');
979
993
  const noPinMapTomlPath = path.join(codexAgentsDirPath, 'trailhead-codebase-map.toml');
980
994
  ok('codex: trailhead-fix.toml is projected with no models.codex.* set', fs.existsSync(noPinFixTomlPath));
981
995
  ok('codex: trailhead-codebase-map.toml is projected with no models.codex.* set', fs.existsSync(noPinMapTomlPath));
982
- ok('codex: trailhead-fix.toml is pin-less (no model = line) with no models.codex.* set',
983
- fs.existsSync(noPinFixTomlPath) && !/^model = /m.test(fs.readFileSync(noPinFixTomlPath, 'utf8')));
996
+ // #325: fix non ha chiave propria, porta lo stesso pin di execute (la config del
997
+ // repo puo' impostarlo, quindi si confrontano le due righe model, qualunque sia).
998
+ const modelLine325 = (f) => (fs.readFileSync(f, 'utf8').match(/^model = .*$/m) || [''])[0];
999
+ const effortLine325 = (f) => (fs.readFileSync(f, 'utf8').match(/^model_reasoning_effort = .*$/m) || [''])[0];
1000
+ const executeTomlPath325 = path.join(codexAgentsDirPath, 'trailhead-execute.toml');
1001
+ ok('codex: trailhead-fix.toml carries the same model pin as trailhead-execute.toml (fix tracks execute, #325)',
1002
+ fs.existsSync(noPinFixTomlPath) && fs.existsSync(executeTomlPath325) &&
1003
+ modelLine325(noPinFixTomlPath) === modelLine325(executeTomlPath325) &&
1004
+ effortLine325(noPinFixTomlPath) === effortLine325(executeTomlPath325));
984
1005
 
985
1006
  assertModelDefaultsLibLayout('codex copy', path.join(codexDir, 'skills', '_shared'));
986
1007
 
@@ -1011,7 +1032,7 @@ ok('codex copy: templates is a regular dir (not a symlink)',
1011
1032
  assertModelDefaultsLibLayout('codex symlink', path.join(codexSymDir, 'skills', '_shared'));
1012
1033
 
1013
1034
  // --- codex agent TOML projection (#38, #88) -------------------------------------
1014
- // With NO models.codex.* set, a codex install still projects all 8 agents
1035
+ // With NO models.codex.* set, a codex install still projects all 9 agents
1015
1036
  // uniformly, pin-less (uniform projection, #88), never zero. Install from an
1016
1037
  // isolated cwd whose .trailhead/config.json sets no models.codex.*, not
1017
1038
  // repoRoot (whose own config now pins models.codex.*, so it would project
@@ -1024,11 +1045,11 @@ fs.writeFileSync(
1024
1045
  );
1025
1046
  const unsetCodexHomeDir = mktmp();
1026
1047
  runInstaller([`--codex`, `--dir=${unsetCodexHomeDir}`], { cwd: unsetProjectDir });
1027
- ok('codex: all 8 trailhead-*.toml exist under agents/ even with models.codex.* unset', (() => {
1048
+ ok('codex: all 9 trailhead-*.toml exist under agents/ even with models.codex.* unset', (() => {
1028
1049
  const dir = path.join(unsetCodexHomeDir, 'agents');
1029
1050
  if (!fs.existsSync(dir)) return false;
1030
1051
  const tomls = fs.readdirSync(dir).filter((f) => /^trailhead-.*\.toml$/.test(f));
1031
- return tomls.length === 8;
1052
+ return tomls.length === 9;
1032
1053
  })());
1033
1054
  ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (() => {
1034
1055
  const dir = path.join(unsetCodexHomeDir, 'agents');
@@ -1037,7 +1058,7 @@ ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (()
1037
1058
  })());
1038
1059
 
1039
1060
  // #250: the reviewing block (single source scripts/agent-blocks.md) is projected
1040
- // verbatim into the two reviewing agents' TOMLs and into none of the other six.
1061
+ // verbatim into the two reviewing agents' TOMLs and into none of the other seven.
1041
1062
  {
1042
1063
  const sync250 = require(path.join(repoRoot, 'scripts', 'sync-agent-blocks.js'));
1043
1064
  const reviewingBlock250 = sync250.parseSource(fs.readFileSync(path.join(repoRoot, 'scripts', 'agent-blocks.md'), 'utf8')).reviewing;
@@ -1045,7 +1066,7 @@ ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (()
1045
1066
  const agentsDir250 = path.join(unsetCodexHomeDir, 'agents');
1046
1067
  const reviewingNames250 = new Set(['trailhead-verify', 'trailhead-review']); // the code-review agent projects as trailhead-review.toml
1047
1068
  const tomls250 = fs.readdirSync(agentsDir250).filter((f) => /^trailhead-.*\.toml$/.test(f));
1048
- ok('#250: all 8 TOMLs are checked', tomls250.length === 8);
1069
+ ok('#250: all 9 TOMLs are checked', tomls250.length === 9);
1049
1070
  for (const f of tomls250) {
1050
1071
  const name = f.replace(/\.toml$/, '');
1051
1072
  const text = fs.readFileSync(path.join(agentsDir250, f), 'utf8');
@@ -1108,7 +1129,8 @@ ok('claude: cluster ../_shared/ reference resolves', fs.existsSync(path.join(cla
1108
1129
  ok('claude: skills/_shared/load-first.md exists', fs.existsSync(path.join(claudeDir, 'skills', '_shared', 'load-first.md')));
1109
1130
  ok('claude: trailhead-work/../_shared/load-first.md resolves', fs.existsSync(path.join(claudeDir, 'skills', 'trailhead-work', '..', '_shared', 'load-first.md')));
1110
1131
  for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
1111
- ok(`claude: ${cl} SKILL.md references ../_shared/load-first.md`, fs.readFileSync(path.join(claudeDir, 'skills', cl, 'SKILL.md'), 'utf8').includes('../_shared/load-first.md'));
1132
+ const want = cl === 'trailhead-capture' ? '../_shared/capture-core.md' : '../_shared/load-first.md';
1133
+ ok(`claude: ${cl} SKILL.md references ${want}`, fs.readFileSync(path.join(claudeDir, 'skills', cl, 'SKILL.md'), 'utf8').includes(want));
1112
1134
  }
1113
1135
 
1114
1136
  // #146: single-sourced ticket-language directive, projected on Claude too.
@@ -1128,6 +1150,10 @@ ok('claude: hooks/lib/body-sources.js exists (secret-guard require target, #210)
1128
1150
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'body-sources.js')));
1129
1151
  ok('claude: hooks/lib/unmodelled.js exists (guards require target, #227)',
1130
1152
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'unmodelled.js')));
1153
+ ok('claude: hooks/lib/inherit-session.js exists (required by model-route and model-defaults-review, #323)',
1154
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'inherit-session.js')));
1155
+ ok('claude: hooks/lib/model-route.js exists (grilling routing helper, #323)',
1156
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-route.js')));
1131
1157
  ok('claude: hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
1132
1158
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults-review.js')));
1133
1159
  ok('claude: hooks/lib/model-defaults.json exists (#186)',
@@ -1185,7 +1211,7 @@ ok('claude: SKILL.md does not start with the codex adapter header', !fs.readFile
1185
1211
  // falls back to running inline on the session model, so the per-technique
1186
1212
  // config.models.* split silently never applies.
1187
1213
  const claudeAgentsDir = path.join(claudeDir, 'agents');
1188
- const expectedAgents = ['trailhead-plan', 'trailhead-executor', 'trailhead-research', 'trailhead-code-review', 'trailhead-debug', 'trailhead-fix', 'trailhead-codebase-map'];
1214
+ const expectedAgents = ['trailhead-plan', 'trailhead-executor', 'trailhead-research', 'trailhead-code-review', 'trailhead-debug', 'trailhead-fix', 'trailhead-codebase-map', 'trailhead-grill'];
1189
1215
  for (const a of expectedAgents) {
1190
1216
  ok(`claude: agents/${a}.md placed`, fs.existsSync(path.join(claudeAgentsDir, `${a}.md`)));
1191
1217
  }
@@ -1315,6 +1341,7 @@ ok('claude: hooks/trailhead-bash-guards.js and its six modules exist', ['bash-gu
1315
1341
  // Prototype "link in the same message as the approval ask" rule.
1316
1342
  ok('claude: hooks/trailhead-mockup-link-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-mockup-link-guard.js')));
1317
1343
  ok('claude: hooks/lib/mockup-link.js exists (mockup-link-guard require target)', fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'mockup-link.js')));
1344
+ ok('claude: hooks/lib/gh-auth-preflight.js exists (check-update require target)', fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-auth-preflight.js')));
1318
1345
  ok('claude: hooks/trailhead-mockup-link-stop.js does NOT exist (Codex-only twin)', !fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-mockup-link-stop.js')));
1319
1346
  ok('claude: settings.json does NOT register mockup-link-stop', !settingsContent.includes('trailhead-mockup-link-stop.js'));
1320
1347
  ok('claude: settings.json registers mockup-link-guard under PreToolUse/AskUserQuestion',
@@ -1390,6 +1417,21 @@ const pinConfigTomlPath = path.join(pinCodexHomeDir, 'config.toml');
1390
1417
  ok('codex: config.toml enables multi_agent_v2 when a models.codex.* pin is projected',
1391
1418
  fs.existsSync(pinConfigTomlPath) && fs.readFileSync(pinConfigTomlPath, 'utf8').includes('multi_agent_v2 = true'));
1392
1419
 
1420
+ // #323: a project layer that sets plan but not grill grills on that plan, even when
1421
+ // the global layer pins grill (the implicit-grill rule applies per layer, before the merge).
1422
+ {
1423
+ const pd = mktmp();
1424
+ fs.mkdirSync(path.join(pd, '.trailhead'), { recursive: true });
1425
+ fs.writeFileSync(path.join(pd, '.trailhead', 'config.json'), JSON.stringify({ models: { codex: { plan: 'gpt-5.6-sol' } } }));
1426
+ const ch = mktmp();
1427
+ fs.mkdirSync(path.join(ch, 'trailhead'), { recursive: true });
1428
+ fs.writeFileSync(path.join(ch, 'trailhead', 'config.json'), JSON.stringify({ models: { codex: { grill: 'gpt-5.6-luna' } } }));
1429
+ runInstaller([`--codex`, `--dir=${ch}`], { cwd: pd });
1430
+ const grillToml = fs.readFileSync(path.join(ch, 'agents', 'trailhead-grill.toml'), 'utf8');
1431
+ ok('codex: project plan with no project grill beats a global grill pin in trailhead-grill.toml',
1432
+ grillToml.includes('model = "gpt-5.6-sol"') && !grillToml.includes('gpt-5.6-luna'));
1433
+ }
1434
+
1393
1435
  // Uninstall must sweep the projected TOML too, without a --dir= cwd dependency.
1394
1436
  runInstaller([`--codex`, `--dir=${pinCodexHomeDir}`, '--uninstall']);
1395
1437
  ok('codex uninstall: trailhead-execute.toml is gone', !fs.existsSync(pinnedTomlPath));
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // Docs test for #322: resolving any ticket (map or whiteboard, via work or quick) unblocks
3
+ // its dependents, as a step independent of the map-only `Decisions so far` book-keeping.
4
+ // Plain asserts, no framework. Run: node bin/unblock-docs.test.js
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const skills = path.resolve(__dirname, '..', 'plugins', 'trailhead', 'skills');
10
+ const read = (p) => fs.readFileSync(path.join(skills, p), 'utf8');
11
+ const engines = read('trailhead-work/references/ticket-engines.md');
12
+ const work = read('trailhead-work/SKILL.md');
13
+ const commands = read('_shared/substrate-commands.md');
14
+ const HELPER = 'trailhead-unblock.js --issue <n> --repo <owner/name>';
15
+ let passed = 0;
16
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
17
+
18
+ const resolveLine = (section) => {
19
+ const start = engines.indexOf(section);
20
+ const m = /^5\. \*\*Resolve\*\*.*$/m.exec(engines.slice(start));
21
+ return m ? m[0] : '';
22
+ };
23
+ const unblockClause = (line) => {
24
+ const i = line.indexOf('**Unblock dependents');
25
+ if (i < 0) return '';
26
+ // the clause ends at the next bold step (the handoff), not at a fixed window
27
+ const end = line.indexOf('**Session handoff**', i);
28
+ return line.slice(i, end < 0 ? undefined : end);
29
+ };
30
+
31
+ for (const section of ['## `build`', '## `bug`']) {
32
+ const line = resolveLine(section);
33
+ const clause = unblockClause(line);
34
+ ok(`engines ${section}: Resolve has an Unblock dependents clause`, clause !== '');
35
+ ok(`engines ${section}: names the helper`, clause.includes(HELPER));
36
+ ok(`engines ${section}: applies to map and whiteboard tickets alike`, /whiteboard/.test(clause) && /map/.test(clause));
37
+ ok(`engines ${section}: keeps the by-hand fallback`, clause.includes('--remove-label "trailhead:blocked"'));
38
+ ok(`engines ${section}: independent of the map-only Decisions so far update`, /independent of the map-only `Decisions so far` update/.test(clause));
39
+ ok(`engines ${section}: runs right after the close`, /^\*\*Unblock dependents[^*]*\*\*\s*Right after the close/.test(clause) && line.indexOf('gh issue close') < line.indexOf('**Unblock dependents'));
40
+ ok(`engines ${section}: fallback names the blocked_by endpoint`, clause.includes('dependencies/blocked_by'));
41
+ }
42
+
43
+ const step5 = (/^5\. Record the resolution.*$/m.exec(work) || [''])[0];
44
+ ok('SKILL.md step 5: unblock is its own sentence naming the helper', /\.\s+Unblocking is a separate step[^]*\*\*unblock dependents\*\*/.test(step5) && step5.includes('trailhead-unblock.js'));
45
+ ok('SKILL.md step 5: holds for whiteboard tickets too', /whiteboard/.test(step5));
46
+
47
+ const bullet = (/^- \*\*No map narrative book-keeping\.\*\*.*$/m.exec(work) || [''])[0];
48
+ ok('SKILL.md quick bullet exists', bullet !== '');
49
+ ok('SKILL.md quick bullet: unblocking is among the steps that still hold', /Everything else holds:[^.]*unblocking dependents/.test(bullet) && bullet.includes('trailhead-unblock.js'));
50
+ ok('SKILL.md quick bullet: no map-only exception', !bullet.includes('Exception, `quick <n>` on a *map* ticket'));
51
+
52
+ ok('substrate-commands.md: unblock block names the helper as default', /Unblock:[\s\S]{0,400}trailhead-unblock\.js --issue <n> --repo <owner\/name>/.test(commands));
53
+ ok('substrate-commands.md: keeps the raw gh command as fallback', /Unblock:[\s\S]{0,700}gh issue edit <(?:n|dep)> --remove-label "trailhead:blocked"/.test(commands));
54
+ ok('substrate-commands.md: cookbook comment names the helper', /# unblock[^\n]*trailhead-unblock\.js/.test(commands));
55
+ ok('no em-dash', ![engines, work, commands].some((s) => s.includes(String.fromCharCode(0x2014))));
56
+ console.log(`unblock-docs.test.js: ${passed} checks passed`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.16.0",
3
+ "version": "0.17.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.16.0",
3
+ "version": "0.17.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. 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 -->