@marcomigozzi/trailhead 0.15.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.
Files changed (96) hide show
  1. package/README.it.md +45 -9
  2. package/README.md +46 -10
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/grilling-holistic-review-docs.test.js +90 -0
  12. package/bin/lib/codex-projection.js +5 -5
  13. package/bin/lib/codex-projection.test.js +2 -2
  14. package/bin/needs-you-docs.test.js +41 -0
  15. package/bin/pick-stress-test-docs.test.js +105 -0
  16. package/bin/trailhead.js +3 -3
  17. package/bin/trailhead.test.js +17 -18
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/commands/auto.md +2 -2
  21. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  22. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  23. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  24. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  25. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  26. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  27. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  28. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  29. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  30. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  31. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  32. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  33. package/plugins/trailhead/hooks/mod/README.md +180 -39
  34. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  35. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  36. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  37. package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
  38. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  39. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  40. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  41. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  42. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  43. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  44. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  46. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  47. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  48. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  49. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  50. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  52. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  56. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  57. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  59. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  60. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  61. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  62. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  63. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  64. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  65. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  66. package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
  67. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  68. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  69. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  70. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  71. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  72. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  73. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  74. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  75. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  76. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  77. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  78. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  79. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  80. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  81. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  82. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  83. package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
  84. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  85. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  86. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  88. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  89. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  90. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  91. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  92. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  93. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  94. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  95. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  96. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead checkout-in (self-contained, ships in templates/ so it reaches every
4
+ // install channel). The panel cannot move the session's directory, so it hands the
5
+ // pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
6
+ // verb moves there first. This helper decodes the token, validates it and says
7
+ // whether the session is already there. It fails closed: it never searches for
8
+ // another checkout. #312.
9
+ //
10
+ // The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
11
+ // with spaces, quotes or a leading `-` travels as one shell-safe word.
12
+ //
13
+ // CLI:
14
+ // node trailhead-checkout-in.js --in <b64url> --cwd <dir>
15
+ //
16
+ // Prints exactly one JSON line on stdout:
17
+ // {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
18
+ // `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
19
+ // Exit 0 for the three statuses, 2 on a usage error (stderr).
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const PREFIX = '--in=';
26
+
27
+ function encodeIn(p) {
28
+ return PREFIX + Buffer.from(String(p), 'utf8').toString('base64url');
29
+ }
30
+
31
+ function decodeIn(token) {
32
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token)) return null;
33
+ const p = Buffer.from(token, 'base64url').toString('utf8');
34
+ // Round-trip guard: a token that does not re-encode to itself is not ours.
35
+ return p !== '' && Buffer.from(p, 'utf8').toString('base64url') === token ? p : null;
36
+ }
37
+
38
+ function real(p) {
39
+ try {
40
+ return fs.realpathSync(p);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function toplevelOf(dir) {
47
+ const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
48
+ if (r.status !== 0) return null;
49
+ const top = r.stdout.replace(/\n$/, '');
50
+ return top === '' ? null : real(top);
51
+ }
52
+
53
+ // POSIX single-quote escaping: ' becomes '\''.
54
+ function cdCommand(p) {
55
+ return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
56
+ }
57
+
58
+ function check(token, cwd) {
59
+ const p = decodeIn(token);
60
+ if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
61
+ if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
62
+ const canon = real(p);
63
+ if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
64
+ // The path must be a checkout root itself, never a folder inside one.
65
+ if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
66
+ const here = real(cwd) !== null ? toplevelOf(cwd) : null;
67
+ if (here === canon) return { status: 'here', path: p };
68
+ return { status: 'move', path: p, cd: cdCommand(p) };
69
+ }
70
+
71
+ function main(argv) {
72
+ const get = (flag) => {
73
+ const i = argv.indexOf(flag);
74
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
75
+ };
76
+ const token = get('--in');
77
+ const cwd = get('--cwd');
78
+ if (token === null || cwd === null) {
79
+ process.stderr.write('usage: trailhead-checkout-in.js --in <b64url> --cwd <dir>\n');
80
+ return 2;
81
+ }
82
+ process.stdout.write(JSON.stringify(check(token, cwd)) + '\n');
83
+ return 0;
84
+ }
85
+
86
+ module.exports = { encodeIn, decodeIn, check, cdCommand };
87
+
88
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
@@ -13,6 +13,9 @@
13
13
  // existing body. --dry-run gathers and reads through gh, computes and scans, but
14
14
  // writes, creates, pins and labels nothing; it prints the old and new byte sizes.
15
15
  //
16
+ // Singleton labels (dashboard, codebase, conventions): more than one open issue
17
+ // aborts before any write, naming every duplicate by title and number.
18
+ //
16
19
  // Self-heal: ensures the `trailhead:dashboard` label; creates the issue directly
17
20
  // with the final scanned body when it is missing (never an empty or placeholder
18
21
  // body) and pins it; re-pins it when unpinned. Never closes or prunes a map: the
@@ -165,12 +168,33 @@ function gatherMap(gh, repo, m, truncated) {
165
168
  return out;
166
169
  }
167
170
 
168
- function firstNumber(gh, repo, label) {
169
- const list = issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number', '--limit', '1']);
170
- return list.length ? list[0].number : null;
171
+ // Singleton labels: exactly one open issue each. More than one is ambiguous and
172
+ // never resolved by picking one (#274).
173
+ const SINGLETON_LIMIT = 10;
174
+ const SINGLETON_LABELS = [LABEL, 'trailhead:codebase', 'trailhead:conventions'];
175
+
176
+ // Looks up every singleton label first and aborts (GatherError) naming all the
177
+ // duplicated ones at once, before anything is written. Returns the single
178
+ // number per label, or null when there is none.
179
+ function lookupSingletons(gh, repo) {
180
+ const found = SINGLETON_LABELS.map((label) => ({
181
+ label,
182
+ list: issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number,title', '--limit', String(SINGLETON_LIMIT)]),
183
+ }));
184
+ const dupes = found.filter((f) => f.list.length > 1);
185
+ if (dupes.length) {
186
+ // Control characters are stripped so the one-line stdout stays safe; a list
187
+ // that filled the fetch limit may be cut, so say more may exist.
188
+ const safe = (title) => linkText(title).replace(/[\x00-\x1f\x7f]/g, '');
189
+ const named = dupes.map((f) => `${f.label}: ${f.list.map((i) => `${safe(i.title)} (#${i.number})`).join(', ')}${f.list.length >= SINGLETON_LIMIT ? ', and possibly more' : ''}`).join('; ');
190
+ throw new GatherError(`ambiguous singleton issues, close the duplicates and keep one: ${named}`);
191
+ }
192
+ const num = (label) => { const f = found.find((x) => x.label === label); return f.list.length ? f.list[0].number : null; };
193
+ return { dashboard: num(LABEL), codebase: num('trailhead:codebase'), conventions: num('trailhead:conventions') };
171
194
  }
172
195
 
173
- function gatherSnapshot(gh, repo) {
196
+ function gatherSnapshot(gh, repo, singles) {
197
+ const one = singles || lookupSingletons(gh, repo);
174
198
  const truncated = [];
175
199
  const capped = (list, limit, name) => { if (list.length >= limit) truncated.push(name); return list; };
176
200
  const maps = capped(issueList(gh, repo, ['--label', 'trailhead:map', '--state', 'open', '--json', 'number,title', '--limit', String(LIMITS.maps)]), LIMITS.maps, 'maps')
@@ -190,7 +214,7 @@ function gatherSnapshot(gh, repo) {
190
214
  if (closed.length >= LIMITS.resolved && resolved.length < RESOLVED_SHOWN) truncated.push('resolved');
191
215
  const openIssues = capped(issueList(gh, repo, ['--state', 'open', '--json', 'number,labels', '--limit', String(LIMITS.inbox)]), LIMITS.inbox, 'inbox');
192
216
  const inbox = openIssues.filter((i) => !names(i.labels).some((n) => n.startsWith('trailhead:'))).length;
193
- const anchors = { codebase: firstNumber(gh, repo, 'trailhead:codebase'), conventions: firstNumber(gh, repo, 'trailhead:conventions') };
217
+ const anchors = { codebase: one.codebase, conventions: one.conventions };
194
218
  return { repo, maps, whiteboard, resolved, inbox, anchors, lastAudit: null, truncated };
195
219
  }
196
220
 
@@ -209,11 +233,6 @@ function summaryLine(number, snap) {
209
233
  return `dashboard${number === null ? '' : ' #' + number}: ${snap.maps.length} maps, whiteboard ${snap.whiteboard.length}, flags: ${flagSummary(snap.maps)}${cut}`;
210
234
  }
211
235
 
212
- function findDashboard(gh, repo) {
213
- const list = issueList(gh, repo, ['--label', LABEL, '--state', 'open', '--json', 'number', '--limit', '1']);
214
- return list.length ? list[0].number : null;
215
- }
216
-
217
236
  function isPinned(gh, repo, number) {
218
237
  const [owner, name] = repo.split('/');
219
238
  const q = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){pinnedIssues(first:3){nodes{issue{number}}}}}';
@@ -284,10 +303,11 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
284
303
  try {
285
304
  const repo = o.repo || defaultRepo(run);
286
305
  if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
306
+ const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
287
307
  if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
288
- const snap = gatherSnapshot(run, repo);
308
+ const snap = gatherSnapshot(run, repo, singles);
289
309
  const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
290
- const number = findDashboard(run, repo);
310
+ const number = singles.dashboard;
291
311
 
292
312
  if (number === null) {
293
313
  const body = renderDashboard({ ...snap, lastAudit: marker });