@marcomigozzi/trailhead 0.17.0 → 0.18.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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  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 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -8,6 +8,9 @@
8
8
  // CLI:
9
9
  // node trailhead-plan-review-rounds.js ask --repo <project-root> [--host claude|codex]
10
10
  // node trailhead-plan-review-rounds.js round --choice <config|1|2|3|until> --configured <n> --history <file>
11
+ // node trailhead-plan-review-rounds.js budget --repo <project-root> --reviewer <name> --prompt <file>
12
+ // node trailhead-plan-review-rounds.js overflow --output <file> [--prompt <brief file>]
13
+ // node trailhead-plan-review-rounds.js run --output <file> [--timeout <seconds>] -- <cmd> [args...]
11
14
  // Prints one JSON line on stdout. Exit 0 ok / 1 error / 2 usage (message on stderr).
12
15
  //
13
16
  // Config: `plan_review` (off | on | comma list of CLIs, default off) and `plan_review.rounds`
@@ -39,11 +42,50 @@
39
42
  // concerns) | ceiling (choice until: 5 rounds done) | exhausted (fixed choice: its rounds are
40
43
  // spent) | continue. Every stop but converged carries `surviving`, the latest round's concerns.
41
44
  // `config` resolves to --configured.
45
+ //
46
+ // budget: pre-check of one reviewer call. Estimates the prompt file's tokens as ceil(utf8 bytes / 3)
47
+ // (an estimate on the high side for code and English, not a guarantee; `overflow` covers what it
48
+ // misses) and compares them with cap = floor(window * 0.65), the other 35% being headroom for the
49
+ // reviewer's own system prompt and output. The window is the per-CLI default (CONTEXT_WINDOWS, 128000
50
+ // for any other name) or the `plan_review.context` override. Decision, first match wins: skip
51
+ // (tokens > cap) | file (bytes > 102400, a single Linux argument over 128 KiB fails with E2BIG) | arg.
52
+ // {"status":"ok","reviewer","decision","bytes","tokens","window","cap","windowSource":"default"|"config",
53
+ // "reason"?,"pointer"?,"warnings":[]}
54
+ // `reason` (skip) is ready to quote in PLAN-REVIEW; `pointer` (file) is the short argument to pass
55
+ // instead of the prompt. Skip is still exit 0: the verdict is data, not a failure.
56
+ //
57
+ // overflow: post-check of one failed reviewer call. A pure pattern match over the captured output
58
+ // (stdout and stderr together) for the known context-overflow and E2BIG messages. With --prompt
59
+ // <brief file>, lines of the brief echoed back by the CLI are ignored; rate-limit lines never count.
60
+ // {"status":"ok","overflow":bool,"reason"?:<the matched line, trimmed and length-capped>}
61
+ //
62
+ // run: launches ONE reviewer call under a wall-clock deadline, so no call can outlive its bound
63
+ // (the Bash tool's own `timeout` does not apply to a background call). `--` ends the helper's flags:
64
+ // everything after it is the command's argv verbatim (no shell). The command gets stdin closed and its
65
+ // stdout and stderr together go to --output (a file descriptor, truncated at start, no pipes), in its
66
+ // own process group. At the deadline the group gets SIGTERM, then SIGKILL after a grace period (10s;
67
+ // TRAILHEAD_REVIEW_GRACE_MS overrides it, for tests), so a child that ignores SIGTERM and its
68
+ // descendants still end. If the helper itself gets SIGTERM, SIGINT or SIGHUP it kills the group first (exit 143, 130, 129).
69
+ // A process that leaves the group with setsid is out of reach of the group kill, and a reviewer that
70
+ // exits in the window after the deadline fires is still reported `timeout`.
71
+ // {"status":"ok"|"failed"|"timeout"|"error","exitCode":<n|null>,"signal":<s|null>,"seconds":<n>,
72
+ // "timeoutSeconds":<n>,"output":<file>,"reason"?:<text, for failed, timeout and error>}
73
+ // ok = exit 0; failed = non-zero exit (or a signal) before the deadline; timeout = the deadline hit,
74
+ // whatever the exit then was and even when the output has content (do not run `overflow` on it);
75
+ // error = the command could not be launched (ENOENT, E2BIG, EACCES...), the error text is also written
76
+ // into --output. All four exit 0 (the reviewer's result is data). --timeout is a positive integer,
77
+ // default 600 seconds. A missing --output or command is a usage error (exit 2); an unwritable
78
+ // --output is exit 1. `main` returns a Promise of the exit code for this subcommand only.
79
+ //
80
+ // Config `plan_review.context`: an object { "<cli>": <window in tokens> }, merged per reviewer across
81
+ // the two files (project entry, else global entry, else default). A valid entry is a positive integer
82
+ // no larger than 10000000; anything else warns and falls through to the next layer; a value that is
83
+ // not an object warns and is ignored.
42
84
 
43
85
  const fs = require('fs');
44
86
  const os = require('os');
45
87
  const path = require('path');
46
- const { spawnSync } = require('child_process');
88
+ const { spawn, spawnSync } = require('child_process');
47
89
 
48
90
  const CONFIG_REL = path.join('.trailhead', 'config.json');
49
91
  const ROSTER = ['claude', 'gemini', 'codex', 'qwen', 'kimi', 'agy', 'opencode', 'cursor-agent', 'coderabbit'];
@@ -51,7 +93,35 @@ const DEFAULT_ROUNDS = 2;
51
93
  const CEILING = 5;
52
94
  const HOSTS = ['claude', 'codex'];
53
95
  const CHOICES = ['config', '1', '2', '3', 'until'];
54
- const VALUED = { ask: ['repo', 'host'], round: ['choice', 'configured', 'history'] };
96
+ const VALUED = { ask: ['repo', 'host'], round: ['choice', 'configured', 'history'], budget: ['repo', 'reviewer', 'prompt'], overflow: ['output', 'prompt'], run: ['output', 'timeout'] };
97
+ // Default context windows in tokens; any other reviewer name gets FALLBACK_WINDOW.
98
+ const CONTEXT_WINDOWS = { claude: 200000, gemini: 1000000, codex: 272000, qwen: 256000, kimi: 256000, agy: 1000000, opencode: 128000, 'cursor-agent': 128000, coderabbit: 128000 };
99
+ const FALLBACK_WINDOW = 128000;
100
+ const MAX_WINDOW = 10000000;
101
+ const CAP_RATIO = 0.65;
102
+ const BYTES_PER_TOKEN = 3;
103
+ const FILE_THRESHOLD_BYTES = 102400;
104
+ const REASON_MAX = 200;
105
+ // Wall-clock deadline of one reviewer call: above the 540s floor agy already uses. It is wall-clock
106
+ // (not a no-output check) because a healthy reviewer may stay quiet for minutes.
107
+ const DEFAULT_TIMEOUT_SECONDS = 600;
108
+ // Time between SIGTERM and SIGKILL to the reviewer's process group.
109
+ const KILL_GRACE_MS = 10000;
110
+ // Last resort after SIGKILL: stop waiting for an exit event that never comes.
111
+ const EXIT_FALLBACK_MS = 2000;
112
+ // Known context-overflow and E2BIG messages of the reviewer CLIs (case-insensitive).
113
+ const OVERFLOW_PATTERNS = [
114
+ /context_length_exceeded/i,
115
+ /maximum context length/i,
116
+ /prompt is too long/i,
117
+ /exceeds the context window/i,
118
+ /input is too long/i,
119
+ /input token count .* exceeds the maximum/i,
120
+ /argument list too long/i,
121
+ /E2BIG/i,
122
+ ];
123
+ // A rate-limit error can mention tokens too; it is never a context overflow.
124
+ const RATE_LIMIT = /rate.?limit|\b429\b|too many requests/i;
55
125
 
56
126
  class Usage extends Error {}
57
127
 
@@ -61,6 +131,10 @@ function parseArgs(argv) {
61
131
  const out = { command };
62
132
  for (let i = 1; i < argv.length; i++) {
63
133
  const a = argv[i];
134
+ if (command === 'run' && a === '--') {
135
+ out.cmd = argv.slice(i + 1);
136
+ break;
137
+ }
64
138
  const name = a.startsWith('--') ? a.slice(2) : null;
65
139
  if (!name || !VALUED[command].includes(name)) return { error: `usage: unknown argument ${a}` };
66
140
  const v = argv[++i];
@@ -70,6 +144,16 @@ function parseArgs(argv) {
70
144
  if (command === 'ask') {
71
145
  if (!out.repo) return { error: 'usage: missing --repo <project-root>' };
72
146
  if (out.host !== undefined && !HOSTS.includes(out.host)) return { error: `usage: --host must be one of ${HOSTS.join(', ')}` };
147
+ } else if (command === 'budget') {
148
+ if (!out.repo) return { error: 'usage: missing --repo <project-root>' };
149
+ if (!out.reviewer) return { error: 'usage: missing --reviewer <name>' };
150
+ if (!out.prompt) return { error: 'usage: missing --prompt <file>' };
151
+ } else if (command === 'overflow') {
152
+ if (!out.output) return { error: 'usage: missing --output <file>' };
153
+ } else if (command === 'run') {
154
+ if (!out.output) return { error: 'usage: missing --output <file>' };
155
+ if (out.timeout !== undefined && !/^[1-9]\d*$/.test(out.timeout)) return { error: 'usage: --timeout needs a positive integer (seconds)' };
156
+ if (!out.cmd || out.cmd.length === 0 || out.cmd[0] === '') return { error: 'usage: missing command after -- <cmd> [args...]' };
73
157
  } else {
74
158
  if (!out.choice || !CHOICES.includes(out.choice)) return { error: `usage: --choice must be one of ${CHOICES.join(', ')}` };
75
159
  if (!out.configured || !/^[1-9]\d*$/.test(out.configured)) return { error: 'usage: --configured needs a positive integer' };
@@ -100,12 +184,17 @@ function readFile(file, fs_) {
100
184
  return { cfg };
101
185
  }
102
186
 
187
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
188
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
189
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
190
+
103
191
  function globalConfigPath(env) {
104
- const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
192
+ const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
105
193
  return path.join(base, 'trailhead', 'config.json');
106
194
  }
107
195
 
108
196
  // Effective { planReview, rounds } (undefined when unset) or { error }. Nearest file wins per key.
197
+ // `contextLayers` is every file that sets plan_review.context, nearest first (merged per reviewer later).
109
198
  function readConfig(root, env, fs_) {
110
199
  const files = [path.join(root, CONFIG_REL), globalConfigPath(env)];
111
200
  const parsed = [];
@@ -119,6 +208,7 @@ function readConfig(root, env, fs_) {
119
208
  const hit = parsed.find(({ cfg }) => has(cfg, key));
120
209
  if (hit) out[prop] = { value: hit.cfg[key], file: hit.file };
121
210
  }
211
+ out.contextLayers = parsed.filter(({ cfg }) => has(cfg, 'plan_review.context')).map(({ file, cfg }) => ({ value: cfg['plan_review.context'], file }));
122
212
  return out;
123
213
  }
124
214
 
@@ -207,6 +297,160 @@ function runAsk(parsed, cwd, env, fs_, deps) {
207
297
  return { code: 0, result: { status: 'ask', configured, reviewers, unknown, warnings, ...buildMenus(configured) } };
208
298
  }
209
299
 
300
+ const validWindow = (v) => Number.isInteger(v) && v > 0 && v <= MAX_WINDOW;
301
+
302
+ // Window for one reviewer: first valid entry across the layers, else the default.
303
+ function resolveWindow(layers, reviewer, warnings) {
304
+ for (const { value, file } of layers) {
305
+ if (!isObject(value)) {
306
+ warnings.push(`plan_review.context in ${file} must be an object of CLI name to window tokens; ignored`);
307
+ continue;
308
+ }
309
+ if (!has(value, reviewer)) continue;
310
+ if (validWindow(value[reviewer])) return { window: value[reviewer], windowSource: 'config' };
311
+ warnings.push(`plan_review.context.${reviewer} in ${file} must be a positive integer of at most ${MAX_WINDOW}; ignored`);
312
+ }
313
+ return { window: has(CONTEXT_WINDOWS, reviewer) ? CONTEXT_WINDOWS[reviewer] : FALLBACK_WINDOW, windowSource: 'default' };
314
+ }
315
+
316
+ // Pure decision over the prompt size and the window. First match wins: skip, file, arg.
317
+ function budgetDecision(bytes, window) {
318
+ const tokens = Math.ceil(bytes / BYTES_PER_TOKEN);
319
+ const cap = Math.floor(window * CAP_RATIO);
320
+ const decision = tokens > cap ? 'skip' : bytes > FILE_THRESHOLD_BYTES ? 'file' : 'arg';
321
+ return { decision, tokens, cap };
322
+ }
323
+
324
+ function runBudget(parsed, cwd, env, fs_) {
325
+ const cfg = readConfig(path.resolve(cwd, parsed.repo), env, fs_);
326
+ if (cfg.error) return { code: 1, result: { status: 'error', reason: cfg.error } };
327
+ const file = path.resolve(cwd, parsed.prompt);
328
+ let bytes;
329
+ try {
330
+ bytes = fs_.readFileSync(file).length;
331
+ } catch (err) {
332
+ return { code: 1, result: { status: 'error', reason: `cannot read ${file}: ${err && err.message ? err.message : err}` } };
333
+ }
334
+ const warnings = [];
335
+ const { window, windowSource } = resolveWindow(cfg.contextLayers, parsed.reviewer, warnings);
336
+ const { decision, tokens, cap } = budgetDecision(bytes, window);
337
+ const result = { status: 'ok', reviewer: parsed.reviewer, decision, bytes, tokens, window, cap, windowSource };
338
+ if (decision === 'skip') result.reason = `prompt too large for ${parsed.reviewer}: estimated ${tokens} tokens exceeds the cap of ${cap} (65% of its ${window}-token context window)`;
339
+ if (decision === 'file') result.pointer = `Read the complete review brief in ${file} and answer it exactly as it asks.`;
340
+ result.warnings = warnings;
341
+ return { code: 0, result };
342
+ }
343
+
344
+ // Pure match over captured reviewer output: the first line carrying a known overflow message.
345
+ // Lines of the brief echoed back by the CLI (matched trimmed) and rate-limit lines never count.
346
+ function matchOverflow(text, brief = '') {
347
+ const echoed = new Set(String(brief).split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
348
+ for (const line of String(text).split(/\r?\n/)) {
349
+ const t = line.trim();
350
+ if (echoed.has(t) || RATE_LIMIT.test(t)) continue;
351
+ if (OVERFLOW_PATTERNS.some((re) => re.test(t))) return t.slice(0, REASON_MAX);
352
+ }
353
+ return null;
354
+ }
355
+
356
+ function runOverflow(parsed, cwd, fs_) {
357
+ const file = path.resolve(cwd, parsed.output);
358
+ let text;
359
+ try {
360
+ text = fs_.readFileSync(file, 'utf8');
361
+ } catch (err) {
362
+ return { code: 1, result: { status: 'error', reason: `cannot read ${file}: ${err && err.message ? err.message : err}` } };
363
+ }
364
+ let brief = '';
365
+ if (parsed.prompt) {
366
+ const pf = path.resolve(cwd, parsed.prompt);
367
+ try {
368
+ brief = fs_.readFileSync(pf, 'utf8');
369
+ } catch (err) {
370
+ return { code: 1, result: { status: 'error', reason: `cannot read ${pf}: ${err && err.message ? err.message : err}` } };
371
+ }
372
+ }
373
+ const reason = matchOverflow(text, brief);
374
+ return { code: 0, result: reason === null ? { status: 'ok', overflow: false } : { status: 'ok', overflow: true, reason } };
375
+ }
376
+
377
+ // Runs one reviewer call under a wall-clock deadline. Resolves { code, result }; never rejects.
378
+ function runRun(parsed, cwd, env, deps) {
379
+ const file = path.resolve(cwd, parsed.output);
380
+ const timeoutSeconds = parsed.timeout === undefined ? DEFAULT_TIMEOUT_SECONDS : Number(parsed.timeout);
381
+ const graceMs = deps.graceMs !== undefined ? deps.graceMs : (/^\d+$/.test(env.TRAILHEAD_REVIEW_GRACE_MS || '') ? Number(env.TRAILHEAD_REVIEW_GRACE_MS) : KILL_GRACE_MS);
382
+ const base = { timeoutSeconds, output: file };
383
+ let fd;
384
+ try {
385
+ fd = fs.openSync(file, 'w');
386
+ } catch (e) {
387
+ return Promise.resolve({ code: 1, result: { status: 'error', reason: `cannot write ${file}: ${e && e.message ? e.message : e}` } });
388
+ }
389
+ const started = Date.now();
390
+ const seconds = () => Math.round((Date.now() - started) / 100) / 10;
391
+ const [cmd, ...args] = parsed.cmd;
392
+
393
+ return new Promise((resolve) => {
394
+ let child = null;
395
+ let settled = false;
396
+ let timedOut = false;
397
+ let timers = [];
398
+ const killGroup = (sig) => {
399
+ if (!child || !child.pid) return;
400
+ try { process.kill(-child.pid, sig); } catch (e) { /* group already gone */ }
401
+ };
402
+ const onSignal = (sig) => { killGroup('SIGKILL'); process.exit(sig === 'SIGINT' ? 130 : sig === 'SIGHUP' ? 129 : 143); };
403
+ const finish = (code, result) => {
404
+ if (settled) return;
405
+ settled = true;
406
+ timers.forEach(clearTimeout);
407
+ process.removeListener('SIGTERM', onSignal);
408
+ process.removeListener('SIGINT', onSignal);
409
+ process.removeListener('SIGHUP', onSignal);
410
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
411
+ resolve({ code, result: { ...result, seconds: seconds(), ...base } });
412
+ };
413
+ const fail = (e) => {
414
+ const text = `trailhead run: cannot launch ${cmd}: ${e && e.message ? e.message : e}`;
415
+ try { fs.writeSync(fd, text + '\n'); } catch (w) { /* the reason still goes in the JSON */ }
416
+ finish(0, { status: 'error', exitCode: null, signal: null, reason: text });
417
+ };
418
+
419
+ try {
420
+ child = spawn(cmd, args, { stdio: ['ignore', fd, fd], detached: true, cwd });
421
+ } catch (e) {
422
+ fail(e);
423
+ return;
424
+ }
425
+ process.once('SIGTERM', onSignal);
426
+ process.once('SIGINT', onSignal);
427
+ process.once('SIGHUP', onSignal);
428
+ child.on('error', (e) => { if (!child.pid) fail(e); });
429
+
430
+ child.on('exit', (exitCode, signal) => {
431
+ // Stragglers of the reviewer's group never outlive the call.
432
+ killGroup('SIGKILL');
433
+ if (timedOut) {
434
+ finish(0, { status: 'timeout', exitCode, signal, reason: `no exit within ${timeoutSeconds}s; the process group was terminated` });
435
+ } else if (exitCode === 0) {
436
+ finish(0, { status: 'ok', exitCode, signal });
437
+ } else {
438
+ finish(0, { status: 'failed', exitCode, signal, reason: signal ? `killed by ${signal}` : `exit code ${exitCode}` });
439
+ }
440
+ });
441
+
442
+ timers.push(setTimeout(() => {
443
+ timedOut = true;
444
+ killGroup('SIGTERM');
445
+ timers.push(setTimeout(() => {
446
+ killGroup('SIGKILL');
447
+ // Final fallback: do not wait for an exit event a stuck descendant could delay.
448
+ timers.push(setTimeout(() => finish(0, { status: 'timeout', exitCode: null, signal: 'SIGKILL', reason: `no exit within ${timeoutSeconds}s; the process group was killed` }), EXIT_FALLBACK_MS));
449
+ }, graceMs));
450
+ }, timeoutSeconds * 1000));
451
+ });
452
+ }
453
+
210
454
  const norm = (key) => key.trim().toLowerCase().replace(/\s+/g, ' ');
211
455
  const filled = (v) => typeof v === 'string' && v.trim() !== '';
212
456
 
@@ -256,7 +500,8 @@ function runRound(parsed, cwd, fs_) {
256
500
  return { code: 0, result: decide(history, parsed.choice, Number(parsed.configured)) };
257
501
  }
258
502
 
259
- function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp } = {}) {
503
+ // Returns the exit code, or a Promise of it for the `run` subcommand (the only asynchronous one).
504
+ function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp, graceMs } = {}) {
260
505
  const cwd_ = cwd || process.cwd();
261
506
  const out = stdout || process.stdout;
262
507
  const err = stderr || process.stderr;
@@ -268,9 +513,18 @@ function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp } = {
268
513
  err.write(parsed.error + '\n');
269
514
  return 2;
270
515
  }
516
+ if (parsed.command === 'run') {
517
+ return runRun(parsed, cwd_, env_, { graceMs }).then((r) => {
518
+ out.write(JSON.stringify(r.result) + '\n');
519
+ return r.code;
520
+ });
521
+ }
271
522
  let run;
272
523
  try {
273
- run = parsed.command === 'ask' ? runAsk(parsed, cwd_, env_, fs_, { which, kimiHelp }) : runRound(parsed, cwd_, fs_);
524
+ if (parsed.command === 'ask') run = runAsk(parsed, cwd_, env_, fs_, { which, kimiHelp });
525
+ else if (parsed.command === 'budget') run = runBudget(parsed, cwd_, env_, fs_);
526
+ else if (parsed.command === 'overflow') run = runOverflow(parsed, cwd_, fs_);
527
+ else run = runRound(parsed, cwd_, fs_);
274
528
  } catch (e) {
275
529
  if (e instanceof Usage) {
276
530
  err.write(e.message + '\n');
@@ -282,8 +536,8 @@ function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp } = {
282
536
  return run.code;
283
537
  }
284
538
 
285
- module.exports = { parseArgs, readConfig, decide, main };
539
+ module.exports = { globalConfigPath, parseArgs, readConfig, decide, budgetDecision, matchOverflow, main };
286
540
 
287
541
  if (require.main === module) {
288
- process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, env: process.env }));
542
+ Promise.resolve(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, env: process.env })).then((code) => process.exit(code));
289
543
  }
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead repo target (self-contained, ships in templates/ so it reaches every
4
+ // install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm and
5
+ // Codex). Verifies which repository `gh` will write issues to, once per session,
6
+ // before the first tracker write. #351.
7
+ //
8
+ // Why: in a fork clone `gh` ranks a parent remote (`upstream`) above `origin`, or
9
+ // honours `remote.<parent>.gh-resolved=base` (set by `gh repo fork` / `gh repo
10
+ // set-default`), so a bare `gh issue ...` or `gh api repos/{owner}/{repo}/...` can
11
+ // land on the parent. Seen from the resolved target the parent reports
12
+ // isFork:false, so the fork relation is read from the `origin` side: origin is a
13
+ // fork whose parent equals the resolved target. Limits: only the remote named
14
+ // `origin` is read as the fork side, and only github.com URLs parse (GHES, or a fork
15
+ // remote under another name, is not detected). When origin exists but its fork
16
+ // relation cannot be verified (lookup failed, or not a github.com URL) the check
17
+ // reports `skipped` rather than `ok`.
18
+ //
19
+ // CLI:
20
+ // node trailhead-repo-target.js check [--repo <dir>]
21
+ // node trailhead-repo-target.js record [--repo <dir>] --target <owner/name>
22
+ // node trailhead-repo-target.js clear [--repo <dir>]
23
+ // --repo defaults to cwd; canonicalised to the project root (main worktree via the
24
+ // git common dir) like trailhead-isolation-answer.js, so a linked worktree shares
25
+ // the record.
26
+ // File shape (<project root>/.trailhead/repo-target.json): { target, recordedAt, usedAt }
27
+ // A session is an 8-hour sliding idle window (each reuse refreshes usedAt); a
28
+ // malformed or future timestamp is invalid and the target is checked again.
29
+ // check prints one JSON line:
30
+ // {"status":"ok","target"} plain repo, or a verified writable own fork: proceed silently
31
+ // {"status":"reuse","target","recordedAt","path"} a recorded answer for this very target (usedAt refreshed)
32
+ // {"status":"ask","reason":"fork-parent|no-write","target","candidates":[...],"path"}
33
+ // {"status":"skipped","reason"} gh/git unavailable, origin unverifiable or any error: proceed as before
34
+ // record validates the slug, runs `gh repo set-default <target>` when it differs
35
+ // from the current resolution (so every later bare gh call lands there), then writes
36
+ // the file (and adds `.trailhead/repo-target.json` to <project root>/.gitignore if
37
+ // absent, only when the root is a git checkout): {"status":"written","target","setDefault":true|false,"path"}; if
38
+ // set-default fails: {"status":"error","reason"} and nothing is written.
39
+ // clear prints {"status":"cleared|absent","path"}.
40
+ // Exit 0 on every normal outcome and on any unexpected error; exit 2 only on a
41
+ // usage error (nothing is written).
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+ const crypto = require('crypto');
46
+ const { execFileSync, spawnSync } = require('child_process');
47
+ const { gitEnv } = require('./trailhead-git-env.js');
48
+
49
+ const STATE_DIR = '.trailhead';
50
+ const STATE_FILE = 'repo-target.json';
51
+ const IDLE_WINDOW_MS = 8 * 60 * 60 * 1000;
52
+ const WRITE_PERMISSIONS = ['ADMIN', 'MAINTAIN', 'WRITE'];
53
+ const IGNORE_LINE = `${STATE_DIR}/${STATE_FILE}`;
54
+ // GitHub owners start with an alphanumeric (never `-`, so a slug is never read as a flag).
55
+ const OWNER = '[A-Za-z0-9][A-Za-z0-9-]*';
56
+ const SLUG_RE = new RegExp(`^${OWNER}\\/[A-Za-z0-9_.-]+$`);
57
+
58
+ const sameSlug = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
59
+
60
+ // "owner/name" of a GitHub remote URL (https, scp-like ssh, ssh://), else null.
61
+ function parseGithubSlug(url) {
62
+ if (typeof url !== 'string') return null;
63
+ const m = url.trim().match(/^(?:https?:\/\/(?:[^/@]+@)?github\.com\/|ssh:\/\/(?:[^/@]+@)?github\.com(?::\d+)?\/|[^/@\s]+@github\.com:)([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
64
+ return m ? `${m[1]}/${m[2]}` : null;
65
+ }
66
+
67
+ // Strict ISO-8601 timestamp (as written by toISOString) -> epoch ms, or null.
68
+ function parseTime(text) {
69
+ if (typeof text !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(text)) return null;
70
+ const ms = Date.parse(text);
71
+ return Number.isNaN(ms) ? null : ms;
72
+ }
73
+
74
+ // Valid record or null (not an object, bad slug, malformed or future timestamp).
75
+ function validateRecord(raw, nowMs) {
76
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
77
+ if (typeof raw.target !== 'string' || !SLUG_RE.test(raw.target)) return null;
78
+ const recordedAt = parseTime(raw.recordedAt);
79
+ const usedAt = parseTime(raw.usedAt);
80
+ if (recordedAt === null || usedAt === null || recordedAt > nowMs || usedAt > nowMs) return null;
81
+ return { target: raw.target, recordedAt: raw.recordedAt, usedAt };
82
+ }
83
+
84
+ // Slug of the parent in a `gh repo view --json parent` value, or null.
85
+ function parentSlug(parent) {
86
+ if (!parent || typeof parent !== 'object') return null;
87
+ if (typeof parent.nameWithOwner === 'string') return parent.nameWithOwner;
88
+ const owner = parent.owner && typeof parent.owner === 'object' ? parent.owner.login : parent.owner;
89
+ return typeof owner === 'string' && typeof parent.name === 'string' ? `${owner}/${parent.name}` : null;
90
+ }
91
+
92
+ // Pure decision. facts: { target, permission, originSlug, originInfo, remotes }
93
+ // originInfo: parsed `gh repo view <origin>` ({isFork, parent}) or null; remotes: GitHub slugs, origin first;
94
+ // originExists: an `origin` remote is configured (even if not a github.com URL).
95
+ // record: parsed file content (null/undefined = absent).
96
+ function decide(facts, record, now) {
97
+ const { target, permission, originSlug, originInfo, remotes, originExists } = facts;
98
+ const valid = record === null || record === undefined ? null : validateRecord(record, now.getTime());
99
+ if (valid && now.getTime() - valid.usedAt <= IDLE_WINDOW_MS && sameSlug(valid.target, target)) {
100
+ return { status: 'reuse', target, recordedAt: valid.recordedAt };
101
+ }
102
+ const parent = originInfo && originInfo.isFork === true ? parentSlug(originInfo.parent) : null;
103
+ if (originSlug && parent && sameSlug(parent, target) && !sameSlug(originSlug, target)) {
104
+ return { status: 'ask', reason: 'fork-parent', target, candidates: [originSlug, target] };
105
+ }
106
+ if (typeof permission === 'string' && !WRITE_PERMISSIONS.includes(permission)) {
107
+ const others = (remotes || []).filter((s) => !sameSlug(s, target));
108
+ const candidates = [...new Set([...others, target])];
109
+ return { status: 'ask', reason: 'no-write', target, candidates };
110
+ }
111
+ // origin is there but its relation to the target was not verified: do not claim ok.
112
+ if (originExists && (!originSlug || (!sameSlug(originSlug, target) && !originInfo))) {
113
+ return { status: 'skipped', reason: 'origin fork relation could not be verified' };
114
+ }
115
+ return { status: 'ok', target };
116
+ }
117
+
118
+ function parseArgs(argv) {
119
+ const command = argv[0];
120
+ if (command !== 'check' && command !== 'record' && command !== 'clear') {
121
+ return { error: `usage: expected "check", "record" or "clear", got: ${command === undefined ? '(nothing)' : command}` };
122
+ }
123
+ const allowed = command === 'record' ? ['--repo', '--target'] : ['--repo'];
124
+ let repo = null;
125
+ let target;
126
+ for (let i = 1; i < argv.length; i++) {
127
+ const flag = argv[i];
128
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
129
+ i++;
130
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
131
+ if (flag === '--repo') repo = argv[i];
132
+ else if (!SLUG_RE.test(argv[i])) return { error: `usage: --target must be owner/name, got: ${argv[i]}` };
133
+ else target = argv[i];
134
+ }
135
+ if (command === 'record' && target === undefined) return { error: 'usage: record needs --target' };
136
+ return { command, repo, target };
137
+ }
138
+
139
+ // Project root: the main worktree when dir is inside a linked worktree, else dir.
140
+ function resolveProjectRoot(dir) {
141
+ try {
142
+ const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
143
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv() }).trim();
144
+ if (common && path.basename(common) === '.git') return path.dirname(common);
145
+ return dir;
146
+ } catch {
147
+ return dir;
148
+ }
149
+ }
150
+
151
+ // Default runner: run(cmd, args, { cwd }) -> { status, stdout, stderr } (status null when the binary is missing).
152
+ function defaultRun(cmd, args, { cwd } = {}) {
153
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
154
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
155
+ }
156
+
157
+ function ghJson(run, args, cwd) {
158
+ const r = run('gh', args, { cwd });
159
+ if (!r || r.status !== 0) return null;
160
+ try {
161
+ const v = JSON.parse(r.stdout);
162
+ return v && typeof v === 'object' ? v : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // GitHub slugs of the clone's remotes, origin first, deduplicated.
169
+ function remoteSlugs(run, cwd) {
170
+ const r = run('git', ['config', '--get-regexp', '^remote\\..*\\.url$'], { cwd });
171
+ if (!r || r.status !== 0) return { origin: null, originExists: false, all: [] };
172
+ let origin = null;
173
+ let originExists = false;
174
+ const all = [];
175
+ for (const line of String(r.stdout).split('\n')) {
176
+ const m = line.match(/^remote\.(.+)\.url\s+(.+)$/);
177
+ if (m && m[1] === 'origin') originExists = true;
178
+ const slug = m ? parseGithubSlug(m[2]) : null;
179
+ if (!slug) continue;
180
+ if (m[1] === 'origin') origin = slug;
181
+ all.push({ name: m[1], slug });
182
+ }
183
+ all.sort((a, b) => (a.name === 'origin' ? -1 : 0) - (b.name === 'origin' ? -1 : 0));
184
+ return { origin, originExists, all: [...new Set(all.map((x) => x.slug))] };
185
+ }
186
+
187
+ function readRecord(file, fsMod) {
188
+ let text;
189
+ try {
190
+ text = fsMod.readFileSync(file, 'utf8');
191
+ } catch (err) {
192
+ if (err && err.code === 'ENOENT') return null;
193
+ throw err;
194
+ }
195
+ try {
196
+ return JSON.parse(text);
197
+ } catch {
198
+ return {};
199
+ }
200
+ }
201
+
202
+ // Atomic write: a unique temp in the same dir, then rename over the file.
203
+ function writeAtomic(dir, file, text, fsMod) {
204
+ fsMod.mkdirSync(dir, { recursive: true });
205
+ const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
206
+ try {
207
+ fsMod.writeFileSync(tmp, text);
208
+ fsMod.renameSync(tmp, file);
209
+ } catch (err) {
210
+ try { fsMod.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
211
+ throw err;
212
+ }
213
+ }
214
+
215
+ const writeResult = (stdout, result) => stdout.write(JSON.stringify(result) + '\n');
216
+
217
+ function doCheck({ repoDir, file, dir, run, fsMod, now, out }) {
218
+ const view = ghJson(run, ['repo', 'view', '--json', 'nameWithOwner,viewerPermission'], repoDir);
219
+ if (!view || typeof view.nameWithOwner !== 'string') {
220
+ writeResult(out, { status: 'skipped', reason: 'gh could not resolve a target repository' });
221
+ return;
222
+ }
223
+ const target = view.nameWithOwner;
224
+ const { origin, originExists, all } = remoteSlugs(run, repoDir);
225
+ const originInfo = origin && !sameSlug(origin, target)
226
+ ? ghJson(run, ['repo', 'view', '--json', 'nameWithOwner,isFork,parent', '--', origin], repoDir) : null;
227
+ const raw = readRecord(file, fsMod);
228
+ const decision = decide({ target, permission: view.viewerPermission, originSlug: origin, originInfo, remotes: all, originExists }, raw, now);
229
+ if (decision.status === 'reuse') {
230
+ // Slide the idle window first: reuse is only reported once it is persisted.
231
+ writeAtomic(dir, file, JSON.stringify({ ...raw, usedAt: now.toISOString() }, null, 2) + '\n', fsMod);
232
+ writeResult(out, { ...decision, path: file });
233
+ } else {
234
+ writeResult(out, decision.status === 'ask' ? { ...decision, path: file } : decision);
235
+ }
236
+ }
237
+
238
+ // Adds the marker to <root>/.gitignore if absent; only in a git checkout. Best-effort.
239
+ function ensureIgnored(root, fsMod) {
240
+ try {
241
+ if (!fsMod.existsSync(path.join(root, '.git'))) return;
242
+ const file = path.join(root, '.gitignore');
243
+ const text = fsMod.existsSync(file) ? fsMod.readFileSync(file, 'utf8') : '';
244
+ if (text.split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE || l.trim() === '/' + IGNORE_LINE)) return;
245
+ fsMod.writeFileSync(file, text + (text === '' || text.endsWith('\n') ? '' : '\n') + IGNORE_LINE + '\n');
246
+ } catch { /* the record itself is already written */ }
247
+ }
248
+
249
+ function doRecord({ repoDir, file, dir, run, fsMod, now, out, target }) {
250
+ const view = ghJson(run, ['repo', 'view', '--json', 'nameWithOwner'], repoDir);
251
+ if (!view || typeof view.nameWithOwner !== 'string') {
252
+ writeResult(out, { status: 'error', reason: 'gh could not resolve the current target repository' });
253
+ return;
254
+ }
255
+ const needsDefault = !sameSlug(view.nameWithOwner, target);
256
+ if (needsDefault) {
257
+ const r = run('gh', ['repo', 'set-default', '--', target], { cwd: repoDir });
258
+ if (!r || r.status !== 0) {
259
+ const detail = r && r.stderr ? String(r.stderr).trim().split('\n')[0] : 'exit ' + (r ? r.status : 'unknown');
260
+ writeResult(out, { status: 'error', reason: `gh repo set-default ${target} failed: ${detail}` });
261
+ return;
262
+ }
263
+ }
264
+ const stamp = now.toISOString();
265
+ writeAtomic(dir, file, JSON.stringify({ target, recordedAt: stamp, usedAt: stamp }, null, 2) + '\n', fsMod);
266
+ ensureIgnored(path.dirname(dir), fsMod);
267
+ writeResult(out, { status: 'written', target, setDefault: needsDefault, path: file });
268
+ }
269
+
270
+ function main(argv, { cwd, stdout, fs: fsMod, now, run } = {}) {
271
+ const out = stdout || process.stdout;
272
+ const parsed = parseArgs(argv || []);
273
+ if (parsed.error) {
274
+ process.stderr.write(parsed.error + '\n');
275
+ return 2;
276
+ }
277
+ const repoDir = parsed.repo ? path.resolve(cwd || process.cwd(), parsed.repo) : (cwd || process.cwd());
278
+ const root = resolveProjectRoot(repoDir);
279
+ const dir = path.join(root, STATE_DIR);
280
+ const file = path.join(dir, STATE_FILE);
281
+ const ctx = { repoDir, file, dir, run: run || defaultRun, fsMod: fsMod || fs, now: now || new Date(), out, target: parsed.target };
282
+ try {
283
+ if (parsed.command === 'clear') {
284
+ const present = ctx.fsMod.existsSync(file);
285
+ if (present) ctx.fsMod.unlinkSync(file);
286
+ writeResult(out, { status: present ? 'cleared' : 'absent', path: file });
287
+ } else if (parsed.command === 'record') {
288
+ doRecord(ctx);
289
+ } else {
290
+ doCheck(ctx);
291
+ }
292
+ } catch (err) {
293
+ writeResult(out, { status: 'skipped', reason: 'error: ' + (err && err.message ? err.message : String(err)) });
294
+ }
295
+ return 0;
296
+ }
297
+
298
+ module.exports = { IDLE_WINDOW_MS, parseGithubSlug, parentSlug, decide, parseArgs, resolveProjectRoot, main, SLUG_RE };
299
+
300
+ if (require.main === module) {
301
+ process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout }));
302
+ }