@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,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead auto run record (self-contained, ships in templates/ so it reaches
|
|
4
|
+
// every install channel: bin/trailhead.js copies templates/ wholesale for
|
|
5
|
+
// Claude/npm and Codex). Persists the start-up choices and the working lists of
|
|
6
|
+
// an autonomous run in <repo>/.trailhead/auto-run.json, so a run survives a
|
|
7
|
+
// context compaction (and a crash) without asking again. #295.
|
|
8
|
+
// Written at the main checkout only, never in a slot workspace.
|
|
9
|
+
//
|
|
10
|
+
// CLI:
|
|
11
|
+
// node trailhead-auto-run.js start --repo <dir> --scope <s> --slots <n> --review <r> [--replace]
|
|
12
|
+
// node trailhead-auto-run.js read --repo <dir>
|
|
13
|
+
// node trailhead-auto-run.js skip --repo <dir> --ticket <n>
|
|
14
|
+
// node trailhead-auto-run.js unmerged --repo <dir> --ticket <n> --branch <b>
|
|
15
|
+
// [--file <path> ...] [--merge-ticket <n>]
|
|
16
|
+
// node trailhead-auto-run.js clear --repo <dir>
|
|
17
|
+
// --repo defaults to cwd. File shape:
|
|
18
|
+
// { version: 1, scope, slots, review, session, skip: [<n>...],
|
|
19
|
+
// unmerged: [{ ticket, branch, files: [...], mergeTicket: <n>|null }],
|
|
20
|
+
// startedAt, updatedAt }
|
|
21
|
+
// scope: map:<n> | whiteboard | everything (trailhead-auto-queue.js tokens).
|
|
22
|
+
// slots: 1 to 4. review: config | 1 | 2 | 3 | until (plan-review-rounds
|
|
23
|
+
// tokens) or skip (the ask was skipped). session: CLAUDE_CODE_SESSION_ID at
|
|
24
|
+
// start (null when unset, as on Codex).
|
|
25
|
+
// Prints one JSON line {"status","path","record"?,"sameSession"?,"message"?}.
|
|
26
|
+
// start: written | exists (record on file, kept unless --replace) | skipped
|
|
27
|
+
// read: found (+ sameSession) | absent | corrupt | skipped
|
|
28
|
+
// skip, unmerged: written | absent | corrupt | skipped
|
|
29
|
+
// clear: cleared | absent | skipped
|
|
30
|
+
// A corrupt file (unparseable, or an invalid version/scope/slots/review) is
|
|
31
|
+
// reported and never rewritten except by `start --replace`; invalid list
|
|
32
|
+
// entries (skip, unmerged) are dropped from an otherwise found record.
|
|
33
|
+
// Exit 0 on every normal outcome and on any unexpected I/O error (it must never
|
|
34
|
+
// block the run: status skipped with the message); exit 2 only on a usage or
|
|
35
|
+
// validation error (nothing is written).
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
|
|
41
|
+
const STATE_DIR = '.trailhead';
|
|
42
|
+
const STATE_FILE = 'auto-run.json';
|
|
43
|
+
const VERSION = 1;
|
|
44
|
+
const REVIEWS = ['config', '1', '2', '3', 'until', 'skip'];
|
|
45
|
+
const SCOPE_RE = /^(map:[1-9]\d*|whiteboard|everything)$/;
|
|
46
|
+
const COMMANDS = ['start', 'read', 'skip', 'unmerged', 'clear'];
|
|
47
|
+
const FLAGS = {
|
|
48
|
+
start: ['--repo', '--scope', '--slots', '--review', '--replace'],
|
|
49
|
+
read: ['--repo'],
|
|
50
|
+
skip: ['--repo', '--ticket'],
|
|
51
|
+
unmerged: ['--repo', '--ticket', '--branch', '--file', '--merge-ticket'],
|
|
52
|
+
clear: ['--repo'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const isPosInt = (v) => Number.isInteger(v) && v > 0;
|
|
56
|
+
const isLine = (v) => typeof v === 'string' && v.trim() !== '' && !/[\u0000-\u001f\u007f]/.test(v);
|
|
57
|
+
|
|
58
|
+
// Strict parse of a positive integer from a CLI string; null if invalid.
|
|
59
|
+
function parsePosInt(text) {
|
|
60
|
+
return /^[1-9]\d*$/.test(text) ? Number(text) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Lista di ticket: solo interi positivi, senza doppioni, in ordine crescente.
|
|
64
|
+
function cleanTickets(list) {
|
|
65
|
+
if (!Array.isArray(list)) return [];
|
|
66
|
+
return [...new Set(list.filter(isPosInt))].sort((a, b) => a - b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Una voce unmerged valida o null (la voce invalida viene scartata).
|
|
70
|
+
function cleanUnmerged(raw) {
|
|
71
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
72
|
+
if (!isPosInt(raw.ticket) || !isLine(raw.branch)) return null;
|
|
73
|
+
return {
|
|
74
|
+
ticket: raw.ticket,
|
|
75
|
+
branch: raw.branch.trim(),
|
|
76
|
+
files: Array.isArray(raw.files) ? raw.files.filter(isLine) : [],
|
|
77
|
+
mergeTicket: isPosInt(raw.mergeTicket) ? raw.mergeTicket : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Una voce per ticket (l'ultima vince), in ordine di ticket.
|
|
82
|
+
function cleanUnmergedList(list) {
|
|
83
|
+
if (!Array.isArray(list)) return [];
|
|
84
|
+
const byTicket = new Map();
|
|
85
|
+
for (const item of list) {
|
|
86
|
+
const entry = cleanUnmerged(item);
|
|
87
|
+
if (entry) byTicket.set(entry.ticket, entry);
|
|
88
|
+
}
|
|
89
|
+
return [...byTicket.values()].sort((a, b) => a.ticket - b.ticket);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Normalizza un oggetto qualunque in un record. Un campo obbligatorio invalido
|
|
93
|
+
// (version, scope, slots, review) rende il record corrotto: { error }.
|
|
94
|
+
function sanitizeRecord(raw) {
|
|
95
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'not an object' };
|
|
96
|
+
if (raw.version !== VERSION) return { error: 'invalid version' };
|
|
97
|
+
if (typeof raw.scope !== 'string' || !SCOPE_RE.test(raw.scope)) return { error: 'invalid scope' };
|
|
98
|
+
if (!Number.isInteger(raw.slots) || raw.slots < 1 || raw.slots > 4) return { error: 'invalid slots' };
|
|
99
|
+
if (!REVIEWS.includes(raw.review)) return { error: 'invalid review' };
|
|
100
|
+
return {
|
|
101
|
+
record: {
|
|
102
|
+
version: VERSION,
|
|
103
|
+
scope: raw.scope,
|
|
104
|
+
slots: raw.slots,
|
|
105
|
+
review: raw.review,
|
|
106
|
+
session: typeof raw.session === 'string' && raw.session !== '' ? raw.session : null,
|
|
107
|
+
skip: cleanTickets(raw.skip),
|
|
108
|
+
unmerged: cleanUnmergedList(raw.unmerged),
|
|
109
|
+
startedAt: typeof raw.startedAt === 'string' ? raw.startedAt : null,
|
|
110
|
+
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseArgs(argv) {
|
|
116
|
+
const command = argv[0];
|
|
117
|
+
if (!COMMANDS.includes(command)) {
|
|
118
|
+
return { error: `usage: expected one of ${COMMANDS.join(', ')}, got: ${command === undefined ? '(nothing)' : command}` };
|
|
119
|
+
}
|
|
120
|
+
const allowed = FLAGS[command];
|
|
121
|
+
const out = { command, repo: null, replace: false, files: [] };
|
|
122
|
+
for (let i = 1; i < argv.length; i++) {
|
|
123
|
+
const flag = argv[i];
|
|
124
|
+
if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
125
|
+
if (flag === '--replace') { out.replace = true; continue; }
|
|
126
|
+
i++;
|
|
127
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
128
|
+
const value = argv[i];
|
|
129
|
+
if (flag === '--repo') out.repo = value;
|
|
130
|
+
else if (flag === '--scope') {
|
|
131
|
+
if (!SCOPE_RE.test(value)) return { error: `usage: --scope must be map:<n>, whiteboard or everything, got: ${value}` };
|
|
132
|
+
out.scope = value;
|
|
133
|
+
} else if (flag === '--slots') {
|
|
134
|
+
const n = parsePosInt(value);
|
|
135
|
+
if (n === null || n > 4) return { error: `usage: --slots must be 1 to 4, got: ${value}` };
|
|
136
|
+
out.slots = n;
|
|
137
|
+
} else if (flag === '--review') {
|
|
138
|
+
if (!REVIEWS.includes(value)) return { error: `usage: --review must be one of ${REVIEWS.join(', ')}, got: ${value}` };
|
|
139
|
+
out.review = value;
|
|
140
|
+
} else if (flag === '--ticket') {
|
|
141
|
+
if (parsePosInt(value) === null) return { error: `usage: --ticket must be a positive integer, got: ${value}` };
|
|
142
|
+
out.ticket = parsePosInt(value);
|
|
143
|
+
} else if (flag === '--merge-ticket') {
|
|
144
|
+
if (parsePosInt(value) === null) return { error: `usage: --merge-ticket must be a positive integer, got: ${value}` };
|
|
145
|
+
out.mergeTicket = parsePosInt(value);
|
|
146
|
+
} else if (flag === '--branch') {
|
|
147
|
+
if (!isLine(value)) return { error: 'usage: --branch must be a single non-empty line' };
|
|
148
|
+
out.branch = value.trim();
|
|
149
|
+
} else if (flag === '--file') {
|
|
150
|
+
if (!isLine(value)) return { error: 'usage: --file must be a single non-empty line' };
|
|
151
|
+
out.files.push(value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const missing = { start: ['scope', 'slots', 'review'], skip: ['ticket'], unmerged: ['ticket', 'branch'] }[command] || [];
|
|
155
|
+
for (const key of missing) {
|
|
156
|
+
if (out[key] === undefined) return { error: `usage: ${command} needs --${key}` };
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Legge il file: { status: 'absent' } | { status: 'corrupt', message } | { status: 'found', record }.
|
|
162
|
+
// Un errore di I/O (non di parsing) risale al chiamante, che risponde skipped.
|
|
163
|
+
function readFile(file, fsMod) {
|
|
164
|
+
if (!fsMod.existsSync(file)) return { status: 'absent' };
|
|
165
|
+
const text = fsMod.readFileSync(file, 'utf8');
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = JSON.parse(text);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { status: 'corrupt', message: 'unparseable: ' + err.message };
|
|
171
|
+
}
|
|
172
|
+
const s = sanitizeRecord(raw);
|
|
173
|
+
return s.error ? { status: 'corrupt', message: s.error } : { status: 'found', record: s.record };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Scrittura atomica: temp univoco nella stessa cartella, poi rename sul file;
|
|
177
|
+
// il temp viene rimosso se qualcosa fallisce.
|
|
178
|
+
function writeAtomic(dir, file, text, fsMod) {
|
|
179
|
+
fsMod.mkdirSync(dir, { recursive: true });
|
|
180
|
+
const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
181
|
+
try {
|
|
182
|
+
fsMod.writeFileSync(tmp, text);
|
|
183
|
+
fsMod.renameSync(tmp, file);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
try { fsMod.unlinkSync(tmp); } catch { /* pulizia best effort */ }
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function writeResult(stdout, result) {
|
|
191
|
+
stdout.write(JSON.stringify(result) + '\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function main(argv, { cwd, stdout, stderr, env, fs: fsMod, now } = {}) {
|
|
195
|
+
const cwd_ = cwd || process.cwd();
|
|
196
|
+
const out = stdout || process.stdout;
|
|
197
|
+
const err_ = stderr || process.stderr;
|
|
198
|
+
const env_ = env || process.env;
|
|
199
|
+
const fs_ = fsMod || fs;
|
|
200
|
+
|
|
201
|
+
const parsed = parseArgs(argv || []);
|
|
202
|
+
if (parsed.error) {
|
|
203
|
+
err_.write(parsed.error + '\n');
|
|
204
|
+
return 2;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
|
|
208
|
+
const dir = path.join(repoDir, STATE_DIR);
|
|
209
|
+
const file = path.join(dir, STATE_FILE);
|
|
210
|
+
const stamp = (now || new Date()).toISOString();
|
|
211
|
+
const save = (record) => writeAtomic(dir, file, JSON.stringify(record, null, 2) + '\n', fs_);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (parsed.command === 'clear') {
|
|
215
|
+
if (!fs_.existsSync(file)) {
|
|
216
|
+
writeResult(out, { status: 'absent', path: file });
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
fs_.unlinkSync(file);
|
|
220
|
+
writeResult(out, { status: 'cleared', path: file });
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const sessionEnv = env_.CLAUDE_CODE_SESSION_ID;
|
|
225
|
+
const session = typeof sessionEnv === 'string' && sessionEnv !== '' ? sessionEnv : null;
|
|
226
|
+
const existing = readFile(file, fs_);
|
|
227
|
+
|
|
228
|
+
if (parsed.command === 'read') {
|
|
229
|
+
if (existing.status !== 'found') {
|
|
230
|
+
const result = { status: existing.status, path: file };
|
|
231
|
+
if (existing.message) result.message = existing.message;
|
|
232
|
+
writeResult(out, result);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
const sameSession = session !== null && existing.record.session !== null && existing.record.session === session;
|
|
236
|
+
writeResult(out, { status: 'found', path: file, record: existing.record, sameSession });
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsed.command === 'start') {
|
|
241
|
+
if (existing.status !== 'absent' && !parsed.replace) {
|
|
242
|
+
// Un record corrotto non si sovrascrive in silenzio: serve --replace.
|
|
243
|
+
writeResult(out, { status: 'exists', path: file, record: existing.record || null, ...(existing.message ? { message: existing.message } : {}) });
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
const record = {
|
|
247
|
+
version: VERSION,
|
|
248
|
+
scope: parsed.scope,
|
|
249
|
+
slots: parsed.slots,
|
|
250
|
+
review: parsed.review,
|
|
251
|
+
session,
|
|
252
|
+
skip: [],
|
|
253
|
+
unmerged: [],
|
|
254
|
+
startedAt: stamp,
|
|
255
|
+
updatedAt: stamp,
|
|
256
|
+
};
|
|
257
|
+
save(record);
|
|
258
|
+
writeResult(out, { status: 'written', path: file, record });
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// skip / unmerged: servono un record leggibile.
|
|
263
|
+
if (existing.status !== 'found') {
|
|
264
|
+
const result = { status: existing.status, path: file };
|
|
265
|
+
if (existing.message) result.message = existing.message;
|
|
266
|
+
writeResult(out, result);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
const base = existing.record;
|
|
270
|
+
const next = parsed.command === 'skip'
|
|
271
|
+
? { ...base, skip: cleanTickets([...base.skip, parsed.ticket]) }
|
|
272
|
+
: {
|
|
273
|
+
...base,
|
|
274
|
+
unmerged: cleanUnmergedList([
|
|
275
|
+
...base.unmerged,
|
|
276
|
+
{ ticket: parsed.ticket, branch: parsed.branch, files: parsed.files, mergeTicket: parsed.mergeTicket === undefined ? null : parsed.mergeTicket },
|
|
277
|
+
]),
|
|
278
|
+
};
|
|
279
|
+
const record = { ...next, updatedAt: stamp };
|
|
280
|
+
save(record);
|
|
281
|
+
writeResult(out, { status: 'written', path: file, record });
|
|
282
|
+
return 0;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
writeResult(out, { status: 'skipped', path: file, message: 'error: ' + (err && err.message ? err.message : String(err)) });
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = { REVIEWS, sanitizeRecord, parseArgs, main };
|
|
290
|
+
|
|
291
|
+
if (require.main === module) {
|
|
292
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, env: process.env });
|
|
293
|
+
process.exit(code);
|
|
294
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead auto-slot helper (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). Opens and lists the slot workspaces of an autonomous run with
|
|
5
|
+
// parallel slots: one workspace per ticket in flight, a worktree (or, on a superrepo,
|
|
6
|
+
// an independent clone) at a sibling path on its own branch. #283.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-auto-slot.js open --repo <main-checkout> --ticket <n> --base <sha>
|
|
10
|
+
// node trailhead-auto-slot.js list --repo <main-checkout>
|
|
11
|
+
//
|
|
12
|
+
// Layout: the workspace of ticket <n> is <parent of the main checkout>/<basename>-t<n>
|
|
13
|
+
// on the branch trailhead/t<n>.
|
|
14
|
+
//
|
|
15
|
+
// open (never overwrites anything, never removes anything):
|
|
16
|
+
// - the branch is already checked out in a worktree (at the slot path or any other
|
|
17
|
+
// path, e.g. one made by hand earlier): status "reentered" with that path;
|
|
18
|
+
// - the branch exists but has no worktree: "reattached" (git worktree add <path> <branch>);
|
|
19
|
+
// - neither: "created" (git worktree add -b <branch> <path> <base>), the branch pointing
|
|
20
|
+
// exactly at --base (the trunk SHA captured at claim, so a moved main checkout never
|
|
21
|
+
// changes it);
|
|
22
|
+
// - the branch's worktree is registered in git but its directory is gone (deleted by
|
|
23
|
+
// hand): "error" naming it and telling to run `git worktree prune` (never pruned here);
|
|
24
|
+
// - the slot path already exists: an independent git checkout on the branch (a clone
|
|
25
|
+
// slot) is "reentered" with kind "clone"; anything else is "error", never overwritten.
|
|
26
|
+
// This check comes before the submodule rule, so an existing path is never cloned over;
|
|
27
|
+
// - only a missing path, with the main checkout declaring a submodule (.gitmodules):
|
|
28
|
+
// "clone-required", nothing created. The caller sets the clone up at the same path, then checks out the branch
|
|
29
|
+
// at the same base, so a clone slot carries the same path, branch and base.
|
|
30
|
+
// list: every slot workspace, sorted by ticket: each worktree on a trailhead/t<n> branch,
|
|
31
|
+
// plus each sibling <basename>-t<n> that is an independent git checkout on the branch
|
|
32
|
+
// trailhead/t<n> and not already a worktree. Each entry:
|
|
33
|
+
// {ticket, kind: "worktree"|"clone", branch, path, dirty, workState}
|
|
34
|
+
// A worktree git lists but whose directory is gone (stale) is kept out of slots and
|
|
35
|
+
// reported in warnings, with a `git worktree prune` suggestion.
|
|
36
|
+
// dirty is true when `git status --porcelain` is non-empty; workState is the parsed
|
|
37
|
+
// <path>/.trailhead/work-state.json, or null (missing, or unreadable plus a warning).
|
|
38
|
+
//
|
|
39
|
+
// Prints exactly one JSON line on stdout:
|
|
40
|
+
// open: {"status":"created|reentered|reattached|clone-required","ticket":n,"branch":"...","path":"...","kind":"worktree|clone"}
|
|
41
|
+
// list: {"status":"ok","slots":[...],"warnings":[...]}
|
|
42
|
+
// error: {"status":"error","reason":"..."}
|
|
43
|
+
// Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
|
|
44
|
+
|
|
45
|
+
const { spawnSync } = require('child_process');
|
|
46
|
+
const nodeFs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
function defaultGit(args) {
|
|
50
|
+
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
51
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
55
|
+
const branchOf = (n) => `trailhead/t${n}`;
|
|
56
|
+
const BRANCH_RE = /^refs\/heads\/trailhead\/t(\d+)$/;
|
|
57
|
+
|
|
58
|
+
// --- args ---------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const cmd = argv[0];
|
|
62
|
+
if (cmd !== 'open' && cmd !== 'list') return { error: `usage: expected open or list, got: ${cmd === undefined ? '(nothing)' : cmd}` };
|
|
63
|
+
const allowed = cmd === 'open' ? ['--repo', '--ticket', '--base'] : ['--repo'];
|
|
64
|
+
const o = { cmd, repo: null, ticket: null, base: null };
|
|
65
|
+
for (let i = 1; i < argv.length; i++) {
|
|
66
|
+
const flag = argv[i];
|
|
67
|
+
if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
68
|
+
i++;
|
|
69
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
70
|
+
const v = argv[i];
|
|
71
|
+
if (flag === '--ticket') {
|
|
72
|
+
if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --ticket must be a positive integer, got: ${v}` };
|
|
73
|
+
o.ticket = Number(v);
|
|
74
|
+
} else if (!v.trim()) {
|
|
75
|
+
return { error: `usage: ${flag} requires a non-empty value` };
|
|
76
|
+
} else if (flag === '--repo') {
|
|
77
|
+
o.repo = v;
|
|
78
|
+
} else {
|
|
79
|
+
o.base = v;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
|
|
83
|
+
if (cmd === 'open' && !o.ticket) return { error: 'usage: open requires --ticket' };
|
|
84
|
+
if (cmd === 'open' && !o.base) return { error: 'usage: open requires --base' };
|
|
85
|
+
return o;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- git helpers --------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
// [{ path, branch|null }] from `git worktree list --porcelain`, main checkout first.
|
|
91
|
+
function parseWorktrees(text) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const block of String(text).split(/\n\s*\n/)) {
|
|
94
|
+
let wt = null;
|
|
95
|
+
let branch = null;
|
|
96
|
+
for (const line of block.split('\n')) {
|
|
97
|
+
if (line.startsWith('worktree ')) wt = line.slice('worktree '.length);
|
|
98
|
+
else if (line.startsWith('branch ')) branch = line.slice('branch '.length);
|
|
99
|
+
}
|
|
100
|
+
if (wt) out.push({ path: wt, branch });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const realOr = (fs, p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
106
|
+
|
|
107
|
+
function hasSubmodule(fs, main) {
|
|
108
|
+
try {
|
|
109
|
+
return /^\s*\[submodule\s/m.test(fs.readFileSync(path.join(main, '.gitmodules'), 'utf8'));
|
|
110
|
+
} catch { return false; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- open ---------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function openSlot(ctx, o) {
|
|
116
|
+
const { git, fs, main, fail, emit } = ctx;
|
|
117
|
+
const branch = branchOf(o.ticket);
|
|
118
|
+
const target = path.join(path.dirname(main), `${path.basename(main)}-t${o.ticket}`);
|
|
119
|
+
const result = (status, p, kind) => { emit({ status, ticket: o.ticket, branch, path: p, kind }); return 0; };
|
|
120
|
+
|
|
121
|
+
const wl = git(['-C', o.repo, 'worktree', 'list', '--porcelain']);
|
|
122
|
+
if (wl.status !== 0) return fail(`git worktree list failed: ${firstLine(wl.stderr) || 'exit ' + wl.status}`);
|
|
123
|
+
const held = parseWorktrees(wl.stdout).find((w) => w.branch === `refs/heads/${branch}`);
|
|
124
|
+
if (held) {
|
|
125
|
+
if (!fs.existsSync(held.path)) return fail(`stale worktree of ${branch}: ${held.path} is gone from disk but git still lists it; run git worktree prune and retry`);
|
|
126
|
+
return result('reentered', held.path, 'worktree');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The slot path is checked before the submodule rule, so an existing path is never cloned over.
|
|
130
|
+
if (fs.existsSync(target)) {
|
|
131
|
+
if (cloneOnBranch(git, fs, target, branch)) return result('reentered', target, 'clone');
|
|
132
|
+
return fail(`slot path already exists and is not a checkout of ${branch}: ${target}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (hasSubmodule(fs, main)) return result('clone-required', target, 'clone');
|
|
136
|
+
|
|
137
|
+
const exists = git(['-C', o.repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
138
|
+
if (exists.status === 0) {
|
|
139
|
+
const add = git(['-C', o.repo, 'worktree', 'add', target, branch]);
|
|
140
|
+
if (add.status !== 0) return fail(`git worktree add failed: ${firstLine(add.stderr) || 'exit ' + add.status}`);
|
|
141
|
+
return result('reattached', target, 'worktree');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const base = git(['-C', o.repo, 'rev-parse', '--verify', '--quiet', `${o.base}^{commit}`]);
|
|
145
|
+
if (base.status !== 0) return fail(`base does not resolve to a commit: ${o.base}`);
|
|
146
|
+
const add = git(['-C', o.repo, 'worktree', 'add', '-b', branch, target, base.stdout.trim()]);
|
|
147
|
+
if (add.status !== 0) return fail(`git worktree add failed: ${firstLine(add.stderr) || 'exit ' + add.status}`);
|
|
148
|
+
return result('created', target, 'worktree');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- list ---------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
// { workState, warning } for the checkout at dir.
|
|
154
|
+
function readWorkState(fs, dir) {
|
|
155
|
+
const file = path.join(dir, '.trailhead', 'work-state.json');
|
|
156
|
+
let raw;
|
|
157
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) {
|
|
158
|
+
if (e && e.code === 'ENOENT') return { workState: null, warning: null };
|
|
159
|
+
return { workState: null, warning: `unreadable work state at ${file}` };
|
|
160
|
+
}
|
|
161
|
+
try { return { workState: JSON.parse(raw), warning: null }; } catch {
|
|
162
|
+
return { workState: null, warning: `unparseable work state at ${file}` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isDirty(git, dir) {
|
|
167
|
+
const r = git(['-C', dir, 'status', '--porcelain']);
|
|
168
|
+
return r.status === 0 ? { dirty: r.stdout.trim() !== '', warning: null } : { dirty: null, warning: `git status failed at ${dir}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// True when dir is an independent checkout (its git dir lives inside it) on `branch`.
|
|
172
|
+
function cloneOnBranch(git, fs, dir, branch) {
|
|
173
|
+
const g = git(['-C', dir, 'rev-parse', '--absolute-git-dir']);
|
|
174
|
+
if (g.status !== 0) return false;
|
|
175
|
+
const gitDir = realOr(fs, g.stdout.trim());
|
|
176
|
+
const root = realOr(fs, dir);
|
|
177
|
+
if (gitDir !== root && !gitDir.startsWith(root + path.sep)) return false;
|
|
178
|
+
const b = git(['-C', dir, 'symbolic-ref', '--quiet', 'HEAD']);
|
|
179
|
+
return b.status === 0 && b.stdout.trim() === `refs/heads/${branch}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listSlots(ctx, o) {
|
|
183
|
+
const { git, fs, main, fail, emit } = ctx;
|
|
184
|
+
const wl = git(['-C', o.repo, 'worktree', 'list', '--porcelain']);
|
|
185
|
+
if (wl.status !== 0) return fail(`git worktree list failed: ${firstLine(wl.stderr) || 'exit ' + wl.status}`);
|
|
186
|
+
const entries = [];
|
|
187
|
+
const warnings = [];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const w of parseWorktrees(wl.stdout)) {
|
|
190
|
+
const m = w.branch && BRANCH_RE.exec(w.branch);
|
|
191
|
+
if (!m || realOr(fs, w.path) === main) continue;
|
|
192
|
+
seen.add(realOr(fs, w.path));
|
|
193
|
+
if (!fs.existsSync(w.path)) {
|
|
194
|
+
warnings.push(`stale worktree of ${branchOf(m[1])}: ${w.path} is gone from disk but git still lists it; run git worktree prune`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
entries.push({ ticket: Number(m[1]), kind: 'worktree', branch: branchOf(m[1]), path: w.path });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parent = path.dirname(main);
|
|
201
|
+
const re = new RegExp(`^${path.basename(main).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-t(\\d+)$`);
|
|
202
|
+
let names = [];
|
|
203
|
+
try { names = fs.readdirSync(parent); } catch { /* unreadable parent: no clones */ }
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
const m = re.exec(name);
|
|
206
|
+
if (!m) continue;
|
|
207
|
+
const dir = path.join(parent, name);
|
|
208
|
+
if (seen.has(realOr(fs, dir))) continue;
|
|
209
|
+
if (!cloneOnBranch(git, fs, dir, branchOf(m[1]))) continue;
|
|
210
|
+
entries.push({ ticket: Number(m[1]), kind: 'clone', branch: branchOf(m[1]), path: dir });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const slots = entries.sort((a, b) => a.ticket - b.ticket).map((e) => {
|
|
214
|
+
const d = isDirty(git, e.path);
|
|
215
|
+
const s = readWorkState(fs, e.path);
|
|
216
|
+
for (const w of [d.warning, s.warning]) if (w) warnings.push(w);
|
|
217
|
+
return { ...e, dirty: d.dirty, workState: s.workState };
|
|
218
|
+
});
|
|
219
|
+
emit({ status: 'ok', slots, warnings });
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function main(argv, { git, stdout, stderr, fs } = {}) {
|
|
224
|
+
const out = stdout || process.stdout;
|
|
225
|
+
const err = stderr || process.stderr;
|
|
226
|
+
const o = parseArgs(argv || []);
|
|
227
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
228
|
+
const fsx = fs || nodeFs;
|
|
229
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
230
|
+
const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
|
|
231
|
+
const ctx = { git: git || defaultGit, fs: fsx, main: realOr(fsx, o.repo), fail, emit };
|
|
232
|
+
return o.cmd === 'open' ? openSlot(ctx, o) : listSlots(ctx, o);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { main, parseArgs, parseWorktrees, hasSubmodule };
|
|
236
|
+
|
|
237
|
+
if (require.main === module) {
|
|
238
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
239
|
+
}
|