@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.
- package/README.it.md +28 -18
- package/README.md +28 -18
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/engine-route-docs.test.js +7 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +8 -8
- package/bin/lib/codex-projection.test.js +19 -3
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +9 -4
- package/bin/trailhead.test.js +18 -6
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-grill.md +2 -2
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +30 -14
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
- package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
- package/plugins/trailhead/skills/_shared/substrate.md +3 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +2 -1
- package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
- package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|