@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,318 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead auto-merge helper (self-contained, ships in templates/ so it reaches every
4
+ // install channel). Merges a verified slot branch of an autonomous run into the local
5
+ // trunk (never a push), recovers an interrupted merge, and removes a merged slot. #286.
6
+ //
7
+ // CLI (every command runs against the MAIN checkout, which is on the trunk):
8
+ // node trailhead-auto-merge.js merge --repo <main-checkout> --ticket <n> [--trunk <name>]
9
+ // node trailhead-auto-merge.js recover --repo <main-checkout>
10
+ // node trailhead-auto-merge.js remove --repo <main-checkout> --ticket <n> [--trunk <name>]
11
+ //
12
+ // Trunk: --trunk, else the remote default branch (refs/remotes/origin/HEAD), else the
13
+ // first existing of main / master (the base-preflight rule); none -> error.
14
+ // Every git-state path (MERGE_HEAD, MERGE_MSG, rebase-merge, ...) is resolved with
15
+ // `git rev-parse --git-path`, never assumed under .git/.
16
+ //
17
+ // merge: preconditions first, each a distinct status with nothing touched:
18
+ // not-on-trunk HEAD of the main checkout is not refs/heads/<trunk>
19
+ // in-progress a merge / rebase / cherry-pick is already under way (run recover)
20
+ // dirty tracked changes in the main checkout (untracked files are fine)
21
+ // slot-dirty the branch's slot worktree has uncommitted tracked work
22
+ // error the branch does not exist, or the trunk cannot be resolved
23
+ // Then: already-merged when the tip is an ancestor of the trunk (an idempotent retry);
24
+ // otherwise `git merge --no-edit -m "chore(auto): merge trailhead/t<n>" -m "Refs: #<n>"`
25
+ // (fast-forward when possible, else a merge commit). The main checkout's
26
+ // .trailhead/session-ticket (never written or rewritten here) does not block a merge: a
27
+ // fast-forward creates no commit, so the commit-msg hook never runs; a true merge commit
28
+ // adds --no-verify only when the marker names a ticket other than the merged one (the
29
+ // hook would demand that ticket's Refs trailer, and the helper already builds the whole
30
+ // message). No marker, or the same ticket: the hook stays active.
31
+ // merged method ff|merge, from (trunk before), head (trunk after)
32
+ // conflict conflicting paths in `conflicts`; the merge is aborted
33
+ // merge-failed any other failure (hook refusal, untracked file in the way); aborted
34
+ // An abort that does not leave HEAD at its pre-merge commit with a clean tracked tree
35
+ // falls back to `git reset --merge <pre>` (safe: the preconditions guarantee a clean
36
+ // tracked tree); only when that fails too is the result error "main left mid-merge",
37
+ // the one case the caller must stop on. Never --force, never reset --hard.
38
+ // On merged / already-merged, `behind` lists every OTHER trailhead/t* worktree slot with
39
+ // trunk commits not in its branch ({ticket, path, behind}), informational.
40
+ //
41
+ // recover: no merge under way -> clean; a merge whose MERGE_MSG first line is
42
+ // "chore(auto): merge trailhead/t<n>" -> aborted (same abort / fallback), with `ticket`;
43
+ // anything else (a human's merge, a rebase, a cherry-pick) -> foreign, untouched.
44
+ //
45
+ // remove: only for a branch already merged into the trunk (else not-merged). A worktree
46
+ // slot is removed with `git worktree remove` (no --force: a dirty one is an error and
47
+ // stays) and the branch deleted with `git branch -d`; a clone slot is never deleted
48
+ // (kept). Idempotent: a worktree already gone or a branch already deleted is skipped.
49
+ // removed carries worktreeRemoved / branchDeleted (what THIS call did).
50
+ //
51
+ // One orchestrator runs merges one at a time. Two autonomous runs on the same main
52
+ // checkout are not supported (no lock file); the in-progress precondition keeps this
53
+ // helper from touching a merge it did not start.
54
+ //
55
+ // Prints exactly one JSON line on stdout: {"status":"...", ...}. Exit 0 whenever the
56
+ // status is a result (parked statuses included), 1 on status error, 2 on a usage error.
57
+
58
+ const { spawnSync } = require('child_process');
59
+ const nodeFs = require('fs');
60
+ const path = require('path');
61
+
62
+ function defaultGit(args) {
63
+ const env = { ...process.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
64
+ const r = spawnSync('git', args, { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
65
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
66
+ }
67
+
68
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
69
+ const branchOf = (n) => `trailhead/t${n}`;
70
+ const BRANCH_RE = /^refs\/heads\/trailhead\/t(\d+)$/;
71
+ const MERGE_SUBJECT = /^chore\(auto\): merge trailhead\/t(\d+)$/;
72
+ const IN_PROGRESS = ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD'];
73
+
74
+ // --- args ---------------------------------------------------------------------
75
+
76
+ const FLAGS = { merge: ['--repo', '--ticket', '--trunk'], recover: ['--repo'], remove: ['--repo', '--ticket', '--trunk'] };
77
+
78
+ function parseArgs(argv) {
79
+ const cmd = argv[0];
80
+ if (!Object.prototype.hasOwnProperty.call(FLAGS, cmd)) return { error: `usage: expected merge, recover or remove, got: ${cmd === undefined ? '(nothing)' : cmd}` };
81
+ const o = { cmd, repo: null, ticket: null, trunk: null };
82
+ for (let i = 1; i < argv.length; i++) {
83
+ const flag = argv[i];
84
+ if (!FLAGS[cmd].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
85
+ i++;
86
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
87
+ const v = argv[i];
88
+ if (flag === '--ticket') {
89
+ if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --ticket must be a positive integer, got: ${v}` };
90
+ o.ticket = Number(v);
91
+ } else if (!v.trim()) {
92
+ return { error: `usage: ${flag} requires a non-empty value` };
93
+ } else if (flag === '--repo') {
94
+ o.repo = v;
95
+ } else {
96
+ o.trunk = v;
97
+ }
98
+ }
99
+ if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
100
+ if (cmd !== 'recover' && !o.ticket) return { error: `usage: ${cmd} requires --ticket` };
101
+ return o;
102
+ }
103
+
104
+ // --- git helpers --------------------------------------------------------------
105
+
106
+ // [{ path, branch|null }] from `git worktree list --porcelain`, main checkout first.
107
+ function parseWorktrees(text) {
108
+ const out = [];
109
+ for (const block of String(text).split(/\n\s*\n/)) {
110
+ let wt = null;
111
+ let branch = null;
112
+ for (const line of block.split('\n')) {
113
+ if (line.startsWith('worktree ')) wt = line.slice('worktree '.length);
114
+ else if (line.startsWith('branch ')) branch = line.slice('branch '.length);
115
+ }
116
+ if (wt) out.push({ path: wt, branch });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ const realOr = (fs, p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
122
+
123
+ // Absolute path of a git-state file of the main checkout (via --git-path).
124
+ function statePath(ctx, name) {
125
+ const r = ctx.G(['rev-parse', '--git-path', name]);
126
+ return r.status === 0 && r.stdout.trim() ? path.resolve(ctx.main, r.stdout.trim()) : null;
127
+ }
128
+
129
+ const stateExists = (ctx, name) => { const p = statePath(ctx, name); return p !== null && ctx.fs.existsSync(p); };
130
+ const inProgress = (ctx) => IN_PROGRESS.filter((n) => stateExists(ctx, n));
131
+ const head = (ctx) => ctx.G(['rev-parse', 'HEAD']).stdout.trim();
132
+ const trackedDirty = (ctx, dir) => ctx.git(['-C', dir || ctx.main, 'status', '--porcelain', '--untracked-files=no']).stdout.trim() !== '';
133
+ const hasRef = (ctx, ref) => ctx.G(['rev-parse', '--verify', '--quiet', ref]).status === 0;
134
+ const isAncestor = (ctx, a, b) => ctx.G(['merge-base', '--is-ancestor', a, b]).status === 0;
135
+
136
+ // Trunk name per the base-preflight rule, or null.
137
+ function detectTrunk(ctx, requested) {
138
+ if (requested) return requested;
139
+ const h = ctx.G(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
140
+ if (h.status === 0 && h.stdout.trim().startsWith('origin/')) return h.stdout.trim().slice('origin/'.length);
141
+ for (const name of ['main', 'master']) {
142
+ if (hasRef(ctx, `refs/heads/${name}`) || hasRef(ctx, `refs/remotes/origin/${name}`)) return name;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ // The main checkout's state is exactly the pre-merge one: HEAD at pre, no merge, clean tracked tree.
148
+ const settled = (ctx, pre) => head(ctx) === pre && !stateExists(ctx, 'MERGE_HEAD') && !trackedDirty(ctx);
149
+
150
+ // Puts the main checkout back at `pre`. Abort first, reset --merge as the fallback.
151
+ function undoMerge(ctx, pre) {
152
+ if (stateExists(ctx, 'MERGE_HEAD')) ctx.G(['merge', '--abort']);
153
+ if (settled(ctx, pre)) return true;
154
+ ctx.G(['reset', '--merge', pre]);
155
+ return settled(ctx, pre);
156
+ }
157
+
158
+ const stuck = (ctx, fail) => fail('main left mid-merge: the merge could not be aborted; resolve or abort it by hand in ' + ctx.main);
159
+
160
+ // Slot worktrees other than `ticket`, with trunk commits their branch lacks.
161
+ function behindList(ctx, trunk, ticket) {
162
+ const wl = ctx.G(['worktree', 'list', '--porcelain']);
163
+ if (wl.status !== 0) return [];
164
+ const out = [];
165
+ for (const w of parseWorktrees(wl.stdout)) {
166
+ const m = w.branch && BRANCH_RE.exec(w.branch);
167
+ if (!m || Number(m[1]) === ticket || realOr(ctx.fs, w.path) === ctx.main || !ctx.fs.existsSync(w.path)) continue;
168
+ const c = ctx.G(['rev-list', '--count', `${branchOf(m[1])}..refs/heads/${trunk}`]);
169
+ const n = Number(c.stdout.trim());
170
+ if (c.status === 0 && Number.isInteger(n) && n > 0) out.push({ ticket: Number(m[1]), path: w.path, behind: n });
171
+ }
172
+ return out.sort((a, b) => a.ticket - b.ticket);
173
+ }
174
+
175
+ // The marker's ticket number, or null (absent, unreadable or unparseable: the hook ignores it too).
176
+ function markerTicket(ctx) {
177
+ try {
178
+ const raw = String(ctx.fs.readFileSync(path.join(ctx.main, '.trailhead', 'session-ticket'), 'utf8')).replace(/^\uFEFF/, '');
179
+ const m = raw.split('\n')[0].replace(/\r$/, '').match(/^#([1-9]\d*)(?:\s|$)/);
180
+ return m ? Number(m[1]) : null;
181
+ } catch { return null; }
182
+ }
183
+
184
+ // --- merge --------------------------------------------------------------------
185
+
186
+ function mergeBranch(ctx, o) {
187
+ const { fail, emit } = ctx;
188
+ const branch = branchOf(o.ticket);
189
+ const ref = `refs/heads/${branch}`;
190
+ const trunk = detectTrunk(ctx, o.trunk);
191
+ if (!trunk) return fail('no trunk found (no remote default branch, main or master); pass --trunk');
192
+ if (!hasRef(ctx, `refs/heads/${trunk}`)) return fail(`trunk ${trunk} has no local branch`);
193
+ const result = (status, extra) => { emit({ status, ticket: o.ticket, branch, trunk, ...extra }); return 0; };
194
+
195
+ const sym = ctx.G(['symbolic-ref', '-q', 'HEAD']);
196
+ if (sym.stdout.trim() !== `refs/heads/${trunk}`) return result('not-on-trunk', { reason: `the main checkout is on ${sym.stdout.trim() || 'a detached HEAD'}, not ${trunk}` });
197
+ const busy = inProgress(ctx);
198
+ if (busy.length) return result('in-progress', { reason: `${busy.join(', ')} present in the main checkout; run recover first` });
199
+ if (trackedDirty(ctx)) return result('dirty', { reason: 'tracked changes in the main checkout' });
200
+ if (!hasRef(ctx, ref)) return fail(`branch ${branch} does not exist`);
201
+
202
+ const wl = ctx.G(['worktree', 'list', '--porcelain']);
203
+ const slot = wl.status === 0 ? parseWorktrees(wl.stdout).find((w) => w.branch === ref && realOr(ctx.fs, w.path) !== ctx.main) : null;
204
+ if (slot && ctx.fs.existsSync(slot.path) && trackedDirty(ctx, slot.path)) return result('slot-dirty', { path: slot.path, reason: `uncommitted tracked work in ${slot.path} would be left out of the merge` });
205
+
206
+ if (isAncestor(ctx, ref, `refs/heads/${trunk}`)) return result('already-merged', { head: head(ctx), behind: behindList(ctx, trunk, o.ticket) });
207
+
208
+ const pre = head(ctx);
209
+ // marker di un altro ticket: l'hook commit-msg rifiuterebbe il merge commit, lo saltiamo
210
+ const marker = markerTicket(ctx);
211
+ const skipHook = marker !== null && marker !== o.ticket;
212
+ const m = ctx.G(['merge', '--no-edit', ...(skipHook ? ['--no-verify'] : []), '-m', `chore(auto): merge ${branch}`, '-m', `Refs: #${o.ticket}`, branch]);
213
+ if (m.status === 0) {
214
+ if (!isAncestor(ctx, ref, 'HEAD')) return fail(`git merge reported success but ${branch} is not in the trunk`);
215
+ const method = ctx.G(['rev-parse', '--verify', '--quiet', 'HEAD^2']).status === 0 ? 'merge' : 'ff';
216
+ return result('merged', { method, from: pre, head: head(ctx), behind: behindList(ctx, trunk, o.ticket) });
217
+ }
218
+
219
+ const conflicts = ctx.G(['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean);
220
+ const reason = firstLine(m.stderr) || firstLine(m.stdout) || `git merge exited ${m.status}`;
221
+ if (!undoMerge(ctx, pre)) return stuck(ctx, fail);
222
+ if (conflicts.length > 0) return result('conflict', { conflicts });
223
+ return result('merge-failed', { reason });
224
+ }
225
+
226
+ // --- recover ------------------------------------------------------------------
227
+
228
+ function recoverMerge(ctx) {
229
+ const { fail, emit } = ctx;
230
+ const busy = inProgress(ctx);
231
+ if (!busy.length) { emit({ status: 'clean' }); return 0; }
232
+ if (!busy.includes('MERGE_HEAD')) { emit({ status: 'foreign', reason: `${busy.join(', ')} in progress, not started by a run` }); return 0; }
233
+ let msg = '';
234
+ try { msg = String(ctx.fs.readFileSync(statePath(ctx, 'MERGE_MSG'), 'utf8')); } catch { /* unreadable: foreign */ }
235
+ const m = MERGE_SUBJECT.exec(firstLine(msg).trim());
236
+ if (!m) { emit({ status: 'foreign', reason: 'a merge not started by a run is in progress' }); return 0; }
237
+ if (!undoMerge(ctx, head(ctx))) return stuck(ctx, fail);
238
+ emit({ status: 'aborted', ticket: Number(m[1]), branch: branchOf(m[1]) });
239
+ return 0;
240
+ }
241
+
242
+ // --- remove -------------------------------------------------------------------
243
+
244
+ // True when dir is an independent checkout (its git dir lives inside it) on `branch`.
245
+ function cloneOnBranch(ctx, dir, branch) {
246
+ const g = ctx.git(['-C', dir, 'rev-parse', '--absolute-git-dir']);
247
+ if (g.status !== 0) return false;
248
+ const gitDir = realOr(ctx.fs, g.stdout.trim());
249
+ const root = realOr(ctx.fs, dir);
250
+ if (gitDir !== root && !gitDir.startsWith(root + path.sep)) return false;
251
+ const b = ctx.git(['-C', dir, 'symbolic-ref', '--quiet', 'HEAD']);
252
+ return b.status === 0 && b.stdout.trim() === `refs/heads/${branch}`;
253
+ }
254
+
255
+ function removeSlot(ctx, o) {
256
+ const { fail, emit } = ctx;
257
+ const branch = branchOf(o.ticket);
258
+ const ref = `refs/heads/${branch}`;
259
+ const trunk = detectTrunk(ctx, o.trunk);
260
+ if (!trunk) return fail('no trunk found (no remote default branch, main or master); pass --trunk');
261
+ if (!hasRef(ctx, `refs/heads/${trunk}`)) return fail(`trunk ${trunk} has no local branch`);
262
+
263
+ const wl = ctx.G(['worktree', 'list', '--porcelain']);
264
+ if (wl.status !== 0) return fail(`git worktree list failed: ${firstLine(wl.stderr) || 'exit ' + wl.status}`);
265
+ const slot = parseWorktrees(wl.stdout).find((w) => w.branch === ref && realOr(ctx.fs, w.path) !== ctx.main);
266
+
267
+ if (!slot) {
268
+ const sibling = path.join(path.dirname(ctx.main), `${path.basename(ctx.main)}-t${o.ticket}`);
269
+ if (ctx.fs.existsSync(sibling) && cloneOnBranch(ctx, sibling, branch)) { emit({ status: 'kept', ticket: o.ticket, kind: 'clone', path: sibling, reason: 'a clone slot is never deleted' }); return 0; }
270
+ }
271
+
272
+ const branchExists = hasRef(ctx, ref);
273
+ if (branchExists && !isAncestor(ctx, ref, `refs/heads/${trunk}`)) {
274
+ emit({ status: 'not-merged', ticket: o.ticket, branch, reason: `${branch} is not merged into ${trunk}; nothing removed` });
275
+ return 0;
276
+ }
277
+
278
+ let worktreeRemoved = false;
279
+ if (slot && ctx.fs.existsSync(slot.path)) {
280
+ const rm = ctx.G(['worktree', 'remove', slot.path]);
281
+ if (rm.status !== 0) return fail(`git worktree remove failed for ${slot.path}, kept: ${firstLine(rm.stderr) || 'exit ' + rm.status}`);
282
+ worktreeRemoved = true;
283
+ } else if (slot) {
284
+ return fail(`stale worktree of ${branch}: ${slot.path} is gone from disk but git still lists it; run git worktree prune and retry`);
285
+ }
286
+
287
+ let branchDeleted = false;
288
+ if (branchExists) {
289
+ const d = ctx.G(['branch', '-d', branch]);
290
+ if (d.status !== 0) return fail(`git branch -d ${branch} failed: ${firstLine(d.stderr) || 'exit ' + d.status}`);
291
+ branchDeleted = true;
292
+ }
293
+ emit({ status: 'removed', ticket: o.ticket, branch, worktreeRemoved, branchDeleted });
294
+ return 0;
295
+ }
296
+
297
+ function main(argv, { git, stdout, stderr, fs } = {}) {
298
+ const out = stdout || process.stdout;
299
+ const err = stderr || process.stderr;
300
+ const o = parseArgs(argv || []);
301
+ if (o.error) { err.write(o.error + '\n'); return 2; }
302
+ const fsx = fs || nodeFs;
303
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
304
+ const fail = (reason, extra) => { emit({ status: 'error', reason, ...extra }); return 1; };
305
+ const run = git || defaultGit;
306
+ const mainDir = realOr(fsx, o.repo);
307
+ const ctx = { git: run, G: (args) => run(['-C', mainDir, ...args]), fs: fsx, main: mainDir, fail, emit };
308
+ if (ctx.G(['rev-parse', '--git-dir']).status !== 0) return fail(`${o.repo} is not a git repository`);
309
+ if (o.cmd === 'merge') return mergeBranch(ctx, o);
310
+ if (o.cmd === 'recover') return recoverMerge(ctx);
311
+ return removeSlot(ctx, o);
312
+ }
313
+
314
+ module.exports = { main, parseArgs, parseWorktrees };
315
+
316
+ if (require.main === module) {
317
+ process.exit(main(process.argv.slice(2), {}));
318
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead auto queue (self-contained, ships in templates/ so it reaches every
4
+ // install channel). Builds the ordered work queue of an autonomous run for a chosen
5
+ // run scope: one map, the whiteboard, or everything. #280.
6
+ //
7
+ // CLI:
8
+ // node trailhead-auto-queue.js scopes [--repo <owner/name>]
9
+ // node trailhead-auto-queue.js queue --scope map:<n>|whiteboard|everything
10
+ // [--skip <csv>] [--repo <owner/name>]
11
+ //
12
+ // The frontier is the canonical frontier query (substrate-commands.md): open,
13
+ // `trailhead:ticket`, unassigned, not blocked / unverified / paused. The
14
+ // `trailhead:blocked` label is the source of truth for blockers; this helper adds
15
+ // no dependency logic. The run's own read also skips `trailhead:needs-you` (a ticket
16
+ // a run parked for the human); the human frontier does not. Groups are ordered by
17
+ // map number ascending, the whiteboard last; tickets inside a group ascending by
18
+ // number. `--skip` names the tickets the
19
+ // run already marked needs-you or stopped on; `next` is the lowest ticket of the first
20
+ // group that still has one (null when the scope is drained). A read that hits the
21
+ // 1000 limit fails closed (never a partial queue).
22
+ //
23
+ // Prints exactly one JSON line on stdout:
24
+ // scopes: {"status":"ok","maps":[{"number","title","frontier"}],"whiteboard":{"frontier"}}
25
+ // queue: {"status":"ok","scope","groups":[{"kind","map","title","tickets":[{"number","title","type"}],"resume":[...]}],"drift":[...],"next":{...}|null}
26
+ // error: {"status":"error","reason":"..."}
27
+ // Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
28
+
29
+ const { spawnSync } = require('child_process');
30
+
31
+ const LIMIT = 1000;
32
+ const FRONTIER_SEARCH = 'no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused';
33
+ const NEEDS_YOU = 'trailhead:needs-you';
34
+ // The run's own frontier read: the canonical frontier minus the tickets a run parked
35
+ // for the human. Humans still see those on the frontier (FRONTIER_SEARCH is unchanged).
36
+ const RUN_SEARCH = FRONTIER_SEARCH + ' -label:' + NEEDS_YOU;
37
+ const WHITEBOARD = 'trailhead:whiteboard';
38
+ const MAP_LABEL_RE = /^trailhead:map-(\d+)$/;
39
+ const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
40
+
41
+ function defaultGh(args) {
42
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
43
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
44
+ }
45
+
46
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
47
+ const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
48
+ const byNumber = (a, b) => a.number - b.number;
49
+
50
+ function parseScope(v) {
51
+ if (v === 'everything' || v === 'whiteboard') return v;
52
+ return /^map:[1-9]\d*$/.test(String(v)) ? v : null;
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const cmd = argv[0];
57
+ if (cmd !== 'scopes' && cmd !== 'queue') return { error: `usage: expected scopes or queue, got: ${cmd === undefined ? '(nothing)' : cmd}` };
58
+ const o = { cmd, repo: null, scope: null, skip: [] };
59
+ for (let i = 1; i < argv.length; i++) {
60
+ const flag = argv[i];
61
+ const allowed = cmd === 'queue' ? ['--repo', '--scope', '--skip'] : ['--repo'];
62
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
63
+ i++;
64
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
65
+ const v = argv[i];
66
+ if (flag === '--repo') {
67
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
68
+ o.repo = v;
69
+ } else if (flag === '--scope') {
70
+ o.scope = parseScope(v);
71
+ if (!o.scope) return { error: `usage: --scope must be map:<n>, whiteboard or everything, got: ${v}` };
72
+ } else {
73
+ const parts = v.split(',').map((s) => s.trim()).filter(Boolean);
74
+ if (!parts.every((s) => /^[1-9]\d*$/.test(s))) return { error: `usage: --skip must be a csv of ticket numbers, got: ${v}` };
75
+ o.skip = parts.map(Number);
76
+ }
77
+ }
78
+ if (cmd === 'queue' && !o.scope) return { error: 'usage: queue requires --scope' };
79
+ return o;
80
+ }
81
+
82
+ const typeOf = (labelNames) => {
83
+ const t = TYPES.find((x) => labelNames.includes(`trailhead:${x}`));
84
+ return t || null;
85
+ };
86
+
87
+ // Places each ticket: on its open map, else the whiteboard, else nowhere. A ticket
88
+ // on more than one open map is drift (placed on none).
89
+ function place(openMaps, issues) {
90
+ const open = new Set(openMaps.map((m) => m.number));
91
+ const byMap = new Map(openMaps.map((m) => [m.number, []]));
92
+ const whiteboard = [];
93
+ const drift = [];
94
+ for (const i of issues) {
95
+ const ln = names(i.labels);
96
+ const mapNums = ln.map((l) => (MAP_LABEL_RE.exec(l) || [])[1]).filter(Boolean).map(Number).filter((n) => open.has(n));
97
+ const row = { number: i.number, title: i.title, type: typeOf(ln) };
98
+ if (mapNums.length > 1) drift.push({ ...row, maps: [...new Set(mapNums)].sort((a, b) => a - b), whiteboard: ln.includes(WHITEBOARD) });
99
+ else if (mapNums.length === 1) byMap.get(mapNums[0]).push(row);
100
+ else if (ln.includes(WHITEBOARD)) whiteboard.push(row);
101
+ }
102
+ return { byMap, whiteboard, drift };
103
+ }
104
+
105
+ // Pure: openMaps [{number,title}], frontier / paused issues [{number,title,labels}],
106
+ // scope, skip [n]. Returns { groups, drift, next } or { error }.
107
+ function buildQueue(openMaps, frontier, paused, scope, skip) {
108
+ const skipSet = new Set(skip || []);
109
+ // A needs-you ticket is dropped even when the search index lagged and returned it.
110
+ const live = (list) => list.filter((i) => !skipSet.has(i.number) && !names(i.labels).includes(NEEDS_YOU));
111
+ const maps = [...openMaps].sort(byNumber);
112
+ let wantMaps = maps;
113
+ let wantWhiteboard = scope === 'whiteboard' || scope === 'everything';
114
+ if (scope === 'whiteboard') wantMaps = [];
115
+ else if (scope.startsWith('map:')) {
116
+ const n = Number(scope.slice(4));
117
+ wantMaps = maps.filter((m) => m.number === n);
118
+ if (!wantMaps.length) return { error: `map #${n} is not an open trailhead:map` };
119
+ wantWhiteboard = false;
120
+ }
121
+ const f = place(maps, live(frontier));
122
+ const p = place(maps, live(paused));
123
+ const sorted = (rows) => [...rows].sort(byNumber);
124
+ const groups = wantMaps.map((m) => ({ kind: 'map', map: m.number, title: m.title, tickets: sorted(f.byMap.get(m.number)), resume: sorted(p.byMap.get(m.number)) }));
125
+ if (wantWhiteboard) groups.push({ kind: 'whiteboard', map: null, title: 'Whiteboard', tickets: sorted(f.whiteboard), resume: sorted(p.whiteboard) });
126
+ const inScope = (d) => (scope === 'everything'
127
+ || (scope === 'whiteboard' && d.whiteboard)
128
+ || (scope.startsWith('map:') && d.maps.includes(Number(scope.slice(4)))));
129
+ const seen = new Set();
130
+ const drift = [...f.drift, ...p.drift].filter(inScope).filter((d) => !seen.has(d.number) && seen.add(d.number)).sort(byNumber)
131
+ .map(({ number, title, type, maps: ms }) => ({ number, title, type, maps: ms }));
132
+ const first = groups.find((g) => g.tickets.length);
133
+ const next = first ? { ...first.tickets[0], map: first.map } : null;
134
+ return { groups, drift, next };
135
+ }
136
+
137
+ function readList(run, args, what) {
138
+ const r = run(args);
139
+ if (r.status !== 0) return { error: `gh issue list (${what}) failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
140
+ let rows;
141
+ try { rows = JSON.parse(r.stdout); } catch { return { error: `gh issue list (${what}) returned unparseable JSON` }; }
142
+ if (!Array.isArray(rows)) return { error: `gh issue list (${what}) returned unexpected JSON` };
143
+ if (rows.length >= LIMIT) return { error: `incomplete list: the ${what} read hit the ${LIMIT} limit` };
144
+ return { rows };
145
+ }
146
+
147
+ function main(argv, { gh, stdout, stderr } = {}) {
148
+ const out = stdout || process.stdout;
149
+ const err = stderr || process.stderr;
150
+ const o = parseArgs(argv || []);
151
+ if (o.error) { err.write(o.error + '\n'); return 2; }
152
+ const run = gh || defaultGh;
153
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
154
+ const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
155
+ const R = o.repo ? ['-R', o.repo] : [];
156
+ const lim = ['--limit', String(LIMIT)];
157
+
158
+ const maps = readList(run, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', ...lim, '--json', 'number,title', ...R], 'open maps');
159
+ if (maps.error) return fail(maps.error);
160
+ const frontier = readList(run, ['issue', 'list', '--label', 'trailhead:ticket', '--state', 'open', '--search', RUN_SEARCH, ...lim, '--json', 'number,title,labels', ...R], 'frontier');
161
+ if (frontier.error) return fail(frontier.error);
162
+
163
+ if (o.cmd === 'scopes') {
164
+ const q = buildQueue(maps.rows, frontier.rows, [], 'everything', []);
165
+ const wb = q.groups.find((g) => g.kind === 'whiteboard');
166
+ emit({
167
+ status: 'ok',
168
+ maps: q.groups.filter((g) => g.kind === 'map').map((g) => ({ number: g.map, title: g.title, frontier: g.tickets.length })),
169
+ whiteboard: { frontier: wb.tickets.length },
170
+ });
171
+ return 0;
172
+ }
173
+
174
+ const paused = readList(run, ['issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:paused', '--assignee', '@me', '--state', 'open', ...lim, '--json', 'number,title,labels', ...R], 'paused claims');
175
+ if (paused.error) return fail(paused.error);
176
+ const q = buildQueue(maps.rows, frontier.rows, paused.rows, o.scope, o.skip);
177
+ if (q.error) return fail(q.error);
178
+ emit({ status: 'ok', scope: o.scope, groups: q.groups, drift: q.drift, next: q.next });
179
+ return 0;
180
+ }
181
+
182
+ module.exports = { main, parseArgs, buildQueue, FRONTIER_SEARCH, RUN_SEARCH, NEEDS_YOU };
183
+
184
+ if (require.main === module) {
185
+ process.exit(main(process.argv.slice(2), {}));
186
+ }