@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.
- package/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- 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 <!-- (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
|
-
|
|
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:
|
|
235
|
-
|
|
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) {
|