@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,305 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead base preflight (self-contained, ships in templates/ so it reaches every
4
+ // install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
5
+ // and Codex). Measures how far the working base is behind the trunk, optionally
6
+ // syncs it, and runs the project's own preflight checks. #243.
7
+ //
8
+ // CLI:
9
+ // node trailhead-base-preflight.js --repo <working-root> [--config-root <project-root>]
10
+ // [--trunk <name>] [--sync] [--no-fetch]
11
+ // --config-root is the original checkout the effective config was loaded from
12
+ // (defaults to --repo); a worktree/clone workspace never re-resolves config.
13
+ //
14
+ // Trunk: --trunk, else the remote default branch (refs/remotes/origin/HEAD), else
15
+ // the first existing of main / master; none found -> status skipped.
16
+ // Fetch: an explicit destination refspec so origin/<trunk> really advances. A
17
+ // failed fetch is never tolerated silently: status unverified (distance on local refs).
18
+ // Base (one ref, deterministic): no remote ref -> local <trunk>; HEAD is the trunk ->
19
+ // origin/<trunk>; HEAD on a ticket branch -> origin/<trunk> unless the local trunk is
20
+ // strictly ahead (then the local trunk); both moved -> status trunk-diverged.
21
+ // Sync (--sync, only when behind): refuses a dirty tracked tree (dirty); on the trunk
22
+ // ff-only (rebase --rebase-merges when it has diverged, merge commits are kept); on a ticket branch rebase when unpublished,
23
+ // merge when it has an upstream. Any failed sync is aborted and HEAD verified back at
24
+ // its pre-sync commit: conflict (paths listed) or sync-failed (git's message).
25
+ // Checks: `preflight.checks` (array of shell commands), nested ({"preflight":
26
+ // {"checks":[...]}}) or flat ({"preflight.checks":[...]}); nested wins when a file
27
+ // carries both. The project file (read from --config-root) wins over the global one
28
+ // (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) as a whole value. Each
29
+ // command runs with `sh -c` at --repo after any sync. Not run on unverified,
30
+ // trunk-diverged, error, conflict, dirty, sync-failed.
31
+ // Prints one JSON line:
32
+ // {"status","trunk","base","behind","ahead","method"?,"pulled"?,"testsRequired",
33
+ // "conflicts":[],"checks":[{command,ok,code,output}],"checksOk","checksError"?,"warnings":[],"message"?}
34
+ // status: current | behind | synced | unverified | trunk-diverged | dirty | conflict |
35
+ // sync-failed | error | skipped
36
+ // Fail closed: only current and synced (with checksOk) let the caller go on.
37
+ // Exit 0 whenever a JSON line is printed; exit 2 only on a usage error.
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const { spawnSync } = require('child_process');
43
+
44
+ const CONFIG_REL = path.join('.trailhead', 'config.json');
45
+ const CHECK_TIMEOUT_MS = 300000;
46
+ const OUTPUT_TAIL = 2000;
47
+ const FLAGS = ['sync', 'no-fetch'];
48
+ const VALUED = ['repo', 'config-root', 'trunk'];
49
+
50
+ function parseArgs(argv) {
51
+ const out = {};
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ const name = a.startsWith('--') ? a.slice(2) : null;
55
+ if (name && VALUED.includes(name)) {
56
+ const v = argv[++i];
57
+ if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
58
+ out[name] = v;
59
+ } else if (name && FLAGS.includes(name)) {
60
+ out[name] = true;
61
+ } else {
62
+ return { error: `usage: unknown argument ${a}` };
63
+ }
64
+ }
65
+ if (!out.repo) return { error: 'usage: missing --repo <working-root>' };
66
+ return out;
67
+ }
68
+
69
+ // Reads preflight.checks out of one config file.
70
+ // { state: 'unset' } | { state: 'set', list, warnings } | { state: 'invalid', message }
71
+ function readChecks(file, fs_) {
72
+ let text;
73
+ try {
74
+ text = fs_.readFileSync(file, 'utf8');
75
+ } catch (err) {
76
+ if (err && err.code === 'ENOENT') return { state: 'unset' };
77
+ return { state: 'invalid', message: `cannot read ${file}: ${err && err.message ? err.message : err}` };
78
+ }
79
+ let cfg;
80
+ try {
81
+ cfg = JSON.parse(text);
82
+ } catch (err) {
83
+ return { state: 'invalid', message: `${file} is not valid JSON` };
84
+ }
85
+ if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
86
+ return { state: 'invalid', message: `${file} is not a JSON object` };
87
+ }
88
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
89
+ const nested = cfg.preflight !== null && typeof cfg.preflight === 'object' && !Array.isArray(cfg.preflight) && has(cfg.preflight, 'checks');
90
+ const flat = has(cfg, 'preflight.checks');
91
+ if (!nested && !flat) return { state: 'unset' };
92
+ const warnings = [];
93
+ if (nested && flat) warnings.push(`${file} sets preflight.checks in both nested and flat form; the nested form wins`);
94
+ const value = nested ? cfg.preflight.checks : cfg['preflight.checks'];
95
+ if (!Array.isArray(value)) return { state: 'invalid', message: `preflight.checks in ${file} must be an array of shell commands` };
96
+ if (value.some((e) => typeof e !== 'string' || e.trim() === '')) {
97
+ return { state: 'invalid', message: `preflight.checks in ${file} must hold only non-empty strings` };
98
+ }
99
+ return { state: 'set', list: value, warnings };
100
+ }
101
+
102
+ function globalConfigPath(env) {
103
+ const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
104
+ return path.join(base, 'trailhead', 'config.json');
105
+ }
106
+
107
+ function tail(text) {
108
+ const t = String(text || '');
109
+ return t.length > OUTPUT_TAIL ? '...' + t.slice(t.length - OUTPUT_TAIL) : t;
110
+ }
111
+
112
+ // Runs git at `repo`; never throws on a non-zero exit.
113
+ function makeGit(repo, env) {
114
+ const gitEnv = { ...process.env, ...env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
115
+ const G = (args) => {
116
+ const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: gitEnv, maxBuffer: 64 * 1024 * 1024 });
117
+ if (r.error) throw r.error;
118
+ const res = { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
119
+ G.last = res;
120
+ return res;
121
+ };
122
+ return G;
123
+ }
124
+
125
+ function detectTrunk(requested, G) {
126
+ if (requested) return requested;
127
+ const head = G(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
128
+ if (head.ok && head.out.startsWith('origin/')) return head.out.slice('origin/'.length);
129
+ for (const name of ['main', 'master']) {
130
+ if (G(['show-ref', '--verify', '-q', `refs/heads/${name}`]).ok || G(['show-ref', '--verify', '-q', `refs/remotes/origin/${name}`]).ok) return name;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ // Commit count of a range; null when git fails (the caller reports the error, never a 0).
136
+ const count = (G, range) => {
137
+ const r = G(['rev-list', '--count', range]);
138
+ const n = Number(r.out);
139
+ return r.ok && r.out !== '' && Number.isInteger(n) ? n : null;
140
+ };
141
+
142
+ const gitFailure = (G, what) => `${what}: ${(G.last && (G.last.err || G.last.out)) || 'git failed'}`;
143
+
144
+ // Picks the one base ref. { base, diverged } or { error } when a comparison fails.
145
+ function pickBase(G, trunk, head, hasLocal, hasRemote) {
146
+ const remote = `origin/${trunk}`;
147
+ if (!hasRemote) return { base: trunk, diverged: false };
148
+ if (!hasLocal || head === trunk) return { base: remote, diverged: false };
149
+ const what = `cannot compare ${trunk} with ${remote}`;
150
+ const localBehind = count(G, `${trunk}..${remote}`);
151
+ if (localBehind === null) return { error: gitFailure(G, what) };
152
+ const localAhead = count(G, `${remote}..${trunk}`);
153
+ if (localAhead === null) return { error: gitFailure(G, what) };
154
+ if (localBehind > 0 && localAhead > 0) return { base: remote, diverged: true };
155
+ return { base: localAhead > 0 ? trunk : remote, diverged: false };
156
+ }
157
+
158
+ // Puts HEAD and the tree back at `pre` after a failed sync.
159
+ function restore(G, pre, warnings) {
160
+ G(['rebase', '--abort']);
161
+ G(['merge', '--abort']);
162
+ if (G(['rev-parse', 'HEAD']).out !== pre) {
163
+ G(['reset', '--hard', pre]);
164
+ warnings.push('the failed sync left HEAD moved; it was reset to the pre-sync commit');
165
+ }
166
+ const back = G(['rev-parse', 'HEAD']).out === pre && G(['status', '--porcelain', '--untracked-files=no']).out === '';
167
+ if (!back) warnings.push('could not verify the tree is back at its pre-sync state; inspect it before going on');
168
+ }
169
+
170
+ // Syncs the working base onto `base`. Returns fields for the result.
171
+ function sync(G, { trunk, head, base, ahead, behind, warnings }) {
172
+ if (G(['status', '--porcelain', '--untracked-files=no']).out !== '') {
173
+ return { status: 'dirty', message: 'tracked changes in the working tree; commit or stash them before syncing' };
174
+ }
175
+ if (!head) return { status: 'sync-failed', message: 'detached HEAD: nothing to sync' };
176
+ const pre = G(['rev-parse', 'HEAD']).out;
177
+ let method;
178
+ let r;
179
+ if (head === trunk) {
180
+ method = ahead === 0 ? 'ff' : 'rebase';
181
+ r = method === 'ff' ? G(['merge', '--ff-only', '-q', base]) : G(['rebase', '-q', '--rebase-merges', base]);
182
+ } else if (G(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).ok) {
183
+ method = 'merge';
184
+ r = G(['merge', '--no-edit', '-q', base]);
185
+ } else {
186
+ method = 'rebase';
187
+ r = G(['rebase', '-q', '--rebase-merges', base]);
188
+ }
189
+ if (r.ok) return { status: 'synced', method, pulled: behind, testsRequired: true };
190
+ const conflicts = G(['diff', '--name-only', '--diff-filter=U']).out.split('\n').filter(Boolean);
191
+ restore(G, pre, warnings);
192
+ if (conflicts.length > 0) return { status: 'conflict', method, conflicts, message: `${method} onto ${base} conflicts; aborted, the tree is unchanged` };
193
+ return { status: 'sync-failed', method, message: r.err || r.out || `${method} failed` };
194
+ }
195
+
196
+ function runChecks(list, repo, env) {
197
+ const results = list.map((command) => {
198
+ const r = spawnSync('sh', ['-c', command], { cwd: repo, encoding: 'utf8', env: { ...process.env, ...env }, timeout: CHECK_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
199
+ const timedOut = r.error && r.error.code === 'ETIMEDOUT';
200
+ const output = (r.stdout || '') + (r.stderr || '') + (timedOut ? `\n(timed out after ${CHECK_TIMEOUT_MS / 1000}s)` : r.error && !timedOut ? `\n${r.error.message}` : '');
201
+ return { command, ok: r.status === 0, code: r.status, output: tail(output) };
202
+ });
203
+ return results;
204
+ }
205
+
206
+ const NO_CHECKS = new Set(['unverified', 'trunk-diverged', 'error', 'skipped', 'conflict', 'dirty', 'sync-failed']);
207
+
208
+ function preflight(parsed, cwd, env, fs_) {
209
+ const repo = path.resolve(cwd, parsed.repo);
210
+ const configRoot = path.resolve(cwd, parsed['config-root'] || parsed.repo);
211
+ const G = makeGit(repo, env);
212
+ const warnings = [];
213
+ const result = { status: 'error', trunk: null, base: null, behind: 0, ahead: 0, testsRequired: false, conflicts: [], checks: [], checksOk: false, warnings };
214
+
215
+ if (!G(['rev-parse', '--git-dir']).ok) return { ...result, message: `${repo} is not a git repository` };
216
+ const trunk = detectTrunk(parsed.trunk, G);
217
+ if (!trunk) return { ...result, status: 'skipped', message: 'no trunk found (no remote default branch, main or master)' };
218
+ result.trunk = trunk;
219
+
220
+ let fetchFailed = false;
221
+ const hasOrigin = G(['remote', 'get-url', 'origin']).ok;
222
+ if (!hasOrigin) {
223
+ const remotes = G(['remote']).out.split('\n').filter(Boolean);
224
+ if (remotes.length > 0) warnings.push(`no remote named origin (found: ${remotes.join(', ')}); distance measured on local refs`);
225
+ }
226
+ if (hasOrigin && !parsed['no-fetch']) {
227
+ const f = G(['fetch', '--quiet', 'origin', `+refs/heads/${trunk}:refs/remotes/origin/${trunk}`]);
228
+ if (!f.ok) {
229
+ fetchFailed = true;
230
+ warnings.push(`fetch of origin/${trunk} failed, distance measured on local refs: ${f.err || 'unknown error'}`);
231
+ }
232
+ }
233
+ if (parsed['no-fetch']) warnings.push('--no-fetch: distance measured on local refs');
234
+
235
+ const hasLocal = G(['show-ref', '--verify', '-q', `refs/heads/${trunk}`]).ok;
236
+ const hasRemote = G(['show-ref', '--verify', '-q', `refs/remotes/origin/${trunk}`]).ok;
237
+ if (!hasLocal && !hasRemote) return { ...result, status: 'skipped', message: `trunk ${trunk} has no local or remote ref` };
238
+ const headName = G(['symbolic-ref', '-q', '--short', 'HEAD']);
239
+ const head = headName.ok ? headName.out : null;
240
+ const picked = pickBase(G, trunk, head, hasLocal, hasRemote);
241
+ if (picked.error) return { ...result, message: picked.error };
242
+ result.base = picked.base;
243
+ const behind = count(G, `HEAD..${picked.base}`);
244
+ const ahead = behind === null ? null : count(G, `${picked.base}..HEAD`);
245
+ if (behind === null || ahead === null) return { ...result, message: gitFailure(G, `cannot measure HEAD against ${picked.base}`) };
246
+ result.behind = behind;
247
+ result.ahead = ahead;
248
+ result.status = fetchFailed ? 'unverified' : picked.diverged ? 'trunk-diverged' : result.behind > 0 ? 'behind' : 'current';
249
+ if (fetchFailed) result.message = `could not fetch origin/${trunk}; the distance is not verified`;
250
+ if (picked.diverged) result.message = `local ${trunk} and origin/${trunk} have diverged; reconcile the trunk first`;
251
+
252
+ if (parsed.sync && result.status === 'behind') {
253
+ const s = sync(G, { trunk, head, base: picked.base, ahead: result.ahead, behind: result.behind, warnings });
254
+ Object.assign(result, s);
255
+ if (s.status === 'synced') {
256
+ // The sync succeeded; a failed recount is reported, never turned into a 0.
257
+ result.behind = count(G, `HEAD..${picked.base}`);
258
+ result.ahead = count(G, `${picked.base}..HEAD`);
259
+ if (result.behind === null || result.ahead === null) warnings.push(gitFailure(G, `cannot recount HEAD against ${picked.base} after the sync`));
260
+ }
261
+ }
262
+
263
+ if (!NO_CHECKS.has(result.status)) {
264
+ let cfg = readChecks(path.join(configRoot, CONFIG_REL), fs_);
265
+ if (cfg.state === 'unset') cfg = readChecks(globalConfigPath(env), fs_);
266
+ if (cfg.state === 'invalid') {
267
+ result.checksError = cfg.message;
268
+ } else {
269
+ if (cfg.state === 'set') {
270
+ warnings.push(...cfg.warnings);
271
+ result.checks = runChecks(cfg.list, repo, env);
272
+ }
273
+ result.checksOk = result.checks.every((c) => c.ok);
274
+ }
275
+ }
276
+ return result;
277
+ }
278
+
279
+ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
280
+ const cwd_ = cwd || process.cwd();
281
+ const out = stdout || process.stdout;
282
+ const env_ = env || process.env;
283
+ const fs_ = fsMod || fs;
284
+
285
+ const parsed = parseArgs(argv || []);
286
+ if (parsed.error) {
287
+ process.stderr.write(parsed.error + '\n');
288
+ return 2;
289
+ }
290
+ let result;
291
+ try {
292
+ result = preflight(parsed, cwd_, env_, fs_);
293
+ } catch (err) {
294
+ result = { status: 'error', trunk: null, base: null, behind: 0, ahead: 0, testsRequired: false, conflicts: [], checks: [], checksOk: false, warnings: [], message: 'error: ' + (err && err.message ? err.message : String(err)) };
295
+ }
296
+ out.write(JSON.stringify(result) + '\n');
297
+ return 0;
298
+ }
299
+
300
+ module.exports = { parseArgs, readChecks, main };
301
+
302
+ if (require.main === module) {
303
+ const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
304
+ process.exit(code);
305
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead checkout-in (self-contained, ships in templates/ so it reaches every
4
+ // install channel). The panel cannot move the session's directory, so it hands the
5
+ // pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
6
+ // verb moves there first. This helper decodes the token, validates it and says
7
+ // whether the session is already there. It fails closed: it never searches for
8
+ // another checkout. #312.
9
+ //
10
+ // The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
11
+ // with spaces, quotes or a leading `-` travels as one shell-safe word.
12
+ //
13
+ // CLI:
14
+ // node trailhead-checkout-in.js --in <b64url> --cwd <dir>
15
+ //
16
+ // Prints exactly one JSON line on stdout:
17
+ // {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
18
+ // `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
19
+ // Exit 0 for the three statuses, 2 on a usage error (stderr).
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const PREFIX = '--in=';
26
+
27
+ function encodeIn(p) {
28
+ return PREFIX + Buffer.from(String(p), 'utf8').toString('base64url');
29
+ }
30
+
31
+ function decodeIn(token) {
32
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token)) return null;
33
+ const p = Buffer.from(token, 'base64url').toString('utf8');
34
+ // Round-trip guard: a token that does not re-encode to itself is not ours.
35
+ return p !== '' && Buffer.from(p, 'utf8').toString('base64url') === token ? p : null;
36
+ }
37
+
38
+ function real(p) {
39
+ try {
40
+ return fs.realpathSync(p);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function toplevelOf(dir) {
47
+ const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
48
+ if (r.status !== 0) return null;
49
+ const top = r.stdout.replace(/\n$/, '');
50
+ return top === '' ? null : real(top);
51
+ }
52
+
53
+ // POSIX single-quote escaping: ' becomes '\''.
54
+ function cdCommand(p) {
55
+ return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
56
+ }
57
+
58
+ function check(token, cwd) {
59
+ const p = decodeIn(token);
60
+ if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
61
+ if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
62
+ const canon = real(p);
63
+ if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
64
+ // The path must be a checkout root itself, never a folder inside one.
65
+ if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
66
+ const here = real(cwd) !== null ? toplevelOf(cwd) : null;
67
+ if (here === canon) return { status: 'here', path: p };
68
+ return { status: 'move', path: p, cd: cdCommand(p) };
69
+ }
70
+
71
+ function main(argv) {
72
+ const get = (flag) => {
73
+ const i = argv.indexOf(flag);
74
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
75
+ };
76
+ const token = get('--in');
77
+ const cwd = get('--cwd');
78
+ if (token === null || cwd === null) {
79
+ process.stderr.write('usage: trailhead-checkout-in.js --in <b64url> --cwd <dir>\n');
80
+ return 2;
81
+ }
82
+ process.stdout.write(JSON.stringify(check(token, cwd)) + '\n');
83
+ return 0;
84
+ }
85
+
86
+ module.exports = { encodeIn, decodeIn, check, cdCommand };
87
+
88
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
@@ -1,104 +1,40 @@
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 '';
1
+ #!/bin/sh
2
+ # trailhead commit-msg hook (git). Thin sh wrapper around the CommonJS logic in
3
+ # trailhead-commit-msg.cjs beside it (#257: an extensionless node script would
4
+ # inherit the "type" of the user repo's package.json, so "type": "module" broke it).
5
+ #
6
+ # Never blocks a commit on a startup failure: node missing, the .cjs missing or
7
+ # unreadable, or node crashing at load all exit 0, but print a one-line "inactive"
8
+ # notice on stderr so the failure is not silent. Only the .cjs's dedicated
9
+ # rule-violation code (3) becomes exit 1, which makes git abort the commit.
10
+
11
+ # Avviso unico su stderr, stesso formato per tutti i casi.
12
+ inactive() {
13
+ echo "trailhead: commit-msg hook inactive ($1); commit rules not enforced." >&2
41
14
  }
42
15
 
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);
16
+ case $0 in
17
+ */*) dir=${0%/*} ;;
18
+ *) dir=. ;;
19
+ esac
20
+ cjs="$dir/trailhead-commit-msg.cjs"
21
+
22
+ if [ ! -r "$cjs" ]; then
23
+ inactive "trailhead-commit-msg.cjs missing or unreadable"
24
+ exit 0
25
+ fi
26
+ if ! command -v node >/dev/null 2>&1; then
27
+ inactive "node not found on PATH"
28
+ exit 0
29
+ fi
30
+
31
+ node "$cjs" "$@"
32
+ code=$?
33
+ case $code in
34
+ 0) exit 0 ;;
35
+ 3) exit 1 ;;
36
+ *)
37
+ inactive "trailhead-commit-msg.cjs failed to run, exit $code"
38
+ exit 0
39
+ ;;
40
+ esac