@marcomigozzi/trailhead 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -11,17 +11,21 @@
11
11
  # trailhead al work-start, porta anche il titolo) -> numero dal branch
12
12
  # `trailhead/t<N>` -> titolo via `gh` in cache/background (solo se origin GitHub).
13
13
  #
14
- # Contesto: barra `[BARRA] Nk/Mk (P%)`, dall'ultimo usage del transcript. Tetto:
15
- # $CONTEXT_LIMIT -> .model.context_window -> tabella modello -> auto-bump 1M.
14
+ # Contesto: barra `[BARRA] Nk/Mk (P%)`, dall'ultimo usage del transcript. Tetto
15
+ # (finestra del modello): $CONTEXT_LIMIT -> .context_window.context_window_size ->
16
+ # tabella modello -> auto-bump 1M. La % e' contro la soglia di auto-compact di
17
+ # Claude Code (finestra di compattazione - 20000 - 13000), non contro un 80% fisso:
18
+ # vedi il commento sopra il blocco CTX per le regole. CONTEXT_USABLE_RATIO resta
19
+ # solo come override manuale (0 < ratio <= 1: denominatore = tetto * ratio).
16
20
  #
17
21
  # Usage: % REALE del piano via l'API OAuth di Anthropic (come ccstatusline):
18
22
  # GET https://api.anthropic.com/api/oauth/usage con il Bearer preso da
19
- # ~/.claude/.credentials.json (claudeAiOauth.accessToken). Mostra la finestra
23
+ # ${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json (claudeAiOauth.accessToken). Mostra la finestra
20
24
  # 5h e la settimana (7 giorni) piu' il reset. Cache ~60s, chiamata in background
21
25
  # (il render non aspetta la rete). Disabilita con TRAILHEAD_STATUSLINE_USAGE=0.
22
26
  #
23
27
  # Uso:
24
- # - standalone: settings.json -> statusLine.command = "bash ~/.claude/statusline-trailhead.sh"
28
+ # - standalone: settings.json -> statusLine.command = "bash <config dir>/statusline-trailhead.sh" (config dir = $CLAUDE_CONFIG_DIR, default ~/.claude)
25
29
  # - widget singolo (ccstatusline custom command): "--ticket-only" | "--context-only" | "--usage-only"
26
30
 
27
31
  set -uo pipefail
@@ -139,12 +143,24 @@ fi
139
143
  # Fonte primaria (come ccstatusline): .context_window del JSON di Claude Code
140
144
  # (context_window_size = tetto reale, current_usage = token). Fallback: ultimo
141
145
  # usage del transcript + tabella modello. $CONTEXT_LIMIT forza il tetto.
142
- # Denominatore = finestra * 0.8 (soglia auto-compact, come ccstatusline); il
143
- # rapporto e' regolabile con CONTEXT_USABLE_RATIO (1 = tetto pieno).
146
+ # Denominatore = soglia di auto-compact di Claude Code:
147
+ # finestra di compattazione = min(tetto modello, primo valido tra
148
+ # $CLAUDE_CODE_AUTO_COMPACT_WINDOW, autoCompactWindow dei settings, tetto);
149
+ # nei settings (utente < progetto < progetto.local, vince il piu' specifico)
150
+ # modelSettings.<model id senza [..]>.autoCompactWindow batte autoCompactWindow;
151
+ # soglia = finestra - 20000 (output riservato, massimo) - 13000 (buffer), min 1;
152
+ # $CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (0 < pct <= 100) puo' solo abbassarla:
153
+ # min(soglia, floor(finestra * pct / 100)).
154
+ # Con DISABLE_AUTO_COMPACT / DISABLE_COMPACT truthy il denominatore e' il tetto pieno.
155
+ # CONTEXT_USABLE_RATIO (0 < r <= 1), se impostato, e' un override manuale:
156
+ # denominatore = tetto * r, senza calcolo di compattazione.
144
157
  CTX="$(
145
158
  { [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ] && tail -n 300 "$TRANSCRIPT" 2>/dev/null | tac | grep -m1 'cache_read_input_tokens'; } | \
146
159
  CTX_MODEL_ID="$(j '.model.id')" \
147
160
  CTX_CW_SIZE="$(j '.context_window.context_window_size')" \
161
+ CTX_PROJECT_DIR="$(j '.workspace.project_dir')" \
162
+ CTX_CURRENT_DIR="$(j '.workspace.current_dir')" \
163
+ CTX_CWD="$(j '.cwd')" \
148
164
  CTX_USAGE="$(printf '%s' "$input" | jq -c '.context_window.current_usage // empty' 2>/dev/null)" \
149
165
  python3 -c '
150
166
  import sys, os, json, re
@@ -192,16 +208,65 @@ if not limit:
192
208
  if tok > limit and limit <= 200000: # auto-bump 1M solo nel fallback
193
209
  limit = 1000000
194
210
 
195
- # soglia usabile (auto-compact): denominatore = finestra * ratio (0.8 come ccstatusline)
196
- try:
197
- ratio = float(os.environ.get("CONTEXT_USABLE_RATIO") or 0.8)
198
- except Exception:
199
- ratio = 0.8
200
- denom = round(limit * ratio) if limit else 0
201
- pct = min(100, round(tok*100/denom)) if denom else 0 # % contro la soglia 0.8
211
+ def pos_num(x):
212
+ # numero finito > 0, altrimenti None (bool e stringhe non numeriche scartati)
213
+ if isinstance(x, bool) or x is None: return None
214
+ try: v = float(x)
215
+ except Exception: return None
216
+ return v if v == v and v not in (float("inf"),) and v > 0 else None
217
+
218
+ def truthy(name):
219
+ return (os.environ.get(name) or "").strip().lower() in ("1", "true", "yes", "on")
220
+
221
+ def load_settings():
222
+ # utente < progetto < progetto.local; file illeggibili/invalidi saltati
223
+ cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
224
+ proj = os.environ.get("CTX_PROJECT_DIR") or os.environ.get("CTX_CURRENT_DIR") or os.environ.get("CTX_CWD")
225
+ paths = [os.path.join(cfg, "settings.json")]
226
+ if proj:
227
+ paths += [os.path.join(proj, ".claude", "settings.json"),
228
+ os.path.join(proj, ".claude", "settings.local.json")]
229
+ out = []
230
+ for p in paths:
231
+ try:
232
+ d = json.load(open(p))
233
+ if isinstance(d, dict): out.append(d)
234
+ except Exception:
235
+ pass
236
+ return out # dal meno al piu prioritario
237
+
238
+ def setting_window(model):
239
+ files = load_settings()[::-1] # piu prioritario per primo
240
+ for d in files: # per-modello batte top-level: prima cerco il per-modello
241
+ ms = d.get("modelSettings")
242
+ v = pos_num((ms.get(model) or {}).get("autoCompactWindow")) if isinstance(ms, dict) and isinstance(ms.get(model), dict) else None
243
+ if v: return v
244
+ for d in files:
245
+ v = pos_num(d.get("autoCompactWindow"))
246
+ if v: return v
247
+ return None
248
+
249
+ def threshold_for(limit):
250
+ model = re.sub(r"\[[^\]]*\]\s*$", "", os.environ.get("CTX_MODEL_ID") or "")
251
+ w = pos_num(os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) or setting_window(model) or limit
252
+ w = int(min(w, limit))
253
+ thr = max(1, w - 20000 - 13000)
254
+ p = pos_num(os.environ.get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"))
255
+ if p is not None and p <= 100:
256
+ thr = max(1, min(thr, int(w * p // 100)))
257
+ return thr
258
+
259
+ ratio = pos_num(os.environ.get("CONTEXT_USABLE_RATIO"))
260
+ if ratio is not None and ratio <= 1:
261
+ denom = round(limit * ratio) # override manuale
262
+ elif truthy("DISABLE_AUTO_COMPACT") or truthy("DISABLE_COMPACT"):
263
+ denom = limit # niente compattazione: tetto pieno
264
+ else:
265
+ denom = threshold_for(limit)
266
+ pct = min(100, round(tok*100/denom)) if denom else 0
202
267
  n = 10; filled = round(pct*n/100)
203
268
  bar = "█"*filled + "░"*(n-filled)
204
- # numeri REALI: token effettivi / tetto pieno; la percentuale riflette auto-compact 0.8
269
+ # numeri REALI: token effettivi / tetto pieno; la percentuale e contro la soglia di compattazione
205
270
  print(f"[{bar}] {round(tok/1000)}k/{round(limit/1000)}k ({pct}%)")
206
271
  ' 2>/dev/null
207
272
  )"
@@ -212,7 +277,7 @@ print(f"[{bar}] {round(tok/1000)}k/{round(limit/1000)}k ({pct}%)")
212
277
  # Retry-After (default 300s); altri errori 30s. Timeout 5s (USAGE_API_TIMEOUT lo cambia).
213
278
  # Chiamata in background: il render non attende mai. Off con TRAILHEAD_STATUSLINE_USAGE=0.
214
279
  USAGE_RAW=""
215
- CREDS="$HOME/.claude/.credentials.json"
280
+ CREDS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
216
281
  if [ "${TRAILHEAD_STATUSLINE_USAGE:-1}" != "0" ] && [ -f "$CREDS" ]; then
217
282
  CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/trailhead-statusline"
218
283
  mkdir -p "$CACHE" 2>/dev/null
@@ -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
+ }