@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,186 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead auto queue (self-contained, ships in templates/ so it reaches every
4
+ // install channel). Builds the ordered work queue of an autonomous run for a chosen
5
+ // run scope: one map, the whiteboard, or everything. #280.
6
+ //
7
+ // CLI:
8
+ // node trailhead-auto-queue.js scopes [--repo <owner/name>]
9
+ // node trailhead-auto-queue.js queue --scope map:<n>|whiteboard|everything
10
+ // [--skip <csv>] [--repo <owner/name>]
11
+ //
12
+ // The frontier is the canonical frontier query (substrate-commands.md): open,
13
+ // `trailhead:ticket`, unassigned, not blocked / unverified / paused. The
14
+ // `trailhead:blocked` label is the source of truth for blockers; this helper adds
15
+ // no dependency logic. The run's own read also skips `trailhead:needs-you` (a ticket
16
+ // a run parked for the human); the human frontier does not. Groups are ordered by
17
+ // map number ascending, the whiteboard last; tickets inside a group ascending by
18
+ // number. `--skip` names the tickets the
19
+ // run already marked needs-you or stopped on; `next` is the lowest ticket of the first
20
+ // group that still has one (null when the scope is drained). A read that hits the
21
+ // 1000 limit fails closed (never a partial queue).
22
+ //
23
+ // Prints exactly one JSON line on stdout:
24
+ // scopes: {"status":"ok","maps":[{"number","title","frontier"}],"whiteboard":{"frontier"}}
25
+ // queue: {"status":"ok","scope","groups":[{"kind","map","title","tickets":[{"number","title","type"}],"resume":[...]}],"drift":[...],"next":{...}|null}
26
+ // error: {"status":"error","reason":"..."}
27
+ // Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
28
+
29
+ const { spawnSync } = require('child_process');
30
+
31
+ const LIMIT = 1000;
32
+ const FRONTIER_SEARCH = 'no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused';
33
+ const NEEDS_YOU = 'trailhead:needs-you';
34
+ // The run's own frontier read: the canonical frontier minus the tickets a run parked
35
+ // for the human. Humans still see those on the frontier (FRONTIER_SEARCH is unchanged).
36
+ const RUN_SEARCH = FRONTIER_SEARCH + ' -label:' + NEEDS_YOU;
37
+ const WHITEBOARD = 'trailhead:whiteboard';
38
+ const MAP_LABEL_RE = /^trailhead:map-(\d+)$/;
39
+ const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
40
+
41
+ function defaultGh(args) {
42
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
43
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
44
+ }
45
+
46
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
47
+ const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
48
+ const byNumber = (a, b) => a.number - b.number;
49
+
50
+ function parseScope(v) {
51
+ if (v === 'everything' || v === 'whiteboard') return v;
52
+ return /^map:[1-9]\d*$/.test(String(v)) ? v : null;
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const cmd = argv[0];
57
+ if (cmd !== 'scopes' && cmd !== 'queue') return { error: `usage: expected scopes or queue, got: ${cmd === undefined ? '(nothing)' : cmd}` };
58
+ const o = { cmd, repo: null, scope: null, skip: [] };
59
+ for (let i = 1; i < argv.length; i++) {
60
+ const flag = argv[i];
61
+ const allowed = cmd === 'queue' ? ['--repo', '--scope', '--skip'] : ['--repo'];
62
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
63
+ i++;
64
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
65
+ const v = argv[i];
66
+ if (flag === '--repo') {
67
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
68
+ o.repo = v;
69
+ } else if (flag === '--scope') {
70
+ o.scope = parseScope(v);
71
+ if (!o.scope) return { error: `usage: --scope must be map:<n>, whiteboard or everything, got: ${v}` };
72
+ } else {
73
+ const parts = v.split(',').map((s) => s.trim()).filter(Boolean);
74
+ if (!parts.every((s) => /^[1-9]\d*$/.test(s))) return { error: `usage: --skip must be a csv of ticket numbers, got: ${v}` };
75
+ o.skip = parts.map(Number);
76
+ }
77
+ }
78
+ if (cmd === 'queue' && !o.scope) return { error: 'usage: queue requires --scope' };
79
+ return o;
80
+ }
81
+
82
+ const typeOf = (labelNames) => {
83
+ const t = TYPES.find((x) => labelNames.includes(`trailhead:${x}`));
84
+ return t || null;
85
+ };
86
+
87
+ // Places each ticket: on its open map, else the whiteboard, else nowhere. A ticket
88
+ // on more than one open map is drift (placed on none).
89
+ function place(openMaps, issues) {
90
+ const open = new Set(openMaps.map((m) => m.number));
91
+ const byMap = new Map(openMaps.map((m) => [m.number, []]));
92
+ const whiteboard = [];
93
+ const drift = [];
94
+ for (const i of issues) {
95
+ const ln = names(i.labels);
96
+ const mapNums = ln.map((l) => (MAP_LABEL_RE.exec(l) || [])[1]).filter(Boolean).map(Number).filter((n) => open.has(n));
97
+ const row = { number: i.number, title: i.title, type: typeOf(ln) };
98
+ if (mapNums.length > 1) drift.push({ ...row, maps: [...new Set(mapNums)].sort((a, b) => a - b), whiteboard: ln.includes(WHITEBOARD) });
99
+ else if (mapNums.length === 1) byMap.get(mapNums[0]).push(row);
100
+ else if (ln.includes(WHITEBOARD)) whiteboard.push(row);
101
+ }
102
+ return { byMap, whiteboard, drift };
103
+ }
104
+
105
+ // Pure: openMaps [{number,title}], frontier / paused issues [{number,title,labels}],
106
+ // scope, skip [n]. Returns { groups, drift, next } or { error }.
107
+ function buildQueue(openMaps, frontier, paused, scope, skip) {
108
+ const skipSet = new Set(skip || []);
109
+ // A needs-you ticket is dropped even when the search index lagged and returned it.
110
+ const live = (list) => list.filter((i) => !skipSet.has(i.number) && !names(i.labels).includes(NEEDS_YOU));
111
+ const maps = [...openMaps].sort(byNumber);
112
+ let wantMaps = maps;
113
+ let wantWhiteboard = scope === 'whiteboard' || scope === 'everything';
114
+ if (scope === 'whiteboard') wantMaps = [];
115
+ else if (scope.startsWith('map:')) {
116
+ const n = Number(scope.slice(4));
117
+ wantMaps = maps.filter((m) => m.number === n);
118
+ if (!wantMaps.length) return { error: `map #${n} is not an open trailhead:map` };
119
+ wantWhiteboard = false;
120
+ }
121
+ const f = place(maps, live(frontier));
122
+ const p = place(maps, live(paused));
123
+ const sorted = (rows) => [...rows].sort(byNumber);
124
+ const groups = wantMaps.map((m) => ({ kind: 'map', map: m.number, title: m.title, tickets: sorted(f.byMap.get(m.number)), resume: sorted(p.byMap.get(m.number)) }));
125
+ if (wantWhiteboard) groups.push({ kind: 'whiteboard', map: null, title: 'Whiteboard', tickets: sorted(f.whiteboard), resume: sorted(p.whiteboard) });
126
+ const inScope = (d) => (scope === 'everything'
127
+ || (scope === 'whiteboard' && d.whiteboard)
128
+ || (scope.startsWith('map:') && d.maps.includes(Number(scope.slice(4)))));
129
+ const seen = new Set();
130
+ const drift = [...f.drift, ...p.drift].filter(inScope).filter((d) => !seen.has(d.number) && seen.add(d.number)).sort(byNumber)
131
+ .map(({ number, title, type, maps: ms }) => ({ number, title, type, maps: ms }));
132
+ const first = groups.find((g) => g.tickets.length);
133
+ const next = first ? { ...first.tickets[0], map: first.map } : null;
134
+ return { groups, drift, next };
135
+ }
136
+
137
+ function readList(run, args, what) {
138
+ const r = run(args);
139
+ if (r.status !== 0) return { error: `gh issue list (${what}) failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
140
+ let rows;
141
+ try { rows = JSON.parse(r.stdout); } catch { return { error: `gh issue list (${what}) returned unparseable JSON` }; }
142
+ if (!Array.isArray(rows)) return { error: `gh issue list (${what}) returned unexpected JSON` };
143
+ if (rows.length >= LIMIT) return { error: `incomplete list: the ${what} read hit the ${LIMIT} limit` };
144
+ return { rows };
145
+ }
146
+
147
+ function main(argv, { gh, stdout, stderr } = {}) {
148
+ const out = stdout || process.stdout;
149
+ const err = stderr || process.stderr;
150
+ const o = parseArgs(argv || []);
151
+ if (o.error) { err.write(o.error + '\n'); return 2; }
152
+ const run = gh || defaultGh;
153
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
154
+ const fail = (reason) => { emit({ status: 'error', reason }); return 1; };
155
+ const R = o.repo ? ['-R', o.repo] : [];
156
+ const lim = ['--limit', String(LIMIT)];
157
+
158
+ const maps = readList(run, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', ...lim, '--json', 'number,title', ...R], 'open maps');
159
+ if (maps.error) return fail(maps.error);
160
+ const frontier = readList(run, ['issue', 'list', '--label', 'trailhead:ticket', '--state', 'open', '--search', RUN_SEARCH, ...lim, '--json', 'number,title,labels', ...R], 'frontier');
161
+ if (frontier.error) return fail(frontier.error);
162
+
163
+ if (o.cmd === 'scopes') {
164
+ const q = buildQueue(maps.rows, frontier.rows, [], 'everything', []);
165
+ const wb = q.groups.find((g) => g.kind === 'whiteboard');
166
+ emit({
167
+ status: 'ok',
168
+ maps: q.groups.filter((g) => g.kind === 'map').map((g) => ({ number: g.map, title: g.title, frontier: g.tickets.length })),
169
+ whiteboard: { frontier: wb.tickets.length },
170
+ });
171
+ return 0;
172
+ }
173
+
174
+ const paused = readList(run, ['issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:paused', '--assignee', '@me', '--state', 'open', ...lim, '--json', 'number,title,labels', ...R], 'paused claims');
175
+ if (paused.error) return fail(paused.error);
176
+ const q = buildQueue(maps.rows, frontier.rows, paused.rows, o.scope, o.skip);
177
+ if (q.error) return fail(q.error);
178
+ emit({ status: 'ok', scope: o.scope, groups: q.groups, drift: q.drift, next: q.next });
179
+ return 0;
180
+ }
181
+
182
+ module.exports = { main, parseArgs, buildQueue, FRONTIER_SEARCH, RUN_SEARCH, NEEDS_YOU };
183
+
184
+ if (require.main === module) {
185
+ process.exit(main(process.argv.slice(2), {}));
186
+ }
@@ -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
+ }