@marcomigozzi/trailhead 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead isolation answer (self-contained, ships in templates/ so it reaches
|
|
4
|
+
// every install channel: bin/trailhead.js copies templates/ wholesale for
|
|
5
|
+
// Claude/npm and Codex). Remembers the answer to the work-start concurrency ask
|
|
6
|
+
// under `isolation: auto` in <project root>/.trailhead/isolation-answer.json, so
|
|
7
|
+
// later work-starts of the same working stretch reuse it. #245.
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// node trailhead-isolation-answer.js check [--repo <dir>] --others <csv|none|unavailable>
|
|
11
|
+
// node trailhead-isolation-answer.js record [--repo <dir>] --answer yes|no --others <csv|none>
|
|
12
|
+
// node trailhead-isolation-answer.js clear [--repo <dir>]
|
|
13
|
+
// --repo defaults to cwd. It is canonicalised to the project root: inside a
|
|
14
|
+
// linked worktree it resolves to the main worktree (git common dir), so a call
|
|
15
|
+
// from a trailhead/t<n> worktree reads the same record; on a git failure (or not
|
|
16
|
+
// a git repo) the given dir is used as is.
|
|
17
|
+
// File shape: { answer: "yes"|"no", others: [ticket numbers], recordedAt, usedAt }
|
|
18
|
+
// `others` is the live-claims fingerprint (open issues assigned to @me minus the
|
|
19
|
+
// current ticket), sorted and deduplicated; `none` is the empty set.
|
|
20
|
+
// A session is a working stretch: it ends after 8 hours without a work-start
|
|
21
|
+
// (sliding idle window; each reuse refreshes usedAt).
|
|
22
|
+
// check prints:
|
|
23
|
+
// {"status":"reuse","answer","recordedAt","path"} (only after usedAt was refreshed)
|
|
24
|
+
// {"status":"ask","reason":"absent|expired|claims-changed|invalid|unavailable","path"}
|
|
25
|
+
// record prints {"status":"written","path","record"}; clear prints
|
|
26
|
+
// {"status":"cleared|absent","path"}.
|
|
27
|
+
// Exit 0 on every normal outcome and on any unexpected I/O error (status
|
|
28
|
+
// skipped with a message, which the caller treats as ask); exit 2 only on a
|
|
29
|
+
// usage error (nothing is written).
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
35
|
+
|
|
36
|
+
const STATE_DIR = '.trailhead';
|
|
37
|
+
const STATE_FILE = 'isolation-answer.json';
|
|
38
|
+
const IDLE_WINDOW_MS = 8 * 60 * 60 * 1000;
|
|
39
|
+
const ANSWERS = ['yes', 'no'];
|
|
40
|
+
|
|
41
|
+
const isPosInt = (v) => Number.isInteger(v) && v > 0;
|
|
42
|
+
|
|
43
|
+
// Sorted, deduplicated copy of a list of ticket numbers.
|
|
44
|
+
const normalise = (list) => [...new Set(list)].sort((a, b) => a - b);
|
|
45
|
+
|
|
46
|
+
// Strict parse of the --others value. Returns a sorted deduplicated array,
|
|
47
|
+
// the string "unavailable" (when allowed), or null if invalid.
|
|
48
|
+
function parseOthers(text, { allowUnavailable }) {
|
|
49
|
+
if (text === 'none') return [];
|
|
50
|
+
if (text === 'unavailable') return allowUnavailable ? 'unavailable' : null;
|
|
51
|
+
if (typeof text !== 'string' || !/^[1-9]\d*(,[1-9]\d*)*$/.test(text)) return null;
|
|
52
|
+
return normalise(text.split(',').map(Number));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strict ISO-8601 timestamp (as written by toISOString) -> epoch ms, or null.
|
|
56
|
+
function parseTime(text) {
|
|
57
|
+
if (typeof text !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(text)) return null;
|
|
58
|
+
const ms = Date.parse(text);
|
|
59
|
+
return Number.isNaN(ms) ? null : ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Valid record or null (malformed or future timestamp, answer not yes/no,
|
|
63
|
+
// others not an array of positive integers).
|
|
64
|
+
function validateRecord(raw, nowMs) {
|
|
65
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
66
|
+
if (!ANSWERS.includes(raw.answer)) return null;
|
|
67
|
+
if (!Array.isArray(raw.others) || !raw.others.every(isPosInt)) return null;
|
|
68
|
+
const recordedAt = parseTime(raw.recordedAt);
|
|
69
|
+
const usedAt = parseTime(raw.usedAt);
|
|
70
|
+
if (recordedAt === null || usedAt === null) return null;
|
|
71
|
+
if (recordedAt > nowMs || usedAt > nowMs) return null;
|
|
72
|
+
return { answer: raw.answer, others: normalise(raw.others), recordedAt: raw.recordedAt, usedAt, usedAtText: raw.usedAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pure decision: reuse the record or ask again, and why.
|
|
76
|
+
// record: parsed file content (null/undefined = absent); others: array or "unavailable".
|
|
77
|
+
function decide(record, others, now) {
|
|
78
|
+
if (others === 'unavailable') return { status: 'ask', reason: 'unavailable' };
|
|
79
|
+
if (record === null || record === undefined) return { status: 'ask', reason: 'absent' };
|
|
80
|
+
const valid = validateRecord(record, now.getTime());
|
|
81
|
+
if (valid === null) return { status: 'ask', reason: 'invalid' };
|
|
82
|
+
if (now.getTime() - valid.usedAt > IDLE_WINDOW_MS) return { status: 'ask', reason: 'expired' };
|
|
83
|
+
const want = normalise(others);
|
|
84
|
+
const same = want.length === valid.others.length && want.every((n, i) => n === valid.others[i]);
|
|
85
|
+
if (!same) return { status: 'ask', reason: 'claims-changed' };
|
|
86
|
+
return { status: 'reuse', answer: valid.answer, recordedAt: valid.recordedAt };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseArgs(argv) {
|
|
90
|
+
const command = argv[0];
|
|
91
|
+
if (command !== 'check' && command !== 'record' && command !== 'clear') {
|
|
92
|
+
return { error: `usage: expected "check", "record" or "clear", got: ${command === undefined ? '(nothing)' : command}` };
|
|
93
|
+
}
|
|
94
|
+
const allowed = command === 'check' ? ['--repo', '--others']
|
|
95
|
+
: command === 'record' ? ['--repo', '--answer', '--others'] : ['--repo'];
|
|
96
|
+
let repo = null;
|
|
97
|
+
let answer;
|
|
98
|
+
let others;
|
|
99
|
+
for (let i = 1; i < argv.length; i++) {
|
|
100
|
+
const flag = argv[i];
|
|
101
|
+
if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
102
|
+
i++;
|
|
103
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
104
|
+
const value = argv[i];
|
|
105
|
+
if (flag === '--repo') repo = value;
|
|
106
|
+
else if (flag === '--answer') {
|
|
107
|
+
if (!ANSWERS.includes(value)) return { error: `usage: --answer must be yes or no, got: ${value}` };
|
|
108
|
+
answer = value;
|
|
109
|
+
} else if (flag === '--others') {
|
|
110
|
+
others = parseOthers(value, { allowUnavailable: command === 'check' });
|
|
111
|
+
if (others === null) {
|
|
112
|
+
return { error: `usage: --others must be comma-separated positive integers, "none"${command === 'check' ? ' or "unavailable"' : ''}, got: ${value}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (command === 'check' && others === undefined) return { error: 'usage: check needs --others' };
|
|
117
|
+
if (command === 'record' && answer === undefined) return { error: 'usage: record needs --answer' };
|
|
118
|
+
if (command === 'record' && others === undefined) return { error: 'usage: record needs --others' };
|
|
119
|
+
return { command, repo, answer, others };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Project root: the main worktree when dir is inside a linked worktree, else
|
|
123
|
+
// dir as given (also on any git failure or when not a git repo).
|
|
124
|
+
function resolveProjectRoot(dir) {
|
|
125
|
+
try {
|
|
126
|
+
const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
127
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
if (common && path.basename(common) === '.git') return path.dirname(common);
|
|
129
|
+
return dir;
|
|
130
|
+
} catch {
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Reads and parses the file; missing = null, corrupt = {} (invalid, never a crash).
|
|
136
|
+
function readRecord(file, fsMod) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = fsMod.readFileSync(file, 'utf8');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err && err.code === 'ENOENT') return null;
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(text);
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Atomic write: a unique temp in the same dir, then rename over the file
|
|
152
|
+
// (never an in-place truncate); the temp is removed if anything fails.
|
|
153
|
+
function writeAtomic(dir, file, text, fsMod) {
|
|
154
|
+
fsMod.mkdirSync(dir, { recursive: true });
|
|
155
|
+
const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
156
|
+
try {
|
|
157
|
+
fsMod.writeFileSync(tmp, text);
|
|
158
|
+
fsMod.renameSync(tmp, file);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
try { fsMod.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeResult(stdout, result) {
|
|
166
|
+
stdout.write(JSON.stringify(result) + '\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function main(argv, { cwd, stdout, fs: fsMod, now } = {}) {
|
|
170
|
+
const cwd_ = cwd || process.cwd();
|
|
171
|
+
const out = stdout || process.stdout;
|
|
172
|
+
const fs_ = fsMod || fs;
|
|
173
|
+
const now_ = now || new Date();
|
|
174
|
+
|
|
175
|
+
const parsed = parseArgs(argv || []);
|
|
176
|
+
if (parsed.error) {
|
|
177
|
+
process.stderr.write(parsed.error + '\n');
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
|
|
182
|
+
const root = resolveProjectRoot(repoDir);
|
|
183
|
+
const dir = path.join(root, STATE_DIR);
|
|
184
|
+
const file = path.join(dir, STATE_FILE);
|
|
185
|
+
try {
|
|
186
|
+
if (parsed.command === 'clear') {
|
|
187
|
+
if (!fs_.existsSync(file)) {
|
|
188
|
+
writeResult(out, { status: 'absent', path: file });
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
fs_.unlinkSync(file);
|
|
192
|
+
writeResult(out, { status: 'cleared', path: file });
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (parsed.command === 'record') {
|
|
197
|
+
const record = { answer: parsed.answer, others: parsed.others, recordedAt: now_.toISOString(), usedAt: now_.toISOString() };
|
|
198
|
+
writeAtomic(dir, file, JSON.stringify(record, null, 2) + '\n', fs_);
|
|
199
|
+
writeResult(out, { status: 'written', path: file, record });
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const raw = readRecord(file, fs_);
|
|
204
|
+
const decision = decide(raw, parsed.others, now_);
|
|
205
|
+
if (decision.status !== 'reuse') {
|
|
206
|
+
writeResult(out, { ...decision, path: file });
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
// Slide the idle window first: reuse is only reported once it is persisted.
|
|
210
|
+
// No lock: a concurrent record landing between read and write is overwritten,
|
|
211
|
+
// which at worst leaves a stale fingerprint that re-asks, never a blind reuse.
|
|
212
|
+
const refreshed = { ...raw, usedAt: now_.toISOString() };
|
|
213
|
+
writeAtomic(dir, file, JSON.stringify(refreshed, null, 2) + '\n', fs_);
|
|
214
|
+
writeResult(out, { ...decision, path: file });
|
|
215
|
+
return 0;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
writeResult(out, { status: 'skipped', path: file, message: 'error: ' + (err && err.message ? err.message : String(err)) });
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { IDLE_WINDOW_MS, decide, parseOthers, parseArgs, resolveProjectRoot, main };
|
|
223
|
+
|
|
224
|
+
if (require.main === module) {
|
|
225
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
|
|
226
|
+
process.exit(code);
|
|
227
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead isolation copy (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
|
|
5
|
+
// and Codex). Copies the gitignored files an app needs to run (env files, local
|
|
6
|
+
// config) from the main checkout into a fresh worktree or clone. #242.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-isolation-copy.js --from <main-checkout> --to <workspace>
|
|
10
|
+
// The list lives in `isolation.copy` of .trailhead/config.json: an array of
|
|
11
|
+
// repo-relative paths (file or directory, no globs), nested ({"isolation":
|
|
12
|
+
// {"copy":[...]}}) or flat ({"isolation.copy":[...]}); nested wins when a file
|
|
13
|
+
// carries both (a warning says so). The project file wins over the global one
|
|
14
|
+
// (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) as a whole value: the
|
|
15
|
+
// global list applies only when the project file has no isolation.copy at all.
|
|
16
|
+
// Rules: a destination that already exists is never overwritten (reported
|
|
17
|
+
// `present`); absolute paths, `..`, any `.git` segment, and paths whose parent
|
|
18
|
+
// directory resolves outside its root (checked on both sides) are `rejected`;
|
|
19
|
+
// a final symlink is copied as a symlink, never followed; nested `.git` entries
|
|
20
|
+
// are skipped inside copied directories. An invalid config (bad JSON, non-array,
|
|
21
|
+
// non-string or empty entry) copies nothing and offers no hint.
|
|
22
|
+
// When the key is unset, tracked `*.example` / `*.sample` files whose stripped
|
|
23
|
+
// sibling exists in the main checkout and is gitignored are listed in
|
|
24
|
+
// `suggestions` (never copied).
|
|
25
|
+
// Prints one JSON line:
|
|
26
|
+
// {"status","copied":[],"present":[],"missing":[],"rejected":[],"suggestions":[],"warnings":[],"message"?}
|
|
27
|
+
// status: copied | nothing | unset | invalid | skipped
|
|
28
|
+
// Exit 0 on every normal outcome and on any unexpected I/O error (it must never
|
|
29
|
+
// block the caller: status skipped with the message); exit 2 only on a usage
|
|
30
|
+
// error (missing --from/--to, unknown flag).
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const { execFileSync } = require('child_process');
|
|
36
|
+
|
|
37
|
+
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
38
|
+
const SUFFIXES = ['.example', '.sample'];
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === '--from' || a === '--to') {
|
|
45
|
+
const v = argv[++i];
|
|
46
|
+
if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
|
|
47
|
+
out[a.slice(2)] = v;
|
|
48
|
+
} else {
|
|
49
|
+
return { error: `usage: unknown argument ${a}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!out.from) return { error: 'usage: missing --from <main-checkout>' };
|
|
53
|
+
if (!out.to) return { error: 'usage: missing --to <workspace>' };
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Returns null when the repo-relative entry is acceptable, else the reason.
|
|
58
|
+
function checkRelPath(rel) {
|
|
59
|
+
if (typeof rel !== 'string' || rel === '') return 'empty path';
|
|
60
|
+
if (path.isAbsolute(rel) || path.win32.isAbsolute(rel)) return 'absolute path';
|
|
61
|
+
const segments = rel.split(/[\\/]+/).filter(Boolean);
|
|
62
|
+
if (segments.length === 0 || segments.every((s) => s === '.')) return 'empty path';
|
|
63
|
+
if (segments.includes('..')) return 'parent segment';
|
|
64
|
+
if (segments.includes('.git')) return '.git segment';
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Reads the copy list out of one config file.
|
|
69
|
+
// { state: 'unset' } | { state: 'set', list, warnings } | { state: 'invalid', message }
|
|
70
|
+
function readCopyList(file, fs_) {
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err && err.code === 'ENOENT') return { state: 'unset' };
|
|
76
|
+
return { state: 'invalid', message: `cannot read ${file}: ${err && err.message ? err.message : err}` };
|
|
77
|
+
}
|
|
78
|
+
let cfg;
|
|
79
|
+
try {
|
|
80
|
+
cfg = JSON.parse(text);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { state: 'invalid', message: `${file} is not valid JSON` };
|
|
83
|
+
}
|
|
84
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
85
|
+
return { state: 'invalid', message: `${file} is not a JSON object` };
|
|
86
|
+
}
|
|
87
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
88
|
+
const nested = cfg.isolation !== null && typeof cfg.isolation === 'object' && !Array.isArray(cfg.isolation) && has(cfg.isolation, 'copy');
|
|
89
|
+
const flat = has(cfg, 'isolation.copy');
|
|
90
|
+
if (!nested && !flat) return { state: 'unset' };
|
|
91
|
+
const warnings = [];
|
|
92
|
+
if (nested && flat) warnings.push(`${file} sets isolation.copy in both nested and flat form; the nested form wins`);
|
|
93
|
+
const value = nested ? cfg.isolation.copy : cfg['isolation.copy'];
|
|
94
|
+
if (!Array.isArray(value)) return { state: 'invalid', message: `isolation.copy in ${file} must be an array of paths` };
|
|
95
|
+
if (value.some((e) => typeof e !== 'string' || e.trim() === '')) {
|
|
96
|
+
return { state: 'invalid', message: `isolation.copy in ${file} must hold only non-empty strings` };
|
|
97
|
+
}
|
|
98
|
+
return { state: 'set', list: value, warnings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function globalConfigPath(env) {
|
|
102
|
+
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
103
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// realpath of the deepest existing ancestor of p (p itself included).
|
|
107
|
+
function deepestReal(p, fs_) {
|
|
108
|
+
let cur = p;
|
|
109
|
+
for (;;) {
|
|
110
|
+
try {
|
|
111
|
+
return fs_.realpathSync(cur);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const up = path.dirname(cur);
|
|
114
|
+
if (up === cur) return cur;
|
|
115
|
+
cur = up;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// True when the parent directory of root/rel resolves inside realRoot.
|
|
121
|
+
function parentInside(root, realRoot, rel, fs_) {
|
|
122
|
+
const real = deepestReal(path.dirname(path.join(root, rel)), fs_);
|
|
123
|
+
return real === realRoot || real.startsWith(realRoot + path.sep);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const exists = (p, fs_) => {
|
|
127
|
+
try { fs_.lstatSync(p); return true; } catch (err) { return false; }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Tracked *.example / *.sample files whose stripped sibling is present and ignored.
|
|
131
|
+
function suggest(from, fs_) {
|
|
132
|
+
let tracked;
|
|
133
|
+
try {
|
|
134
|
+
tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
|
|
135
|
+
.toString().split('\0').filter(Boolean);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const file of tracked) {
|
|
141
|
+
const suffix = SUFFIXES.find((s) => file.endsWith(s) && file.length > s.length);
|
|
142
|
+
if (!suffix) continue;
|
|
143
|
+
const sibling = file.slice(0, -suffix.length);
|
|
144
|
+
if (out.includes(sibling) || checkRelPath(sibling) !== null || !exists(path.join(from, sibling), fs_)) continue;
|
|
145
|
+
try {
|
|
146
|
+
execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore' });
|
|
147
|
+
out.push(sibling);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// not ignored (exit 1) or git error: not a suggestion
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function copyEntries(list, from, to, fs_) {
|
|
156
|
+
const result = { copied: [], present: [], missing: [], rejected: [], warnings: [] };
|
|
157
|
+
const realFrom = fs_.realpathSync(from);
|
|
158
|
+
const realTo = fs_.realpathSync(to);
|
|
159
|
+
for (const rel of list) {
|
|
160
|
+
if (checkRelPath(rel) !== null || !parentInside(from, realFrom, rel, fs_)) {
|
|
161
|
+
result.rejected.push(rel);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const src = path.join(from, rel);
|
|
165
|
+
const dst = path.join(to, rel);
|
|
166
|
+
if (!exists(src, fs_)) { result.missing.push(rel); continue; }
|
|
167
|
+
if (!parentInside(to, realTo, rel, fs_)) { result.rejected.push(rel); continue; }
|
|
168
|
+
if (exists(dst, fs_)) { result.present.push(rel); continue; }
|
|
169
|
+
try {
|
|
170
|
+
fs_.mkdirSync(path.dirname(dst), { recursive: true });
|
|
171
|
+
// Re-check after creating parents: nothing may have been redirected meanwhile.
|
|
172
|
+
if (!parentInside(to, realTo, rel, fs_)) { result.rejected.push(rel); continue; }
|
|
173
|
+
fs_.cpSync(src, dst, {
|
|
174
|
+
recursive: true,
|
|
175
|
+
force: false,
|
|
176
|
+
errorOnExist: true,
|
|
177
|
+
verbatimSymlinks: true,
|
|
178
|
+
filter: (s) => path.basename(s) !== '.git',
|
|
179
|
+
});
|
|
180
|
+
result.copied.push(rel);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
result.warnings.push(`could not copy ${rel}: ${err && err.message ? err.message : err}`);
|
|
183
|
+
// This run created the destination (it did not exist before): drop any
|
|
184
|
+
// partial copy so the next run retries instead of reporting it present.
|
|
185
|
+
try {
|
|
186
|
+
fs_.rmSync(dst, { recursive: true, force: true });
|
|
187
|
+
} catch (cleanupErr) {
|
|
188
|
+
result.warnings.push(`could not clean up partial ${rel}: ${cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function writeResult(out, fields) {
|
|
196
|
+
const result = { status: 'skipped', copied: [], present: [], missing: [], rejected: [], suggestions: [], warnings: [], ...fields };
|
|
197
|
+
out.write(JSON.stringify(result) + '\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
201
|
+
const cwd_ = cwd || process.cwd();
|
|
202
|
+
const out = stdout || process.stdout;
|
|
203
|
+
const env_ = env || process.env;
|
|
204
|
+
const fs_ = fsMod || fs;
|
|
205
|
+
|
|
206
|
+
const parsed = parseArgs(argv || []);
|
|
207
|
+
if (parsed.error) {
|
|
208
|
+
process.stderr.write(parsed.error + '\n');
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
const from = path.resolve(cwd_, parsed.from);
|
|
212
|
+
const to = path.resolve(cwd_, parsed.to);
|
|
213
|
+
try {
|
|
214
|
+
let cfg = readCopyList(path.join(from, CONFIG_REL), fs_);
|
|
215
|
+
if (cfg.state === 'unset') cfg = readCopyList(globalConfigPath(env_), fs_);
|
|
216
|
+
if (cfg.state === 'invalid') {
|
|
217
|
+
writeResult(out, { status: 'invalid', message: cfg.message });
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
if (cfg.state === 'unset') {
|
|
221
|
+
writeResult(out, { status: 'unset', suggestions: suggest(from, fs_) });
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
const res = copyEntries(cfg.list, from, to, fs_);
|
|
225
|
+
res.warnings = [...cfg.warnings, ...res.warnings];
|
|
226
|
+
writeResult(out, { ...res, status: res.copied.length > 0 ? 'copied' : 'nothing' });
|
|
227
|
+
return 0;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
writeResult(out, { status: 'skipped', message: 'error: ' + (err && err.message ? err.message : String(err)) });
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { checkRelPath, readCopyList, parseArgs, main };
|
|
235
|
+
|
|
236
|
+
if (require.main === module) {
|
|
237
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|
|
238
|
+
process.exit(code);
|
|
239
|
+
}
|