@marcomigozzi/trailhead 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  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/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-dashboard.js. Run: node dashboard.test.js
3
+ // No framework: plain asserts, same style as issue-body.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. Pure renderDashboard() / mapFlags() on a fixed snapshot.
7
+ // 2. main() against a fake tracker behind an injected `gh` runner: what is
8
+ // gathered (and what is not), last-audit carry-through, self-heal
9
+ // (create + pin, re-pin), the guarded write, dry-run.
10
+ // 3. End-to-end: the script itself with a failing fake `gh` on PATH.
11
+ const assert = require('assert');
12
+ const { spawnSync } = require('child_process');
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+
17
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-dashboard.js');
18
+ const { renderDashboard, mapFlags, classify, main } = require(scriptPath);
19
+
20
+ let passed = 0;
21
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
22
+
23
+ const AUDIT = '<!-- trailhead:last-audit 2026-10-01T10:00:00Z -->';
24
+
25
+ // --- pure render -------------------------------------------------------------
26
+ const SNAP = {
27
+ repo: 'o/r',
28
+ maps: [
29
+ { number: 14, title: '🗺 multi-host', openScoped: 0, total: 20, fog: false, closedEdges: null },
30
+ { number: 98, title: '🗺 website [v1]', openScoped: 5, total: 92, fog: null, closedEdges: true },
31
+ { number: 5, title: '🗺 plain', openScoped: 3, total: 10, fog: null, closedEdges: null },
32
+ ],
33
+ whiteboard: [
34
+ { number: 30, title: 'Frontier one', assignees: [], labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:build'] },
35
+ { number: 31, title: 'Doing it', assignees: ['me'], labels: ['trailhead:ticket', 'trailhead:whiteboard'] },
36
+ { number: 32, title: 'Stuck', assignees: [], labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:blocked'] },
37
+ ],
38
+ resolved: [{ number: 20, title: 'Done a' }, { number: 19, title: 'Done b' }],
39
+ inbox: 4,
40
+ anchors: { codebase: 2, conventions: 3 },
41
+ lastAudit: AUDIT,
42
+ };
43
+
44
+ {
45
+ const body = renderDashboard(SNAP);
46
+ ok('render: deterministic', body === renderDashboard(JSON.parse(JSON.stringify(SNAP))));
47
+ ok('render: ends with a single newline', body.endsWith('\n') && !body.endsWith('\n\n'));
48
+ const heads = body.split('\n').filter((l) => l.startsWith('## '));
49
+ ok('render: the three sections in order', JSON.stringify(heads) === JSON.stringify(['## 🗺 Maps', '## 🧷 Whiteboard (map-less tickets)', '## 📊 Counts']));
50
+ ok('render: map link with open count', body.includes('- [🗺 multi-host](https://github.com/o/r/issues/14): 0 open'));
51
+ ok('render: bracket in a title is escaped', body.includes('[🗺 website \\[v1\\]](https://github.com/o/r/issues/98)'));
52
+ ok('render: exhausted flag', /issues\/14\): 0 open · exhausted · closeable/.test(body));
53
+ ok('render: near cap + prunable flag', /issues\/98\): 5 open · near cap · 92\/100 · prunable/.test(body));
54
+ ok('render: an unflagged map has no flag', /issues\/5\): 3 open\n/.test(body));
55
+ ok('render: whiteboard lists', /Frontier \(1\):\n- \[Frontier one #30\]\(https:\/\/github.com\/o\/r\/issues\/30\)/.test(body)
56
+ && /In progress \(1\):\n- \[Doing it #31\]/.test(body) && /Blocked \(1\):\n- \[Stuck #32\]/.test(body));
57
+ ok('render: recently resolved titles', body.includes('_Recently resolved: [Done a #20](https://github.com/o/r/issues/20), [Done b #19](https://github.com/o/r/issues/19)_'));
58
+ ok('render: counts', /- Untriaged inbox: 4/.test(body) && /- Whiteboard frontier: 1/.test(body) && /- Open maps: 3/.test(body));
59
+ ok('render: anchors line', body.includes('[Codebase #2](https://github.com/o/r/issues/2)') && body.includes('[Conventions #3](https://github.com/o/r/issues/3)'));
60
+ ok('render: last-audit marker verbatim and last', body.trimEnd().endsWith(AUDIT));
61
+ ok('render: no em-dash', !body.includes('—'));
62
+ }
63
+ {
64
+ const body = renderDashboard({ ...SNAP, lastAudit: null, maps: [], whiteboard: [], resolved: [], anchors: { codebase: null, conventions: null } });
65
+ ok('render: empty tracker still has every section', /_No open maps\._/.test(body) && /_Nothing on the whiteboard\._/.test(body));
66
+ ok('render: no marker when none exists', !body.includes('last-audit'));
67
+ ok('render: no anchors line when none exist', !body.includes('Anchors'));
68
+ }
69
+ {
70
+ ok('mapFlags: exhausted needs zero scoped and no fog', JSON.stringify(mapFlags({ openScoped: 0, total: 3, fog: false })) === JSON.stringify(['exhausted · closeable']));
71
+ ok('mapFlags: fog keeps a zero-count map unflagged', mapFlags({ openScoped: 0, total: 3, fog: true }).length === 0);
72
+ ok('mapFlags: at cap', JSON.stringify(mapFlags({ openScoped: 4, total: 100, closedEdges: false })) === JSON.stringify(['at cap · 100/100']));
73
+ ok('mapFlags: at cap prunable', JSON.stringify(mapFlags({ openScoped: 4, total: 100, closedEdges: true })) === JSON.stringify(['at cap · 100/100 · prunable']));
74
+ ok('mapFlags: 89 is not near cap', mapFlags({ openScoped: 4, total: 89 }).length === 0);
75
+ }
76
+
77
+ // #263: classify() follows the panel's precedence (unverified > paused > blocked > in progress > frontier)
78
+ {
79
+ const wbT = (number, assignees, ...extra) => ({ number, title: `T${number}`, assignees, labels: ['trailhead:ticket', 'trailhead:whiteboard', ...extra] });
80
+ const nums = (list) => list.map((t) => t.number);
81
+ const c = classify([
82
+ wbT(1, []),
83
+ wbT(2, ['me']),
84
+ wbT(3, [], 'trailhead:blocked'),
85
+ wbT(4, [], 'trailhead:paused'),
86
+ wbT(5, ['me'], 'trailhead:paused'),
87
+ wbT(6, [], 'trailhead:paused', 'trailhead:blocked'),
88
+ wbT(7, [], 'trailhead:paused', 'trailhead:unverified'),
89
+ wbT(8, ['me'], 'trailhead:blocked'),
90
+ wbT(9, [], 'trailhead:unverified'),
91
+ ]);
92
+ ok('classify: a paused ticket lands in paused, assigned or not, blocked or not', JSON.stringify(nums(c.paused)) === JSON.stringify([4, 5, 6]));
93
+ ok('classify: paused+unverified is quarantined as unverified', JSON.stringify(nums(c.unverified)) === JSON.stringify([7, 9]));
94
+ ok('classify: assigned+blocked is blocked', JSON.stringify(nums(c.blocked)) === JSON.stringify([3, 8]));
95
+ ok('classify: plain assigned is in progress, plain unassigned is frontier', JSON.stringify(nums(c.inProgress)) === JSON.stringify([2]) && JSON.stringify(nums(c.frontier)) === JSON.stringify([1]));
96
+ const body = renderDashboard({ ...SNAP, whiteboard: [wbT(30, []), wbT(31, ['me']), wbT(33, [], 'trailhead:paused'), wbT(34, ['me'], 'trailhead:paused'), wbT(32, [], 'trailhead:blocked')] });
97
+ ok('render: a Paused group sits between In progress and Blocked', /In progress \(1\):\n- \[T31 #31\][^\n]*\nPaused \(2\):\n- \[T33 #33\][^\n]*\n- \[T34 #34\][^\n]*\nBlocked \(1\):/.test(body));
98
+ ok('render: a paused ticket shows only under Paused', body.split('T33 #33').length === 2 && body.split('T34 #34').length === 2);
99
+ ok('render: Whiteboard frontier excludes paused tickets', /- Whiteboard frontier: 1\n/.test(body));
100
+ }
101
+
102
+ // --- fake tracker ------------------------------------------------------------
103
+ function makeTracker(init = {}) {
104
+ const t = {
105
+ maps: init.maps || [
106
+ { number: 14, title: '🗺 multi-host', scoped: 0, total: 20, body: '## Destination\nx\n\n## Not yet specified\n_none yet_\n', edges: [] },
107
+ { number: 98, title: '🗺 website', scoped: 5, total: 92, body: 'body of 98 long enough', edges: [{ number: 1, state: 'closed' }, { number: 2, state: 'open' }] },
108
+ { number: 5, title: '🗺 plain', scoped: 3, total: 10, body: 'body of 5 long enough text', edges: [] },
109
+ ],
110
+ whiteboardOpen: init.whiteboardOpen || [
111
+ { number: 30, title: 'Frontier one', labels: [{ name: 'trailhead:ticket' }, { name: 'trailhead:whiteboard' }], assignees: [] },
112
+ { number: 31, title: 'Doing it', labels: [{ name: 'trailhead:ticket' }, { name: 'trailhead:whiteboard' }], assignees: [{ login: 'me' }] },
113
+ ],
114
+ closed: init.closed || [
115
+ { number: 20, title: 'Done a', closedAt: '2026-10-10T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] },
116
+ { number: 19, title: 'Done old', closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] },
117
+ { number: 18, title: 'Out of scope one', closedAt: '2026-10-12T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:out-of-scope' }] },
118
+ ],
119
+ openIssues: init.openIssues || [
120
+ { number: 50, labels: [] }, { number: 51, labels: [{ name: 'bug' }] }, { number: 52, labels: [{ name: 'trailhead:ticket' }] },
121
+ ],
122
+ codebase: init.codebase === undefined ? 2 : init.codebase,
123
+ conventions: init.conventions === undefined ? 3 : init.conventions,
124
+ dash: init.dash === undefined ? { number: 33, body: 'Old hand written dashboard body.\n\n## Maps\nlots of prose here\n' + AUDIT + '\n', updatedAt: 'T1' } : init.dash,
125
+ pinned: init.pinned || [33],
126
+ edits: init.edits === undefined ? 1 : init.edits,
127
+ editStep: init.editStep === undefined ? 1 : init.editStep,
128
+ labels: [],
129
+ calls: [],
130
+ writes: [],
131
+ created: [],
132
+ pins: [],
133
+ failOn: init.failOn || null,
134
+ // extra open issues per singleton label (duplicates): { label: [{ number, title }] }
135
+ dups: init.dups || {},
136
+ };
137
+ const ok_ = (stdout) => ({ status: 0, stdout, stderr: '' });
138
+ t.gh = (args) => {
139
+ const line = args.join(' ');
140
+ t.calls.push(line);
141
+ if (t.failOn && t.failOn.test(line)) return { status: 1, stdout: '', stderr: 'forced failure' };
142
+ if (args[0] === 'repo' && args[1] === 'view') return ok_('o/r\n');
143
+ if (args[0] === 'label' && args[1] === 'create') { t.labels.push(args[2]); return ok_(''); }
144
+ if (args[0] === 'issue' && args[1] === 'list') {
145
+ const li = args.indexOf('--label');
146
+ const label = li < 0 ? undefined : args[li + 1];
147
+ const state = args[args.indexOf('--state') + 1];
148
+ if (label === 'trailhead:map') return ok_(JSON.stringify(t.maps.map((m) => ({ number: m.number, title: m.title }))));
149
+ if (/^trailhead:map-\d+$/.test(label || '')) {
150
+ const m = t.maps.find((x) => 'trailhead:map-' + x.number === label);
151
+ return ok_(JSON.stringify(Array.from({ length: m.scoped }, (_, i) => ({ number: 1000 + i }))));
152
+ }
153
+ if (label === 'trailhead:whiteboard' && state === 'open') return ok_(JSON.stringify(t.whiteboardOpen));
154
+ if (label === 'trailhead:whiteboard' && state === 'closed') return ok_(JSON.stringify(t.closed));
155
+ const lim = Number(args[args.indexOf('--limit') + 1]);
156
+ const extra = (t.dups[label] || []);
157
+ if (label === 'trailhead:codebase') return ok_(JSON.stringify((t.codebase ? [{ number: t.codebase, title: 'Codebase' }] : []).concat(extra).slice(0, lim)));
158
+ if (label === 'trailhead:conventions') return ok_(JSON.stringify((t.conventions ? [{ number: t.conventions, title: 'Conventions' }] : []).concat(extra).slice(0, lim)));
159
+ if (label === 'trailhead:dashboard') return ok_(JSON.stringify((t.dash ? [{ number: t.dash.number, title: '📊 trailhead dashboard' }] : []).concat(extra).slice(0, lim)));
160
+ if (label === undefined) return ok_(JSON.stringify(t.openIssues));
161
+ }
162
+ if (args[0] === 'issue' && args[1] === 'view') {
163
+ const n = Number(args[2]);
164
+ if (t.dash && n === t.dash.number) return ok_(JSON.stringify({ body: t.dash.body, updatedAt: t.dash.updatedAt }));
165
+ const m = t.maps.find((x) => x.number === n);
166
+ if (m) return ok_(JSON.stringify({ body: m.body, updatedAt: 'M' }));
167
+ }
168
+ if (args[0] === 'issue' && args[1] === 'edit') {
169
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
170
+ t.writes.push(text);
171
+ t.dash.body = text;
172
+ t.edits += t.editStep;
173
+ t.dash.updatedAt = 'T2';
174
+ return ok_('');
175
+ }
176
+ if (args[0] === 'issue' && args[1] === 'create') {
177
+ const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
178
+ t.created.push({ text, args });
179
+ t.dash = { number: 77, body: text, updatedAt: 'T1' };
180
+ return ok_('https://github.com/o/r/issues/77\n');
181
+ }
182
+ if (args[0] === 'api' && args[1] === 'graphql') {
183
+ const q = args.find((a) => a.startsWith('query=')) || '';
184
+ if (/userContentEdits/.test(q)) return ok_(JSON.stringify({ data: { repository: { issue: { userContentEdits: { totalCount: t.edits } } } } }));
185
+ if (/pinnedIssues/.test(q)) return ok_(JSON.stringify({ data: { repository: { pinnedIssues: { nodes: t.pinned.map((number) => ({ issue: { number } })) } } } }));
186
+ if (/pinIssue/.test(q)) { t.pins.push(args.join(' ')); return ok_('{}'); }
187
+ }
188
+ if (args[0] === 'api') {
189
+ const ep = args[1];
190
+ let m = /^repos\/o\/r\/issues\/(\d+)\/sub_issues/.exec(ep);
191
+ if (m) return ok_(JSON.stringify(t.maps.find((x) => x.number === Number(m[1])).edges));
192
+ m = /^repos\/o\/r\/issues\/(\d+)$/.exec(ep);
193
+ if (m) {
194
+ const n = Number(m[1]);
195
+ if (args.includes('.node_id')) return ok_('NODE_' + n + '\n');
196
+ const mp = t.maps.find((x) => x.number === n);
197
+ if (mp) return ok_(String(mp.total) + '\n');
198
+ }
199
+ }
200
+ return { status: 1, stdout: '', stderr: 'unexpected: ' + line };
201
+ };
202
+ return t;
203
+ }
204
+
205
+ const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
206
+
207
+ function runMain(argv, t, extra = {}) {
208
+ const chunks = [];
209
+ const errChunks = [];
210
+ const realErr = process.stderr.write.bind(process.stderr);
211
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
212
+ let code;
213
+ try {
214
+ code = main(argv, { gh: t.gh, stdout: { write: (s) => chunks.push(s) }, guard: fakeGuard, ...extra });
215
+ } finally {
216
+ process.stderr.write = realErr;
217
+ }
218
+ return { code, out: chunks.join(''), err: errChunks.join('') };
219
+ }
220
+
221
+ // --- regenerate an existing dashboard ----------------------------------------
222
+ {
223
+ const t = makeTracker();
224
+ const oldSize = Buffer.byteLength(t.dash.body);
225
+ const r = runMain(['--repo', 'o/r'], t);
226
+ ok('regen: exit 0', r.code === 0);
227
+ ok('regen: exactly one output line', r.out.split('\n').filter(Boolean).length === 1);
228
+ ok('regen: summary line', /^dashboard #33: 3 maps, whiteboard 2, flags: map-14 exhausted, map-98 near cap 92\/100 prunable/.test(r.out));
229
+ ok('regen: reports old and new size', new RegExp(`body ${oldSize} -> ${Buffer.byteLength(t.writes[0])} bytes`).test(r.out));
230
+ ok('regen: one write', t.writes.length === 1);
231
+ ok('regen: the old prose is gone, the shape is generated', !t.writes[0].includes('lots of prose here') && t.writes[0].includes('## 🗺 Maps'));
232
+ ok('regen: last-audit kept verbatim', t.writes[0].trimEnd().endsWith(AUDIT));
233
+ ok('regen: closed out-of-scope tickets are not "resolved", newest first', /Recently resolved: \[Done a #20\].*\[Done old #19\]/.test(t.writes[0]) && !t.writes[0].includes('Out of scope one'));
234
+ ok('regen: inbox counts only issues without a trailhead label', /- Untriaged inbox: 2/.test(t.writes[0]));
235
+ ok('regen: label ensured first', t.labels.includes('trailhead:dashboard'));
236
+ ok('regen: already pinned, no pin call', t.pins.length === 0);
237
+ }
238
+
239
+ // --- singleton lookups fail closed on duplicates (#274) -----------------------
240
+ {
241
+ const noWrites = (t) => t.writes.length === 0 && t.created.length === 0 && t.pins.length === 0 && !t.calls.some((c) => c.startsWith('issue edit') || c.startsWith('issue create') || c.startsWith('label create'));
242
+ // many dashboards
243
+ for (const flags of [[], ['--dry-run']]) {
244
+ const t = makeTracker({ dups: { 'trailhead:dashboard': [{ number: 41, title: 'Dashboard twin\nsecond line' }] } });
245
+ const r = runMain(['--repo', 'o/r', ...flags], t);
246
+ ok(`dups dashboard${flags[0] || ''}: exit 1 and aborted`, r.code === 1 && /^aborted: /.test(r.out));
247
+ ok(`dups dashboard${flags[0] || ''}: names both numbers and titles`, /trailhead:dashboard: .*\(#33\).*\(#41\)/.test(r.out) && r.out.includes('Dashboard twin second line'));
248
+ ok(`dups dashboard${flags[0] || ''}: one line, nothing written`, r.out.split('\n').filter(Boolean).length === 1 && noWrites(t));
249
+ }
250
+ // many codebase / conventions, named together in one abort
251
+ {
252
+ const t = makeTracker({ dups: { 'trailhead:codebase': [{ number: 8, title: 'Codebase 2' }], 'trailhead:conventions': [{ number: 9, title: 'Conv 2' }] } });
253
+ const r = runMain(['--repo', 'o/r'], t);
254
+ ok('dups anchors: exit 1', r.code === 1 && /^aborted: /.test(r.out));
255
+ ok('dups anchors: codebase and conventions both named', /trailhead:codebase: Codebase \(#2\), Codebase 2 \(#8\)/.test(r.out) && /trailhead:conventions: Conv/.test(r.out) && r.out.includes('(#3)') && r.out.includes('(#9)'));
256
+ ok('dups anchors: nothing written', noWrites(t));
257
+ }
258
+ {
259
+ const t = makeTracker({ dups: { 'trailhead:codebase': [{ number: 8, title: 'Codebase 2' }] } });
260
+ const r = runMain(['--repo', 'o/r'], t);
261
+ ok('dups codebase only: aborts, conventions not named', r.code === 1 && /\(#8\)/.test(r.out) && !/trailhead:conventions/.test(r.out) && noWrites(t));
262
+ }
263
+ // many dashboards and no existing dashboard path is untouched: zero dashboard + dup anchors still aborts before create
264
+ {
265
+ const t = makeTracker({ dash: null, dups: { 'trailhead:conventions': [{ number: 9, title: 'Conv 2' }] } });
266
+ const r = runMain(['--repo', 'o/r'], t);
267
+ ok('dups with no dashboard: aborts before create', r.code === 1 && noWrites(t));
268
+ }
269
+ // a window hit by the fetch limit says more may exist (I1)
270
+ {
271
+ const many = Array.from({ length: 11 }, (_, i) => ({ number: 100 + i, title: 'Twin ' + i }));
272
+ const t = makeTracker({ dups: { 'trailhead:dashboard': many } });
273
+ const r = runMain(['--repo', 'o/r'], t);
274
+ ok('dups at the limit: aborts and says possibly more', r.code === 1 && /\(#108\), and possibly more/.test(r.out) && !r.out.includes("(#109)") && noWrites(t));
275
+ const t2 = makeTracker({ dups: { 'trailhead:dashboard': [{ number: 41, title: 'Twin' }] } });
276
+ ok('dups under the limit: no "possibly more"', !/possibly more/.test(runMain(['--repo', 'o/r'], t2).out));
277
+ }
278
+ // control characters in a title never reach the abort line (I2)
279
+ {
280
+ const t = makeTracker({ dups: { 'trailhead:dashboard': [{ number: 41, title: 'Evil\x1b[31m twin\x07\x7f' }] } });
281
+ const r = runMain(['--repo', 'o/r'], t);
282
+ ok('dups: control characters stripped from titles', r.code === 1 && /Evil\\\[31m twin/.test(r.out) && !/[\x00-\x08\x0b-\x1f\x7f]/.test(r.out));
283
+ }
284
+ // zero and one: unchanged
285
+ {
286
+ const t = makeTracker();
287
+ const r = runMain(['--repo', 'o/r'], t);
288
+ ok('one each: writes as before', r.code === 0 && t.writes.length === 1 && /^dashboard #33:/.test(r.out));
289
+ ok('lookups fetch more than one result', t.calls.filter((c) => /--label trailhead:(dashboard|codebase|conventions) /.test(c)).every((c) => !/--limit 1$/.test(c)));
290
+ }
291
+ {
292
+ const t = makeTracker({ dash: null, codebase: 0, conventions: 0 });
293
+ const r = runMain(['--repo', 'o/r'], t);
294
+ ok('zero each: create and pin path unchanged, anchors absent', r.code === 0 && t.created.length === 1 && t.pins.length === 1 && !/Anchors:/.test(t.created[0].text));
295
+ }
296
+ }
297
+
298
+ // --- what is (not) gathered --------------------------------------------------
299
+ {
300
+ const t = makeTracker();
301
+ runMain(['--repo', 'o/r'], t);
302
+ const views = t.calls.filter((c) => c.startsWith('issue view'));
303
+ ok('fog: body read only for the zero-count map', views.some((c) => c.startsWith('issue view 14 ')) && !views.some((c) => c.startsWith('issue view 98 ') || c.startsWith('issue view 5 ')));
304
+ const edges = t.calls.filter((c) => /\/sub_issues/.test(c));
305
+ ok('closed-edge check only for near/at-cap maps', edges.length === 1 && /issues\/98\/sub_issues/.test(edges[0]));
306
+ }
307
+ {
308
+ // a zero-count map with fog left is not exhausted
309
+ const t = makeTracker({ maps: [{ number: 14, title: '🗺 m', scoped: 0, total: 2, body: '## Not yet specified\n- still foggy\n', edges: [] }] });
310
+ const r = runMain(['--repo', 'o/r'], t);
311
+ ok('fog left: not flagged exhausted', /flags: none/.test(r.out) && !t.writes[0].includes('exhausted'));
312
+ }
313
+
314
+ // --- W2: a missing fog section is unconfirmed, not empty ----------------------
315
+ {
316
+ const t = makeTracker({ maps: [{ number: 14, title: '🗺 m', scoped: 0, total: 2, body: '## Destination\nSomewhere good, long enough.\n', edges: [] }] });
317
+ const r = runMain(['--repo', 'o/r'], t);
318
+ ok('W2: no "Not yet specified" section: not flagged exhausted', r.code === 0 && /flags: none/.test(r.out) && !t.writes[0].includes('exhausted'));
319
+ ok('W2: mapFlags keeps fog=null unflagged', mapFlags({ openScoped: 0, total: 2, fog: null }).length === 0);
320
+ }
321
+
322
+ // --- W3: newest closures are inside the window; a hit limit is said out loud --
323
+ {
324
+ const t = makeTracker();
325
+ runMain(['--repo', 'o/r'], t);
326
+ const q = t.calls.find((c) => c.startsWith('issue list --label trailhead:whiteboard --state closed'));
327
+ ok('W3: the resolved query is sorted by recent updates', /--search sort:updated-desc/.test(q));
328
+ ok('W3: a normal run reports no truncation', !/truncated/.test(t.writes[0]) && !/truncated/.test(runMain(['--repo', 'o/r', '--dry-run'], makeTracker()).out));
329
+ }
330
+ {
331
+ const many = (n, extra) => Array.from({ length: n }, (_, i) => ({ number: 5000 + i, title: 't' + i, labels: [], assignees: [], ...extra }));
332
+ const t = makeTracker({ openIssues: many(1000) });
333
+ const r = runMain(['--repo', 'o/r'], t);
334
+ ok('W3: inbox at its limit is said in the output line', r.code === 0 && /truncated: inbox/.test(r.out));
335
+ ok('W3: the inbox count is rendered as a lower bound', /- Untriaged inbox: 1000\+/.test(t.writes[0]));
336
+ const w = makeTracker({ whiteboardOpen: many(200, { labels: [{ name: 'trailhead:whiteboard' }] }) });
337
+ ok('W3: whiteboard at its limit is said', /truncated: whiteboard/.test(runMain(['--repo', 'o/r'], w).out));
338
+ const dropped = many(100, { closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:out-of-scope' }] });
339
+ const c = makeTracker({ closed: dropped });
340
+ ok('W3: a full resolved window that filters down below 10 is said', /truncated: resolved/.test(runMain(['--repo', 'o/r'], c).out));
341
+ const fine = many(100, { closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] });
342
+ ok('W3: a full resolved window with 10 to show is not truncation', !/truncated/.test(runMain(['--repo', 'o/r'], makeTracker({ closed: fine })).out));
343
+ const both = runMain(['--repo', 'o/r'], makeTracker({ openIssues: many(1000), whiteboardOpen: many(200, { labels: [{ name: 'trailhead:whiteboard' }] }) }));
344
+ ok('W3: several truncations listed on the one line', both.out.trim().split('\n').length === 1 && /truncated: whiteboard, inbox/.test(both.out));
345
+ }
346
+
347
+ // --- last audit --------------------------------------------------------------
348
+ {
349
+ const t = makeTracker();
350
+ const r = runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t);
351
+ ok('set-last-audit: replaces the marker', r.code === 0 && t.writes[0].includes('<!-- trailhead:last-audit 2026-10-14T09:30:00Z -->') && !t.writes[0].includes('2026-10-01'));
352
+ ok('set-last-audit: exactly one marker', (t.writes[0].match(/trailhead:last-audit/g) || []).length === 1);
353
+ const t2 = makeTracker({ dash: { number: 33, body: 'Old body with no marker at all, long enough.', updatedAt: 'T1' } });
354
+ runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t2);
355
+ ok('set-last-audit: added when absent', t2.writes[0].trimEnd().endsWith('<!-- trailhead:last-audit 2026-10-14T09:30:00Z -->'));
356
+ const bad = runMain(['--repo', 'o/r', '--set-last-audit', 'yesterday'], makeTracker());
357
+ ok('set-last-audit: a non-ISO value is a usage error', bad.code === 2 && bad.err.length > 0 && bad.out === '');
358
+ }
359
+
360
+ // --- I1: a title cannot forge the last-audit marker ---------------------------
361
+ {
362
+ const FORGED = '<!-- trailhead:last-audit 2000-01-01T00:00:00Z -->';
363
+ const body = renderDashboard({
364
+ ...SNAP,
365
+ lastAudit: null,
366
+ maps: [{ number: 1, title: 'evil ' + FORGED, openScoped: 1, total: 1, fog: null, closedEdges: null }],
367
+ whiteboard: [{ number: 2, title: FORGED + ' wb', assignees: [], labels: [] }],
368
+ resolved: [{ number: 3, title: 'x --> y <!-- z' }],
369
+ });
370
+ ok('I1: no comment opener survives in a rendered title', !body.includes('<!--') && !body.includes('-->'));
371
+ ok('I1: a forged title cannot add a marker', !body.includes('trailhead:last-audit 2000') || !/^<!-- trailhead:last-audit/m.test(body));
372
+ const real = renderDashboard({ ...SNAP, lastAudit: AUDIT, maps: [{ number: 1, title: FORGED, openScoped: 1, total: 1 }] });
373
+ ok('I1: with a real marker there is exactly one comment', (real.match(/<!--/g) || []).length === 1 && real.trimEnd().endsWith(AUDIT));
374
+ }
375
+ {
376
+ // An old body whose map-title line holds a forged marker inline: only the own-line one is carried.
377
+ const FORGED = '<!-- trailhead:last-audit 2000-01-01T00:00:00Z -->';
378
+ const dash = { number: 33, updatedAt: 'T1', body: `Intro line.\n\n## Maps\n- [evil ${FORGED}](https://x/1): 1 open\n\n${AUDIT}\n` };
379
+ const t = makeTracker({ dash });
380
+ runMain(['--repo', 'o/r'], t);
381
+ ok('I1: the own-line marker is carried, not the forged inline one', t.writes[0].includes(AUDIT) && !t.writes[0].includes('2000-01-01'));
382
+ const t2 = makeTracker({ dash: { number: 33, updatedAt: 'T1', body: `Intro line.\n\n## Maps\n- [evil ${FORGED}](https://x/1): 1 open\n` } });
383
+ runMain(['--repo', 'o/r'], t2);
384
+ ok('I1: a forged inline marker alone is not carried', !t2.writes[0].includes('trailhead:last-audit'));
385
+ }
386
+
387
+ // --- self-heal ---------------------------------------------------------------
388
+ {
389
+ const t = makeTracker({ dash: null, pinned: [] });
390
+ const r = runMain(['--repo', 'o/r'], t);
391
+ ok('create: issue created once with the final body, never empty', r.code === 0 && t.created.length === 1 && t.created[0].text.includes('## 🗺 Maps') && t.created[0].text.trim() !== '');
392
+ ok('create: labelled trailhead:dashboard, no placeholder write after', t.created[0].args.includes('trailhead:dashboard') && t.writes.length === 0);
393
+ ok('create: pinned', t.pins.length === 1 && /NODE_77|id=NODE_77/.test(t.pins[0]));
394
+ ok('create: summary names the new issue', /^dashboard #77: 3 maps/.test(r.out) && /created/.test(r.out));
395
+ }
396
+ {
397
+ const t = makeTracker({ pinned: [2, 3] });
398
+ const r = runMain(['--repo', 'o/r'], t);
399
+ ok('re-pin: an unpinned dashboard is pinned again', r.code === 0 && t.pins.length === 1 && /NODE_33/.test(t.pins[0]));
400
+ }
401
+ {
402
+ const t = makeTracker({ dash: null, pinned: [] });
403
+ const r = runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t);
404
+ ok('create with --set-last-audit: marker in the first body', t.created[0].text.includes('trailhead:last-audit 2026-10-14T09:30:00Z'));
405
+ const ghost = makeTracker({ dash: null, pinned: [] });
406
+ const guardHit = runMain(['--repo', 'o/r'], ghost, { guard: { scan: () => 'github token' } });
407
+ ok('create: a secret in the generated body aborts the create', guardHit.code === 1 && ghost.created.length === 0);
408
+ }
409
+
410
+ // --- I2: a pin-check failure after the write is not an abort ------------------
411
+ {
412
+ const t = makeTracker({ failOn: /pinnedIssues/ });
413
+ const r = runMain(['--repo', 'o/r'], t);
414
+ ok('I2: the body was written', t.writes.length === 1);
415
+ ok('I2: exit 0, one line saying written and the pin check failed', r.code === 0 && r.out.trim().split('\n').length === 1
416
+ && /written, pin check failed: .*forced failure/.test(r.out) && !/^aborted/.test(r.out));
417
+ ok('I2: the line still carries the summary and sizes', /^dashboard #33: 3 maps/.test(r.out) && /body \d+ -> \d+ bytes/.test(r.out));
418
+ }
419
+
420
+ // --- guards ------------------------------------------------------------------
421
+ {
422
+ const t = makeTracker();
423
+ t.maps[0].title = '🗺 leak ghp_' + 'a'.repeat(36);
424
+ const r = runMain(['--repo', 'o/r'], t);
425
+ ok('secret in the whole generated body: abort, nothing written', r.code === 1 && /secret/.test(r.out) && t.writes.length === 0);
426
+ const t2 = makeTracker();
427
+ const r2 = runMain(['--repo', 'o/r'], t2, { guard: null });
428
+ ok('no guard module: fail closed', r2.code === 1 && /secret-scan unavailable/.test(r2.out) && t2.writes.length === 0);
429
+ const t3 = makeTracker({ dash: { number: 33, body: '', updatedAt: 'T1' } });
430
+ const r3 = runMain(['--repo', 'o/r'], t3);
431
+ ok('empty existing body: refused, nothing written', r3.code === 1 && /empty/.test(r3.out) && t3.writes.length === 0);
432
+ const t4 = makeTracker({ failOn: /issue list --label trailhead:whiteboard --state open/ });
433
+ const r4 = runMain(['--repo', 'o/r'], t4);
434
+ ok('a failed gather aborts, nothing written', r4.code === 1 && /^aborted: /.test(r4.out) && t4.writes.length === 0);
435
+ }
436
+
437
+ // --- dry run -----------------------------------------------------------------
438
+ {
439
+ const t = makeTracker();
440
+ const r = runMain(['--repo', 'o/r', '--dry-run'], t);
441
+ ok('dry-run: exit 0, one line, sizes', r.code === 0 && r.out.split('\n').filter(Boolean).length === 1 && /dry-run/.test(r.out) && /body \d+ -> \d+ bytes/.test(r.out));
442
+ ok('dry-run: writes nothing', t.writes.length === 0 && t.created.length === 0 && t.pins.length === 0 && !t.calls.some((c) => c.startsWith('label create')));
443
+ const m = makeTracker({ dash: null, pinned: [] });
444
+ const r2 = runMain(['--repo', 'o/r', '--dry-run'], m);
445
+ ok('dry-run: a missing dashboard is reported, not created', r2.code === 0 && /would create/.test(r2.out) && m.created.length === 0 && m.pins.length === 0);
446
+ }
447
+
448
+ // --- usage errors ------------------------------------------------------------
449
+ for (const argv of [['--bogus'], ['--repo'], ['--repo', 'nope'], ['--set-last-audit'], ['extra']]) {
450
+ const t = makeTracker();
451
+ const r = runMain(argv, t);
452
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && r.err.length > 0 && r.out === '' && t.calls.length === 0);
453
+ }
454
+
455
+ // --- end-to-end with a failing fake gh on PATH --------------------------------
456
+ {
457
+ const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-bin-'));
458
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
459
+ const res = spawnSync('node', [scriptPath, '--repo', 'o/r'], { encoding: 'utf8', env: { ...process.env, PATH: binDir + path.delimiter + process.env.PATH } });
460
+ ok('e2e: gh failing aborts with exit 1 and one line', res.status === 1 && /^aborted: /.test(res.stdout) && res.stdout.trim().split('\n').length === 1);
461
+ const bad = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
462
+ ok('e2e: usage error exits 2', bad.status === 2);
463
+ }
464
+
465
+ {
466
+ // GitHub stops recording body edits at 100: the write is clean on an exact read-back.
467
+ const t = makeTracker({ edits: 100, editStep: 0 });
468
+ const r = runMain(['--repo', 'o/r'], t);
469
+ ok('full edit history: the dashboard write is not a conflict', r.code === 0 && !/conflict|aborted/.test(r.out) && t.writes.length === 1);
470
+ ok('full edit history: the output says the check was read-back only', /edit history full/.test(r.out));
471
+ }
472
+ console.log(`dashboard.test.js: ${passed} checks passed`);
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead commit-msg hook (git). Host-independent guardrail: installed into
4
+ // .git/hooks/commit-msg at repo first-use so trailhead's commit discipline is
5
+ // enforced on EVERY `git commit`, whatever agent host drives it (Codex has no
6
+ // hook bus; this git hook is how the commit-guard survives there). Decision #16.
7
+ //
8
+ // Self-contained by design: detached from the trailhead install, it inlines the
9
+ // same rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
10
+ // lib's test drives this hook end-to-end so the copies cannot drift.
11
+ //
12
+ // git passes the commit-message file path as argv[2]. On a violation: print the
13
+ // reason to stderr and exit 1 (git aborts the commit). Otherwise exit 0.
14
+ // Crash-safe: any unexpected error exits 0 (never wedge a commit).
15
+ //
16
+ // Note: a merge/revert/fixup message already fails the conventional-subject
17
+ // rule below, so it needs no special case; `git commit --amend` keeps whatever
18
+ // trailer the original message carried; `git commit --no-verify` bypasses this
19
+ // hook entirely, as before.
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
25
+ const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
26
+ const CO_AUTHORED = /co-authored-by\s*:/i;
27
+ const SCISSORS = '# ------------------------ >8';
28
+ const REFS_LINE = /^\s*refs\s*:(.*)$/i;
29
+
30
+ function messageRegion(raw) {
31
+ const text = String(raw == null ? '' : raw);
32
+ const i = text.indexOf(SCISSORS);
33
+ return i === -1 ? text : text.slice(0, i);
34
+ }
35
+ function subjectOf(raw) {
36
+ for (const line of messageRegion(raw).split('\n')) {
37
+ if (line.trim() === '' || line.startsWith('#')) continue;
38
+ return line.trim();
39
+ }
40
+ return '';
41
+ }
42
+
43
+ // Mirrors parseSessionTicket() in commit-message-check.js: parses the first
44
+ // line of a .trailhead/session-ticket marker (`#<n> <title>`).
45
+ function parseSessionTicket(text) {
46
+ const raw = String(text == null ? '' : text).replace(/^/, '');
47
+ const firstLine = raw.split('\n')[0].replace(/\r$/, '');
48
+ const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
49
+ return m ? Number(m[1]) : null;
50
+ }
51
+
52
+ // Mirrors hasRefsFor() in commit-message-check.js.
53
+ function hasRefsFor(message, n) {
54
+ const target = new RegExp('#' + n + '(?![\\w])');
55
+ for (const line of messageRegion(message).split('\n')) {
56
+ if (line.trim().startsWith('#')) continue;
57
+ const m = line.match(REFS_LINE);
58
+ if (m && target.test(m[1])) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // Reads the session-ticket marker at the worktree top-level. git runs
64
+ // commit-msg from the worktree top-level (linked worktrees included), so
65
+ // process.cwd() is the right root here. Its own try/catch: a read error
66
+ // (marker absent, unreadable, etc.) must never skip the other rules below,
67
+ // it just leaves the Refs rule off.
68
+ function readTicket() {
69
+ try {
70
+ const markerPath = path.join(process.cwd(), '.trailhead', 'session-ticket');
71
+ return parseSessionTicket(fs.readFileSync(markerPath, 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ try {
78
+ const file = process.argv[2];
79
+ const raw = file ? fs.readFileSync(file, 'utf8') : '';
80
+ const region = messageRegion(raw);
81
+
82
+ if (CO_AUTHORED.test(region)) {
83
+ process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
84
+ process.exit(1);
85
+ }
86
+ const subject = subjectOf(raw);
87
+ if (subject && !CONVENTIONAL.test(subject)) {
88
+ process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
89
+ process.exit(1);
90
+ }
91
+ if (subject.length > 72) {
92
+ process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
93
+ process.exit(1);
94
+ }
95
+
96
+ const ticket = readTicket();
97
+ if (Number.isInteger(ticket) && ticket > 0 && !hasRefsFor(raw, ticket)) {
98
+ process.stderr.write(`trailhead: this commit is on ticket #${ticket} (per .trailhead/session-ticket) but has no "Refs: #${ticket}" trailer. Add "Refs: #${ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${ticket}, remove the stale .trailhead/session-ticket marker first.\n`);
99
+ process.exit(1);
100
+ }
101
+ } catch {
102
+ process.exit(0); // never wedge a commit on an unexpected error
103
+ }
104
+ process.exit(0);