@marcomigozzi/trailhead 0.15.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 (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-merge-ticket.js (#287). Run: node merge-ticket.test.js
3
+ // No framework: plain asserts. A fake tracker sits behind an injected `gh` runner;
4
+ // one end-to-end run uses a fake `gh` on PATH.
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-merge-ticket.js');
12
+ const { main, decodeMarker, encodeMarker } = require(scriptPath);
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
18
+ const EM_DASH = String.fromCharCode(0x2014);
19
+
20
+ // Fake tracker: open issues with labels, ticket titles, edit history, GraphQL pages.
21
+ function makeTracker(init = {}) {
22
+ const t = {
23
+ issues: init.issues || [], // { number, title, body, url, labels, updatedAt, edits }
24
+ titles: init.titles || { 5: 'Ticket five', 7: 'Ticket seven', 9: 'Ticket nine', 40: 'The map' },
25
+ next: init.next || 100,
26
+ calls: [], created: [], edits: [], edges: [],
27
+ listFail: !!init.listFail, listRaw: init.listRaw, listCalls: 0, pageSize: init.pageSize || 100,
28
+ respond: init.respond, edgeFail: !!init.edgeFail, secondListSeesTwo: !!init.secondListSeesTwo,
29
+ onEdit: init.onEdit || null, onView: init.onView || null, views: 0, writeDelta: init.writeDelta === undefined ? 1 : init.writeDelta,
30
+ garble: !!init.garble,
31
+ };
32
+ const find = (n) => t.issues.find((i) => i.number === n);
33
+ t.add = (issue) => { t.issues.push({ url: `https://github.com/o/r/issues/${issue.number}`, updatedAt: 'T1', edits: 3, labels: [], ...issue }); };
34
+ t.touch = (n, body) => { const i = find(n); i.body = body; i.edits += 1; i.updatedAt = 'T' + (Number(i.updatedAt.slice(1)) + 1); };
35
+ t.gh = (args) => {
36
+ const line = args.join(' ');
37
+ t.calls.push(line);
38
+ const sub2 = args.slice(0, 2).join(' ');
39
+ if (sub2 === 'api graphql') {
40
+ const q = args.find((a) => a.startsWith('query=')) || '';
41
+ if (q.includes('userContentEdits')) {
42
+ const n = Number(args[args.indexOf('-F') + 1].split('=')[1]);
43
+ return { status: 0, stdout: JSON.stringify({ data: { repository: { issue: { userContentEdits: { totalCount: find(n).edits } } } } }), stderr: '' };
44
+ }
45
+ t.listCalls += 1;
46
+ if (t.listFail) return { status: 1, stdout: '', stderr: 'forced failure' };
47
+ if (t.listRaw !== undefined) return { status: 0, stdout: t.listRaw, stderr: '' };
48
+ const afterArg = args.find((a) => a.startsWith('after='));
49
+ const page = afterArg ? Number(afterArg.slice(6)) : 0;
50
+ if (t.respond) return { status: 0, stdout: JSON.stringify(t.respond(page)), stderr: '' };
51
+ const label = /labels:\["([^"]+)"\]/.exec(q)[1];
52
+ let rows = t.issues.filter((i) => i.labels.includes(label) && !i.closed);
53
+ if (t.secondListSeesTwo && t.listCalls >= 2 && t.created.length) {
54
+ rows = rows.concat([{ number: 99, title: 'older twin', body: '<!-- trailhead:merge-ticket v1 e30= -->', url: 'https://github.com/o/r/issues/99' }]);
55
+ }
56
+ const slice = rows.slice(page * t.pageSize, (page + 1) * t.pageSize);
57
+ const more = (page + 1) * t.pageSize < rows.length;
58
+ const nodes = slice.map(({ number, title, body, url }) => ({ number, title, body, url }));
59
+ return { status: 0, stdout: JSON.stringify({ data: { repository: { issues: { nodes, pageInfo: { hasNextPage: more, endCursor: more ? String(page + 1) : null } } } } }), stderr: '' };
60
+ }
61
+ if (sub2 === 'issue view') {
62
+ const n = Number(args[2]);
63
+ if (args.includes('title,url')) {
64
+ if (!t.titles[n]) return { status: 1, stdout: '', stderr: 'not found' };
65
+ return { status: 0, stdout: JSON.stringify({ title: t.titles[n], url: `https://github.com/o/r/issues/${n}` }), stderr: '' };
66
+ }
67
+ t.views += 1;
68
+ if (t.onView) t.onView(t, t.views);
69
+ const i = find(n);
70
+ return { status: 0, stdout: JSON.stringify({ body: i.body, updatedAt: i.updatedAt }), stderr: '' };
71
+ }
72
+ if (sub2 === 'issue edit') {
73
+ const n = Number(args[2]);
74
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
75
+ if (t.onEdit) t.onEdit(t);
76
+ const i = find(n);
77
+ i.body = t.garble ? text + '\nextra' : text;
78
+ i.edits += t.writeDelta;
79
+ i.updatedAt = 'T' + (Number(i.updatedAt.slice(1)) + 1);
80
+ t.edits.push({ n, text });
81
+ return { status: 0, stdout: '', stderr: '' };
82
+ }
83
+ if (sub2 === 'label create') return { status: 0, stdout: '', stderr: '' };
84
+ if (sub2 === 'issue create') {
85
+ const n = t.next++;
86
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
87
+ const labels = args.flatMap((a, k) => (args[k - 1] === '--label' ? [a] : []));
88
+ const title = args[args.indexOf('--title') + 1];
89
+ t.created.push({ n, title, text, labels, line });
90
+ t.add({ number: n, title, body: text, labels });
91
+ return { status: 0, stdout: `https://github.com/o/r/issues/${n}\n`, stderr: '' };
92
+ }
93
+ if (args[0] === 'api' && /^repos\/o\/r\/issues\/\d+$/.test(args[1])) {
94
+ return { status: 0, stdout: JSON.stringify({ id: 5000 + Number(args[1].split('/').pop()), number: Number(args[1].split('/').pop()) }), stderr: '' };
95
+ }
96
+ if (args[0] === 'api' && args.includes('--method') && /sub_issues$/.test(args[args.indexOf('--method') + 2])) {
97
+ t.edges.push(line);
98
+ return t.edgeFail ? { status: 1, stdout: '', stderr: 'edge denied' } : { status: 0, stdout: '{}', stderr: '' };
99
+ }
100
+ return { status: 1, stdout: '', stderr: 'unexpected: ' + line };
101
+ };
102
+ return t;
103
+ }
104
+
105
+ function run(t, argv, extra = {}) {
106
+ let out = '';
107
+ let errOut = '';
108
+ const code = main(argv, { gh: t.gh, guard: fakeGuard, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
109
+ let json = null;
110
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
111
+ return { code, out, errOut, json };
112
+ }
113
+
114
+ const add = (ticket, files, over = {}) => {
115
+ const o = { scope: 'map-40', workspace: `/w/t${ticket}`, ...over };
116
+ return ['add', '--scope', o.scope, '--ticket', String(ticket), ...files.flatMap((f) => ['--file', f]), '--workspace', o.workspace, '--repo', 'o/r'];
117
+ };
118
+ const writes = (t) => t.calls.filter((c) => /^issue (edit|create)/.test(c));
119
+ const MAPLABEL = 'trailhead:map-40';
120
+
121
+ // --- usage ---------------------------------------------------------------------
122
+ for (const argv of [
123
+ [], ['bogus'], ['add'],
124
+ add(5, []),
125
+ add(5, ['a.js'], { scope: 'map-0' }), add(5, ['a.js'], { scope: 'map-x' }), add(5, ['a.js'], { scope: 'maps' }),
126
+ ['add', '--scope', 'map-40', '--ticket', '0', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
127
+ ['add', '--scope', 'map-40', '--ticket', 'x', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
128
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'nope'],
129
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--repo', 'o/r'],
130
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w'],
131
+ ['add', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
132
+ ['add', '--scope', 'map-40', '--file', 'a', '--workspace', '/w', '--repo', 'o/r'],
133
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file'],
134
+ ['add', '--scope', 'map-40', '--ticket', '5', '--file', 'a', '--workspace', '/w', '--repo', 'o/r', '--bogus', 'x'],
135
+ ]) {
136
+ const t = makeTracker();
137
+ const r = run(t, argv);
138
+ ok(`usage error exit 2: ${argv.join(' ') || '(none)'}`, r.code === 2 && t.calls.length === 0 && r.errOut.length > 0 && r.out === '');
139
+ }
140
+
141
+ // --- create on a map ------------------------------------------------------------
142
+ {
143
+ const t = makeTracker();
144
+ const r = run(t, add(5, ['src/a.js', 'src/b.js']));
145
+ ok('create: ok, created, one JSON line', r.code === 0 && r.json.status === 'ok' && r.json.action === 'created' && r.json.entries === 1 && r.out.trim().split('\n').length === 1);
146
+ ok('create: scope and issue reported', r.json.scope === 'map-40' && r.json.issue === 100 && r.json.url === 'https://github.com/o/r/issues/100' && r.json.warnings.length === 0);
147
+ const c = t.created[0];
148
+ ok('create: canonical title with the map title', c.title === 'Merge the branches left unmerged: The map');
149
+ ok('create: labels ticket, task, needs-you and the map label', ['trailhead:ticket', 'trailhead:task', 'trailhead:needs-you', MAPLABEL].every((l) => c.labels.includes(l)) && c.labels.length === 4);
150
+ ok('create: Parent line, sections and branch', /^Parent: \[The map\]\(https:\/\/github.com\/o\/r\/issues\/40\)\n/.test(c.text) && c.text.includes('## Question') && c.text.includes('## Unmerged branches') && c.text.includes('## Done when') && c.text.includes('`trailhead/t5`') && c.text.includes('[#5 Ticket five](https://github.com/o/r/issues/5)') && c.text.includes('`/w/t5`') && c.text.includes('`src/a.js`'));
151
+ ok('create: the needs-you label is ensured first', t.calls.findIndex((x) => x.startsWith('label create trailhead:needs-you')) < t.calls.findIndex((x) => x.startsWith('issue create')) && /--color D93F0B/.test(t.calls.find((x) => x.startsWith('label create'))) && /--force/.test(t.calls.find((x) => x.startsWith('label create'))));
152
+ ok('create: sub-issue edge wired with the new id', t.edges.length === 1 && t.edges[0] === 'api --method POST repos/o/r/issues/40/sub_issues -F sub_issue_id=5100');
153
+ const dec = decodeMarker(c.text);
154
+ ok('create: the marker decodes to the entry', !dec.error && dec.entries.length === 1 && dec.entries[0].ticket === 5 && dec.entries[0].branch === 'trailhead/t5' && dec.entries[0].workspace === '/w/t5' && dec.entries[0].files.join('|') === 'src/a.js|src/b.js');
155
+ ok('create: no em-dash in the body', !c.text.includes(EM_DASH));
156
+ ok('create: the find reads OPEN issues with the map label through graphql', t.calls[0].includes('api graphql') && t.calls[0].includes(`labels:["${MAPLABEL}"]`) && t.calls[0].includes('states:[OPEN]') && !t.calls.some((x) => x.startsWith('issue list')));
157
+ }
158
+
159
+ // --- create on the whiteboard ---------------------------------------------------
160
+ {
161
+ const t = makeTracker();
162
+ const r = run(t, add(7, ['x.js'], { scope: 'whiteboard' }));
163
+ const c = t.created[0];
164
+ ok('whiteboard: created with the whiteboard title and label', r.code === 0 && r.json.action === 'created' && r.json.scope === 'whiteboard' && c.title === 'Merge the branches left unmerged: whiteboard' && c.labels.includes('trailhead:whiteboard') && !c.labels.some((l) => l.startsWith('trailhead:map-')));
165
+ ok('whiteboard: no Parent line and no edge', !/^Parent:/m.test(c.text) && t.edges.length === 0 && !t.calls.some((x) => x.includes('sub_issues')));
166
+ }
167
+
168
+ // --- update ----------------------------------------------------------------------
169
+ {
170
+ const t = makeTracker();
171
+ run(t, add(9, ['m.js', 'n.js']));
172
+ const r2 = run(t, add(5, ['n.js', 'k.js']));
173
+ ok('update: a second entry is appended', r2.code === 0 && r2.json.action === 'updated' && r2.json.entries === 2 && r2.json.issue === 100 && t.created.length === 1 && t.edits.length === 1);
174
+ const body = t.edits[0].text;
175
+ ok('update: oldest ticket first', body.indexOf('`trailhead/t5`') < body.indexOf('`trailhead/t9`') && /Suggested merge order: oldest ticket first/.test(body));
176
+ ok('update: worded as a suggestion with a re-check note', /a suggestion, not the order the run followed/.test(body) && /re-check each later one against the trunk/.test(body));
177
+ ok('update: also-touched-by names the other branch and the shared file', /Also touched by: `trailhead\/t9` \(`n\.js`\)/.test(body) && /Also touched by: `trailhead\/t5` \(`n\.js`\)/.test(body));
178
+ ok('update: the Parent line is kept and the marker holds both', body.startsWith('Parent: [The map]') && decodeMarker(body).entries.map((e) => e.ticket).join() === '5,9');
179
+ ok('update: the title is never changed', !t.calls.some((x) => /issue edit .*--title/.test(x)));
180
+ ok('update: the guarded write ran (edit count read)', t.calls.some((x) => x.includes('userContentEdits')));
181
+ // same ticket, same files and workspace -> unchanged
182
+ const before = t.edits.length;
183
+ const r3 = run(t, add(5, ['n.js', 'k.js']));
184
+ ok('unchanged: same ticket, files and workspace writes nothing', r3.code === 0 && r3.json.action === 'unchanged' && r3.json.entries === 2 && t.edits.length === before);
185
+ // same ticket, new files -> updated in place
186
+ const r4 = run(t, add(5, ['n.js', 'z.js']));
187
+ const ents = decodeMarker(t.edits[t.edits.length - 1].text).entries;
188
+ ok('in place: new files replace the entry, no duplicate', r4.json.action === 'updated' && r4.json.entries === 2 && ents.length === 2 && ents.find((e) => e.ticket === 5).files.join() === 'n.js,z.js');
189
+ ok('in place: the ticket is not re-read on a replace', t.calls.filter((x) => x.startsWith('issue view 5 --json title,url')).length === 1);
190
+ }
191
+
192
+ // --- find rules ------------------------------------------------------------------
193
+ {
194
+ const e = (n) => [{ ticket: n, title: `T${n}`, url: `https://github.com/o/r/issues/${n}`, branch: `trailhead/t${n}`, workspace: `/w/${n}`, files: ['f'] }];
195
+ const body = (n) => `Parent: [The map](https://github.com/o/r/issues/40)\n\n## Question\nq\n\n## Unmerged branches\n1. x\n\n## Done when\n- d\n\n${encodeMarker(e(n))}\n`;
196
+ const t = makeTracker({ issues: [
197
+ { number: 60, title: 'plain', body: 'a human note about merging, no marker at all here', labels: [MAPLABEL], url: 'https://github.com/o/r/issues/60' },
198
+ { number: 70, title: 'merge b', body: body(9), labels: [MAPLABEL], url: 'https://github.com/o/r/issues/70' },
199
+ { number: 80, title: 'merge c', body: body(7), labels: [MAPLABEL], url: 'https://github.com/o/r/issues/80' },
200
+ { number: 85, title: 'other map', body: body(7), labels: ['trailhead:map-41'], url: 'https://github.com/o/r/issues/85' },
201
+ ].map((i) => ({ updatedAt: 'T1', edits: 3, ...i })) });
202
+ const r = run(t, add(5, ['q.js']));
203
+ ok('reuse: the lowest-numbered open merge ticket gets the entry', r.code === 0 && r.json.action === 'updated' && r.json.issue === 70 && t.edits.length === 1 && t.edits[0].n === 70 && t.created.length === 0);
204
+ ok('reuse: a second open one is named in a warning, none closed', r.json.warnings.length === 1 && /#70/.test(r.json.warnings[0]) && /#80/.test(r.json.warnings[0]) && !t.calls.some((x) => x.startsWith('issue close')));
205
+ const t2 = makeTracker({ issues: [{ number: 60, title: 'plain', body: 'no marker in this body, only prose that is long enough', labels: [MAPLABEL], url: 'u' }].map((i) => ({ updatedAt: 'T1', edits: 3, ...i })) });
206
+ const r2 = run(t2, add(5, ['q.js']));
207
+ ok('a body without the marker is ignored: a new ticket is created', r2.code === 0 && r2.json.action === 'created' && t2.edits.length === 0);
208
+ }
209
+
210
+ // --- read errors -----------------------------------------------------------------
211
+ {
212
+ const cases = [
213
+ ['gh failure', makeTracker({ listFail: true })],
214
+ ['bad JSON', makeTracker({ listRaw: 'not json' })],
215
+ ['graphql errors', makeTracker({ listRaw: JSON.stringify({ errors: [{ message: 'boom' }], data: null }) })],
216
+ ['no repository', makeTracker({ listRaw: JSON.stringify({ data: { repository: null } }) })],
217
+ ['no nodes', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { pageInfo: { hasNextPage: false, endCursor: null } } } } }) })],
218
+ ['no pageInfo', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { nodes: [] } } } }) })],
219
+ ['cursor missing', makeTracker({ listRaw: JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: null } } } } }) })],
220
+ ];
221
+ for (const [name, t] of cases) {
222
+ const r = run(t, add(5, ['a.js']));
223
+ ok(`read error: ${name}: error, nothing written`, r.code === 1 && r.json.status === 'error' && writes(t).length === 0 && !t.calls.some((x) => x.startsWith('label create')));
224
+ }
225
+ const endless = makeTracker({ respond: (p) => ({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: String(p + 1) } } } } }) });
226
+ const cap = run(endless, add(5, ['a.js']));
227
+ ok('page cap exceeded is an error, never none found', cap.code === 1 && /page/.test(cap.json.reason) && endless.listCalls === 100 && writes(endless).length === 0);
228
+ }
229
+ {
230
+ // The merge ticket sits on page two: it is found, not duplicated.
231
+ const issues = [];
232
+ const e = [{ ticket: 9, title: 'T9', url: 'https://github.com/o/r/issues/1', branch: 'trailhead/t9', workspace: '/w', files: ['f'] }];
233
+ for (let i = 1; i <= 3; i++) issues.push({ number: i, title: 'x', body: 'plain body without a marker', labels: [MAPLABEL] });
234
+ issues.push({ number: 4, title: 'merge', body: `intro\n${encodeMarker(e)}\n`, labels: [MAPLABEL] });
235
+ const t = makeTracker({ pageSize: 2, issues: issues.map((i) => ({ updatedAt: 'T1', edits: 3, url: 'u', ...i })) });
236
+ const r = run(t, add(5, ['a.js']));
237
+ ok('pagination: a merge ticket on a later page is found', r.code === 0 && r.json.action === 'updated' && r.json.issue === 4 && t.created.length === 0 && t.listCalls === 2);
238
+ }
239
+
240
+ // --- concurrency ------------------------------------------------------------------
241
+ {
242
+ const seed = () => {
243
+ const t = makeTracker();
244
+ run(t, add(9, ['m.js']));
245
+ t.edits.length = 0;
246
+ return t;
247
+ };
248
+ const other = (n) => ({ ticket: n, title: `T${n}`, url: `https://github.com/o/r/issues/${n}`, branch: `trailhead/t${n}`, workspace: `/w/${n}`, files: ['o.js'] });
249
+ const t = seed();
250
+ const cur = decodeMarker(t.issues.find((i) => i.number === 100).body).entries;
251
+ let first = true;
252
+ t.onView = (tr, n) => { if (first && n === 2) { first = false; tr.touch(100, renderOther(cur, other(7))); } };
253
+ function renderOther(entries, extra) {
254
+ const { renderBody } = require(scriptPath);
255
+ return renderBody([...entries, extra], 'Parent: [The map](https://github.com/o/r/issues/40)');
256
+ }
257
+ const r = run(t, add(5, ['n.js']));
258
+ const ents = decodeMarker(t.edits[0].text).entries.map((e) => e.ticket).join();
259
+ ok('concurrent edit that added another entry: both survive', r.code === 0 && r.json.action === 'updated' && ents === '5,7,9');
260
+ }
261
+ {
262
+ const t = makeTracker();
263
+ run(t, add(9, ['m.js']));
264
+ t.edits.length = 0;
265
+ const b = () => t.issues.find((i) => i.number === 100).body;
266
+ t.onView = (tr, n) => { if (n === 2 || n === 3) tr.touch(100, b() + '\nnote ' + n); };
267
+ const r = run(t, add(5, ['n.js']));
268
+ ok('changed twice while preparing: error, nothing written', r.code === 1 && /changed twice/.test(r.json.reason) && t.edits.length === 0);
269
+ }
270
+ {
271
+ const t = makeTracker();
272
+ run(t, add(9, ['m.js']));
273
+ t.edits.length = 0;
274
+ t.onEdit = (tr) => { tr.issues.find((i) => i.number === 100).edits += 1; };
275
+ const r = run(t, add(5, ['n.js']));
276
+ ok('post-write conflict is an error naming it', r.code === 1 && /conflict/.test(r.json.reason));
277
+ const t2 = makeTracker();
278
+ run(t2, add(9, ['m.js']));
279
+ t2.garble = true;
280
+ const r2 = run(t2, add(5, ['n.js']));
281
+ ok('read-back mismatch is a conflict error', r2.code === 1 && /conflict/.test(r2.json.reason));
282
+ }
283
+
284
+ // --- secrets and guard --------------------------------------------------------------
285
+ {
286
+ const t = makeTracker();
287
+ const r = run(t, add(5, ['ghp_' + 'a'.repeat(30) + '.js']));
288
+ ok('secret hit on create: error, nothing written', r.code === 1 && /secret/.test(r.json.reason) && writes(t).length === 0);
289
+ const t2 = makeTracker();
290
+ const r2 = run(t2, add(5, ['a.js']), { guard: null });
291
+ ok('missing guard on create: fail closed, nothing written', r2.code === 1 && /secret-scan unavailable/.test(r2.json.reason) && writes(t2).length === 0);
292
+ const t3 = makeTracker();
293
+ run(t3, add(9, ['m.js']));
294
+ t3.edits.length = 0;
295
+ const r3 = run(t3, add(5, ['ghp_' + 'b'.repeat(30) + '.js']));
296
+ ok('secret hit on update: error, nothing written', r3.code === 1 && /secret/.test(r3.json.reason) && t3.edits.length === 0);
297
+ const r4 = run(t3, add(5, ['a.js']), { guard: null });
298
+ ok('missing guard on update: fail closed, nothing written', r4.code === 1 && t3.edits.length === 0);
299
+ }
300
+
301
+ // --- edge, twins, marker faults ------------------------------------------------------
302
+ {
303
+ const t = makeTracker({ edgeFail: true });
304
+ const r = run(t, add(5, ['a.js']));
305
+ ok('edge failure is a warning, status ok', r.code === 0 && r.json.status === 'ok' && r.json.action === 'created' && r.json.warnings.length === 1 && /sub-issue edge/.test(r.json.warnings[0]) && /edge denied/.test(r.json.warnings[0]));
306
+ }
307
+ {
308
+ const t = makeTracker({ secondListSeesTwo: true });
309
+ const r = run(t, add(5, ['a.js']));
310
+ ok('post-create find seeing two open: warning names both, nothing closed', r.code === 0 && r.json.warnings.some((w) => /#100/.test(w) && /#99/.test(w)) && !t.calls.some((x) => x.startsWith('issue close')));
311
+ }
312
+ {
313
+ const mk = (marker) => makeTracker({ issues: [{ number: 70, title: 'm', body: `Parent: [x](y)\n\n## Question\nq\n\n${marker}\n`, labels: [MAPLABEL], url: 'u', updatedAt: 'T1', edits: 3 }] });
314
+ for (const [name, marker] of [
315
+ ['undecodable payload', '<!-- trailhead:merge-ticket v1 !!!not-base64-json -->'],
316
+ ['unknown version', '<!-- trailhead:merge-ticket v2 W10= -->'],
317
+ ['payload not an entry list', `<!-- trailhead:merge-ticket v1 ${Buffer.from('{"a":1}').toString('base64')} -->`],
318
+ ['entry missing files', `<!-- trailhead:merge-ticket v1 ${Buffer.from('[{"ticket":1,"title":"t","url":"u","branch":"b","workspace":"w","files":[]}]').toString('base64')} -->`],
319
+ ]) {
320
+ const t = mk(marker);
321
+ const r = run(t, add(5, ['a.js']));
322
+ ok(`marker fault: ${name}: error, nothing written`, r.code === 1 && r.json.status === 'error' && /#70/.test(r.json.reason) && writes(t).length === 0 && t.created.length === 0);
323
+ }
324
+ }
325
+
326
+ // --- round trip and rendering ---------------------------------------------------------
327
+ {
328
+ const t = makeTracker({ titles: { 5: 'Fix `a` --> now\nsecond line <!-- [x]', 7: 'Ticket seven', 9: 'n', 40: 'The map' } });
329
+ const weird = ['dir/a,b.js', 'we`ird`name.js', 'new\nline.js', 'plain.js'];
330
+ const r = run(t, add(5, weird, { workspace: '/w/with `tick`' }));
331
+ const c = t.created[0];
332
+ const ent = decodeMarker(c.text).entries[0];
333
+ ok('round trip: comma paths, backticks, newlines and a --> title survive intact', r.code === 0 && ent.title === 'Fix `a` --> now\nsecond line <!-- [x]' && ent.files.join('|') === weird.join('|') && ent.workspace === '/w/with `tick`');
334
+ const visible = c.text.split('\n').filter((l) => !l.startsWith('<!-- trailhead:merge-ticket'));
335
+ ok('rendered text: no raw newline leaks, no raw <!--, backticks fenced', visible.every((l) => !l.includes('<!--')) && c.text.includes('``we`ird`name.js``') && c.text.includes('[#5 Fix \'a\' --> now second line &lt;!-- (x)]'));
336
+ ok('rendered text: exactly one marker line', c.text.split('\n').filter((l) => l.startsWith('<!-- trailhead:merge-ticket')).length === 1);
337
+ r.json && ok('round trip: a repeat of the same input is unchanged', run(t, add(5, weird, { workspace: '/w/with `tick`' })).json.action === 'unchanged');
338
+ }
339
+ {
340
+ const t = makeTracker();
341
+ run(t, add(9, ['m.js']));
342
+ run(t, add(5, ['n.js']));
343
+ ok('no em-dash in any rendered body', t.edits.every((e) => !e.text.includes(EM_DASH)) && t.created.every((c) => !c.text.includes(EM_DASH)));
344
+ ok('the decoded marker of a rendered body is stable under encode', encodeMarker(decodeMarker(t.edits[0].text).entries) === t.edits[0].text.split('\n').find((l) => l.startsWith('<!-- trailhead:merge-ticket')));
345
+ }
346
+
347
+ // --- end-to-end with a fake gh on PATH --------------------------------------------------
348
+ {
349
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-ticket-'));
350
+ const binDir = path.join(tmp, 'bin');
351
+ fs.mkdirSync(binDir);
352
+ const log = path.join(tmp, 'calls.log');
353
+ const empty = JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } } });
354
+ const script = `#!/bin/sh
355
+ echo "$@" >> '${log}'
356
+ case "$1 $2" in
357
+ "api graphql") cat <<'EOF_JSON'
358
+ ${empty}
359
+ EOF_JSON
360
+ ;;
361
+ "issue view") echo '{"title":"A ticket","url":"https://github.com/o/r/issues/5"}' ;;
362
+ "issue create") echo 'https://github.com/o/r/issues/55' ;;
363
+ "label create") ;;
364
+ *) case "$1" in api) echo '{"id":9955}' ;; esac ;;
365
+ esac
366
+ `;
367
+ fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
368
+ const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
369
+ const res = spawnSync('node', [scriptPath, ...add(5, ['a.js'], { scope: 'whiteboard' })], { encoding: 'utf8', env });
370
+ let j = null;
371
+ try { j = JSON.parse(res.stdout.trim()); } catch { /* reported below */ }
372
+ ok('e2e: creates through gh on PATH', res.status === 0 && j && j.action === 'created' && j.issue === 55);
373
+ ok('e2e: gh issue create was called', fs.readFileSync(log, 'utf8').includes('issue create'));
374
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
375
+ const bad = spawnSync('node', [scriptPath, ...add(5, ['a.js'])], { encoding: 'utf8', env });
376
+ ok('e2e: failing gh exits 1 with a JSON error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
377
+ const use = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
378
+ ok('e2e: usage error exits 2', use.status === 2);
379
+ fs.rmSync(tmp, { recursive: true, force: true });
380
+ }
381
+
382
+ // --- untrusted text in the rendered body (review I1) --------------------------
383
+ {
384
+ const { renderBody } = require(scriptPath);
385
+ const e = { ticket: 7, title: 'Ping @someone here', url: 'https://github.com/o/r/issues/7', branch: 'trailhead/t7', workspace: '/w/r-t7', files: ['a.js'] };
386
+ const body = renderBody([e], '');
387
+ ok('a title @mention is neutralised (no notification)', !/@someone/.test(body) && body.includes('@​someone'));
388
+ const enc = (list) => encodeMarker(list);
389
+ ok('a marker entry whose url is not https is rejected', !!decodeMarker(enc([{ ...e, url: 'javascript:alert(1)' }])).error);
390
+ ok('a marker entry whose url carries a ) or space is rejected', !!decodeMarker(enc([{ ...e, url: 'https://x/y) [z' }])).error);
391
+ ok('a well-formed https url still decodes', !decodeMarker(enc([e])).error);
392
+ }
393
+
394
+ console.log(`merge-ticket.test.js: ${passed} checks passed`);
@@ -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) {