@marcomigozzi/trailhead 0.13.0 → 0.15.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 +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -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 +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -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 +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- 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/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -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 +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -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/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- 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/prototype.md +1 -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 +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- 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 +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -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 +340 -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-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- package/plugins/trailhead/types/index.d.ts +103 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead dashboard regeneration (self-contained, ships in templates/ so it
|
|
4
|
+
// reaches every install channel). Gathers the tracker data the pinned
|
|
5
|
+
// `trailhead:dashboard` issue indexes, renders the whole body deterministically
|
|
6
|
+
// and writes it through trailhead-issue-body.js's guarded write (fail-safe read,
|
|
7
|
+
// updatedAt check, post-write conflict check, secret scan). #253.
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
|
|
11
|
+
// --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
|
|
12
|
+
// audit's only writer path); without it the marker is copied verbatim from the
|
|
13
|
+
// existing body. --dry-run gathers and reads through gh, computes and scans, but
|
|
14
|
+
// writes, creates, pins and labels nothing; it prints the old and new byte sizes.
|
|
15
|
+
//
|
|
16
|
+
// Self-heal: ensures the `trailhead:dashboard` label; creates the issue directly
|
|
17
|
+
// with the final scanned body when it is missing (never an empty or placeholder
|
|
18
|
+
// body) and pins it; re-pins it when unpinned. Never closes or prunes a map: the
|
|
19
|
+
// flags are signals, the offers stay with the agent.
|
|
20
|
+
//
|
|
21
|
+
// Prints exactly one line on stdout:
|
|
22
|
+
// dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
|
|
23
|
+
// aborted: <reason>
|
|
24
|
+
// Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
|
|
25
|
+
// A pin check or pin failure AFTER the body was written is not an abort: the line
|
|
26
|
+
// says `written, pin check failed: <reason>` (or `written, pin failed ...`) and the
|
|
27
|
+
// exit is 0, since the body is in place; the agent surfaces the pin problem.
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const issueBody = require('./trailhead-issue-body.js');
|
|
33
|
+
|
|
34
|
+
const { guardedWrite, loadGuard, secretProblem, readSection, PLACEHOLDER_RE } = issueBody;
|
|
35
|
+
|
|
36
|
+
const LABEL = 'trailhead:dashboard';
|
|
37
|
+
const TITLE = '📊 trailhead dashboard';
|
|
38
|
+
const NEAR_CAP = 90;
|
|
39
|
+
const CAP = 100;
|
|
40
|
+
const RESOLVED_SHOWN = 10;
|
|
41
|
+
const AUDIT_LINE_RE = /^<!-- trailhead:last-audit [^ ]* -->[ \t\r]*$/;
|
|
42
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
43
|
+
// List-query limits; a list that comes back full is reported as truncated.
|
|
44
|
+
const LIMITS = { maps: 200, scoped: 1000, whiteboard: 200, resolved: 100, inbox: 1000 };
|
|
45
|
+
const NOT_RESOLVED_LABELS = ['trailhead:superseded', 'trailhead:out-of-scope'];
|
|
46
|
+
|
|
47
|
+
// --- pure render -------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// A title safe to put inside a markdown link text, on one line. Comment
|
|
50
|
+
// delimiters are neutralised so a title can never forge the last-audit marker.
|
|
51
|
+
function linkText(title) {
|
|
52
|
+
return String(title).replace(/\s+/g, ' ').trim()
|
|
53
|
+
.replace(/<!--/g, '<!--').replace(/-->/g, '-->')
|
|
54
|
+
.replace(/([\[\]])/g, '\\$1');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The last-audit marker the dashboard itself wrote: a line of its own (the last
|
|
58
|
+
// such line), never text embedded in another line.
|
|
59
|
+
function extractAudit(body) {
|
|
60
|
+
const hits = String(body).split('\n').filter((l) => AUDIT_LINE_RE.test(l));
|
|
61
|
+
return hits.length ? hits[hits.length - 1].trim() : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const issueUrl = (repo, n) => `https://github.com/${repo}/issues/${n}`;
|
|
65
|
+
const link = (repo, n, text) => `[${text}](${issueUrl(repo, n)})`;
|
|
66
|
+
|
|
67
|
+
// The flags of one map, from its raw numbers. exhausted needs a zero open-scoped
|
|
68
|
+
// count AND a confirmed-empty fog; prunable needs the bounded closed-edge check.
|
|
69
|
+
function mapFlags(m) {
|
|
70
|
+
const flags = [];
|
|
71
|
+
if (m.openScoped === 0 && m.fog === false) flags.push('exhausted · closeable');
|
|
72
|
+
if (m.total >= NEAR_CAP) {
|
|
73
|
+
const base = `${m.total >= CAP ? 'at cap' : 'near cap'} · ${m.total}/${CAP}`;
|
|
74
|
+
flags.push(m.closedEdges === true ? `${base} · prunable` : base);
|
|
75
|
+
}
|
|
76
|
+
return flags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Classifies the open whiteboard tickets with the panel's precedence: unverified
|
|
80
|
+
// (quarantined first), paused, blocked, in progress (assigned), frontier
|
|
81
|
+
// (everything else). A ticket lands in exactly one bucket. #263.
|
|
82
|
+
function classify(whiteboard) {
|
|
83
|
+
const out = { frontier: [], inProgress: [], paused: [], blocked: [], unverified: [] };
|
|
84
|
+
for (const t of whiteboard) {
|
|
85
|
+
if (t.labels.includes('trailhead:unverified')) out.unverified.push(t);
|
|
86
|
+
else if (t.labels.includes('trailhead:paused')) out.paused.push(t);
|
|
87
|
+
else if (t.labels.includes('trailhead:blocked')) out.blocked.push(t);
|
|
88
|
+
else if (t.assignees && t.assignees.length) out.inProgress.push(t);
|
|
89
|
+
else out.frontier.push(t);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// snapshot: { repo, maps[], whiteboard[], resolved[], inbox, anchors, lastAudit }.
|
|
95
|
+
function renderDashboard(s) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
lines.push("The pinned index of this repo's trailhead surface. Regenerate any time with `/trailhead:dashboard`; it also self-heals on `/trailhead`, `/trailhead:map`, and `/trailhead:whiteboard`.");
|
|
98
|
+
lines.push('', '## 🗺 Maps');
|
|
99
|
+
if (s.maps.length === 0) lines.push('_No open maps._');
|
|
100
|
+
for (const m of s.maps) {
|
|
101
|
+
lines.push(`- ${link(s.repo, m.number, linkText(m.title))}: ${m.openScoped} open${mapFlags(m).map((f) => ` · ${f}`).join('')}`);
|
|
102
|
+
}
|
|
103
|
+
const wb = classify(s.whiteboard);
|
|
104
|
+
lines.push('', '## 🧷 Whiteboard (map-less tickets)');
|
|
105
|
+
if (s.whiteboard.length === 0) lines.push('_Nothing on the whiteboard._');
|
|
106
|
+
for (const [label, list] of [['Frontier', wb.frontier], ['In progress', wb.inProgress], ['Paused', wb.paused], ['Blocked', wb.blocked], ['Unverified', wb.unverified]]) {
|
|
107
|
+
if (list.length === 0) continue;
|
|
108
|
+
lines.push(`${label} (${list.length}):`);
|
|
109
|
+
for (const t of list) lines.push(`- ${link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)}`);
|
|
110
|
+
}
|
|
111
|
+
if (s.resolved.length) {
|
|
112
|
+
lines.push('', `_Recently resolved: ${s.resolved.map((t) => link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)).join(', ')}_`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('', '## 📊 Counts');
|
|
115
|
+
lines.push(`- Untriaged inbox: ${s.inbox}${(s.truncated || []).includes('inbox') ? '+' : ''}`);
|
|
116
|
+
lines.push(`- Whiteboard frontier: ${wb.frontier.length}`);
|
|
117
|
+
lines.push(`- Open maps: ${s.maps.length}`);
|
|
118
|
+
const anchors = [['Codebase', s.anchors.codebase], ['Conventions', s.anchors.conventions]]
|
|
119
|
+
.filter(([, n]) => n).map(([name, n]) => link(s.repo, n, `${name} #${n}`));
|
|
120
|
+
if (anchors.length) lines.push('', `Anchors: ${anchors.join(' · ')}`);
|
|
121
|
+
if (s.lastAudit) lines.push('', s.lastAudit);
|
|
122
|
+
return lines.join('\n') + '\n';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- gather ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
class GatherError extends Error {}
|
|
128
|
+
|
|
129
|
+
function ghJson(gh, args) {
|
|
130
|
+
const r = gh(args);
|
|
131
|
+
if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 3).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
|
|
132
|
+
try { return JSON.parse(r.stdout); } catch { throw new GatherError(`gh ${args.slice(0, 3).join(' ')} returned unparseable JSON`); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ghText(gh, args) {
|
|
136
|
+
const r = gh(args);
|
|
137
|
+
if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 2).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
|
|
138
|
+
return r.stdout.trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
|
142
|
+
|
|
143
|
+
function issueList(gh, repo, extra) {
|
|
144
|
+
return ghJson(gh, ['issue', 'list', ...extra, '-R', repo]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function gatherMap(gh, repo, m, truncated) {
|
|
148
|
+
const scoped = issueList(gh, repo, ['--label', `trailhead:map-${m.number}`, '--state', 'open', '--json', 'number', '--limit', String(LIMITS.scoped)]);
|
|
149
|
+
if (scoped.length >= LIMITS.scoped) truncated.push(`map-${m.number} scoped`);
|
|
150
|
+
const total = parseInt(ghText(gh, ['api', `repos/${repo}/issues/${m.number}`, '--jq', '.sub_issues_summary.total']), 10);
|
|
151
|
+
if (!Number.isInteger(total)) throw new GatherError(`map #${m.number}: unreadable sub-issue total`);
|
|
152
|
+
const out = { number: m.number, title: m.title, openScoped: scoped.length, total, fog: null, closedEdges: null };
|
|
153
|
+
if (out.openScoped === 0) {
|
|
154
|
+
// Candidate only: confirm the fog (`## Not yet specified`) is empty.
|
|
155
|
+
const view = ghJson(gh, ['issue', 'view', String(m.number), '--json', 'body', '-R', repo]);
|
|
156
|
+
const fog = readSection(String(view.body || ''), 'Not yet specified');
|
|
157
|
+
// A missing section is unconfirmed (fog stays null: no exhausted flag), only
|
|
158
|
+
// a present and empty one confirms there is no fog left.
|
|
159
|
+
out.fog = fog === null ? null : fog.some((l) => !PLACEHOLDER_RE.test(l));
|
|
160
|
+
}
|
|
161
|
+
if (total >= NEAR_CAP) {
|
|
162
|
+
const edges = ghJson(gh, ['api', `repos/${repo}/issues/${m.number}/sub_issues?per_page=100`]);
|
|
163
|
+
out.closedEdges = Array.isArray(edges) && edges.some((e) => e && e.state === 'closed');
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
}
|
|
172
|
+
|
|
173
|
+
function gatherSnapshot(gh, repo) {
|
|
174
|
+
const truncated = [];
|
|
175
|
+
const capped = (list, limit, name) => { if (list.length >= limit) truncated.push(name); return list; };
|
|
176
|
+
const maps = capped(issueList(gh, repo, ['--label', 'trailhead:map', '--state', 'open', '--json', 'number,title', '--limit', String(LIMITS.maps)]), LIMITS.maps, 'maps')
|
|
177
|
+
.sort((a, b) => a.number - b.number)
|
|
178
|
+
.map((m) => gatherMap(gh, repo, m, truncated));
|
|
179
|
+
const whiteboard = capped(issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'open', '--json', 'number,title,labels,assignees', '--limit', String(LIMITS.whiteboard)]), LIMITS.whiteboard, 'whiteboard')
|
|
180
|
+
.map((t) => ({ number: t.number, title: t.title, labels: names(t.labels), assignees: (t.assignees || []).map((a) => a.login) }));
|
|
181
|
+
// Most recently updated first, so the newest closures sit inside the fetched
|
|
182
|
+
// window even on a repo with a long closed history; then ordered by closedAt.
|
|
183
|
+
const closed = issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'closed', '--search', 'sort:updated-desc', '--json', 'number,title,closedAt,labels', '--limit', String(LIMITS.resolved)]);
|
|
184
|
+
const resolved = closed
|
|
185
|
+
.filter((t) => !names(t.labels).some((n) => NOT_RESOLVED_LABELS.includes(n)))
|
|
186
|
+
.sort((a, b) => String(b.closedAt).localeCompare(String(a.closedAt)))
|
|
187
|
+
.slice(0, RESOLVED_SHOWN)
|
|
188
|
+
.map((t) => ({ number: t.number, title: t.title }));
|
|
189
|
+
// A full window only matters when it could not fill the shown list.
|
|
190
|
+
if (closed.length >= LIMITS.resolved && resolved.length < RESOLVED_SHOWN) truncated.push('resolved');
|
|
191
|
+
const openIssues = capped(issueList(gh, repo, ['--state', 'open', '--json', 'number,labels', '--limit', String(LIMITS.inbox)]), LIMITS.inbox, 'inbox');
|
|
192
|
+
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') };
|
|
194
|
+
return { repo, maps, whiteboard, resolved, inbox, anchors, lastAudit: null, truncated };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- summary and self-heal ---------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function flagSummary(maps) {
|
|
200
|
+
const parts = [];
|
|
201
|
+
for (const m of maps) {
|
|
202
|
+
for (const f of mapFlags(m)) parts.push(`map-${m.number} ${f.replace(' · closeable', '').replace(/ · /g, ' ')}`);
|
|
203
|
+
}
|
|
204
|
+
return parts.length ? parts.join(', ') : 'none';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function summaryLine(number, snap) {
|
|
208
|
+
const cut = snap.truncated && snap.truncated.length ? `, truncated: ${snap.truncated.join(', ')}` : '';
|
|
209
|
+
return `dashboard${number === null ? '' : ' #' + number}: ${snap.maps.length} maps, whiteboard ${snap.whiteboard.length}, flags: ${flagSummary(snap.maps)}${cut}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
function isPinned(gh, repo, number) {
|
|
218
|
+
const [owner, name] = repo.split('/');
|
|
219
|
+
const q = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){pinnedIssues(first:3){nodes{issue{number}}}}}';
|
|
220
|
+
const data = ghJson(gh, ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`]);
|
|
221
|
+
const nodes = (((data.data || {}).repository || {}).pinnedIssues || {}).nodes || [];
|
|
222
|
+
return nodes.some((n) => n && n.issue && n.issue.number === number);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Pins the issue; returns null on success or a short failure note.
|
|
226
|
+
function pin(gh, repo, number) {
|
|
227
|
+
const node = gh(['api', `repos/${repo}/issues/${number}`, '--jq', '.node_id']);
|
|
228
|
+
if (node.status !== 0 || !node.stdout.trim()) return 'pin failed (node id)';
|
|
229
|
+
const r = gh(['api', 'graphql', '-f', 'query=mutation($id:ID!){ pinIssue(input:{issueId:$id}){ issue{ number } } }', '-F', `id=${node.stdout.trim()}`]);
|
|
230
|
+
return r.status === 0 ? null : 'pin failed (pinned slots full?)';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function createDashboard(gh, repo, body, tmpdir) {
|
|
234
|
+
const dir = fs.mkdtempSync(path.join(tmpdir || os.tmpdir(), 'trailhead-dashboard-'));
|
|
235
|
+
const file = path.join(dir, 'body.md');
|
|
236
|
+
try {
|
|
237
|
+
fs.writeFileSync(file, body);
|
|
238
|
+
const r = gh(['issue', 'create', '--title', TITLE, '--label', LABEL, '--body-file', file, '-R', repo]);
|
|
239
|
+
if (r.status !== 0) return { error: `gh issue create failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}` };
|
|
240
|
+
const m = /\/issues\/(\d+)\s*$/.exec(r.stdout.trim());
|
|
241
|
+
return m ? { number: Number(m[1]) } : { error: 'gh issue create returned no issue url' };
|
|
242
|
+
} finally {
|
|
243
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- CLI ---------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
function parseArgs(argv) {
|
|
250
|
+
const o = { repo: null, setLastAudit: null, dryRun: false };
|
|
251
|
+
for (let i = 0; i < argv.length; i++) {
|
|
252
|
+
const flag = argv[i];
|
|
253
|
+
if (flag === '--dry-run') { o.dryRun = true; continue; }
|
|
254
|
+
if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
|
|
255
|
+
i++;
|
|
256
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
257
|
+
const v = argv[i];
|
|
258
|
+
if (flag === '--repo') {
|
|
259
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
260
|
+
o.repo = v;
|
|
261
|
+
} else {
|
|
262
|
+
if (!ISO_RE.test(v)) return { error: `usage: --set-last-audit must be an ISO-8601 UTC time like 2026-10-14T09:30:00Z, got: ${v}` };
|
|
263
|
+
o.setLastAudit = v;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return o;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function defaultRepo(gh) {
|
|
270
|
+
const r = gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
|
|
271
|
+
const slug = r.status === 0 ? r.stdout.trim() : '';
|
|
272
|
+
return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {}) {
|
|
276
|
+
const out = stdout || process.stdout;
|
|
277
|
+
const err = stderr || process.stderr;
|
|
278
|
+
const o = parseArgs(argv || []);
|
|
279
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
280
|
+
const run = gh || issueBody.defaultGh;
|
|
281
|
+
const g = guard === undefined ? loadGuard() : guard;
|
|
282
|
+
const abort = (reason) => { out.write(`aborted: ${reason}\n`); return 1; };
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const repo = o.repo || defaultRepo(run);
|
|
286
|
+
if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
|
|
287
|
+
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);
|
|
289
|
+
const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
|
|
290
|
+
const number = findDashboard(run, repo);
|
|
291
|
+
|
|
292
|
+
if (number === null) {
|
|
293
|
+
const body = renderDashboard({ ...snap, lastAudit: marker });
|
|
294
|
+
const problem = secretProblem(body, g);
|
|
295
|
+
if (problem) return abort(problem);
|
|
296
|
+
if (o.dryRun) {
|
|
297
|
+
out.write(`${summaryLine(null, snap)} (dry-run: would create and pin, body ${Buffer.byteLength(body)} bytes), nothing written\n`);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
const created = createDashboard(run, repo, body, tmpdir);
|
|
301
|
+
if (created.error) return abort(created.error);
|
|
302
|
+
const note = pin(run, repo, created.number);
|
|
303
|
+
out.write(`${summaryLine(created.number, snap)} (created${note ? ', ' + note : ' and pinned'}, body ${Buffer.byteLength(body)} bytes)\n`);
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const w = guardedWrite(number, (current) => {
|
|
308
|
+
const carried = marker || extractAudit(current);
|
|
309
|
+
return renderDashboard({ ...snap, lastAudit: carried });
|
|
310
|
+
}, { gh: run, repo, guard: g, dryRun: o.dryRun, tmpdir, firstEditDelta2 });
|
|
311
|
+
if (!w.ok) return abort(w.reason);
|
|
312
|
+
const sizes = `body ${w.oldSize} -> ${w.newSize} bytes`;
|
|
313
|
+
if (w.dryRun) {
|
|
314
|
+
out.write(`${summaryLine(number, snap)} (dry-run, ${sizes}), nothing written\n`);
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
// The body is already written: a pin check or pin failure is reported next to
|
|
318
|
+
// that fact (exit 0), never as an abort that looks like nothing was written.
|
|
319
|
+
let note = w.historyFull ? issueBody.HISTORY_FULL_NOTE.replace(/^ \((.*)\)$/, ', $1') : '';
|
|
320
|
+
try {
|
|
321
|
+
if (!isPinned(run, repo, number)) {
|
|
322
|
+
const failed = pin(run, repo, number);
|
|
323
|
+
note += failed ? `, written, ${failed}` : ', re-pinned';
|
|
324
|
+
}
|
|
325
|
+
} catch (e) {
|
|
326
|
+
note += `, written, pin check failed: ${e && e.message ? e.message : String(e)}`;
|
|
327
|
+
}
|
|
328
|
+
out.write(`${summaryLine(number, snap)} (${sizes}${note})\n`);
|
|
329
|
+
return 0;
|
|
330
|
+
} catch (e) {
|
|
331
|
+
if (e instanceof GatherError) return abort(e.message);
|
|
332
|
+
return abort(`unexpected error: ${e && e.message ? e.message : String(e)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { renderDashboard, mapFlags, classify, gatherSnapshot, parseArgs, main };
|
|
337
|
+
|
|
338
|
+
if (require.main === module) {
|
|
339
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
340
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead isolation answer (self-contained, ships in templates/ so it reaches
|
|
4
|
+
// every install channel: bin/trailhead.js copies templates/ wholesale for
|
|
5
|
+
// Claude/npm and Codex). Remembers the answer to the work-start concurrency ask
|
|
6
|
+
// under `isolation: auto` in <project root>/.trailhead/isolation-answer.json, so
|
|
7
|
+
// later work-starts of the same working stretch reuse it. #245.
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// node trailhead-isolation-answer.js check [--repo <dir>] --others <csv|none|unavailable>
|
|
11
|
+
// node trailhead-isolation-answer.js record [--repo <dir>] --answer yes|no --others <csv|none>
|
|
12
|
+
// node trailhead-isolation-answer.js clear [--repo <dir>]
|
|
13
|
+
// --repo defaults to cwd. It is canonicalised to the project root: inside a
|
|
14
|
+
// linked worktree it resolves to the main worktree (git common dir), so a call
|
|
15
|
+
// from a trailhead/t<n> worktree reads the same record; on a git failure (or not
|
|
16
|
+
// a git repo) the given dir is used as is.
|
|
17
|
+
// File shape: { answer: "yes"|"no", others: [ticket numbers], recordedAt, usedAt }
|
|
18
|
+
// `others` is the live-claims fingerprint (open issues assigned to @me minus the
|
|
19
|
+
// current ticket), sorted and deduplicated; `none` is the empty set.
|
|
20
|
+
// A session is a working stretch: it ends after 8 hours without a work-start
|
|
21
|
+
// (sliding idle window; each reuse refreshes usedAt).
|
|
22
|
+
// check prints:
|
|
23
|
+
// {"status":"reuse","answer","recordedAt","path"} (only after usedAt was refreshed)
|
|
24
|
+
// {"status":"ask","reason":"absent|expired|claims-changed|invalid|unavailable","path"}
|
|
25
|
+
// record prints {"status":"written","path","record"}; clear prints
|
|
26
|
+
// {"status":"cleared|absent","path"}.
|
|
27
|
+
// Exit 0 on every normal outcome and on any unexpected I/O error (status
|
|
28
|
+
// skipped with a message, which the caller treats as ask); exit 2 only on a
|
|
29
|
+
// usage error (nothing is written).
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
35
|
+
|
|
36
|
+
const STATE_DIR = '.trailhead';
|
|
37
|
+
const STATE_FILE = 'isolation-answer.json';
|
|
38
|
+
const IDLE_WINDOW_MS = 8 * 60 * 60 * 1000;
|
|
39
|
+
const ANSWERS = ['yes', 'no'];
|
|
40
|
+
|
|
41
|
+
const isPosInt = (v) => Number.isInteger(v) && v > 0;
|
|
42
|
+
|
|
43
|
+
// Sorted, deduplicated copy of a list of ticket numbers.
|
|
44
|
+
const normalise = (list) => [...new Set(list)].sort((a, b) => a - b);
|
|
45
|
+
|
|
46
|
+
// Strict parse of the --others value. Returns a sorted deduplicated array,
|
|
47
|
+
// the string "unavailable" (when allowed), or null if invalid.
|
|
48
|
+
function parseOthers(text, { allowUnavailable }) {
|
|
49
|
+
if (text === 'none') return [];
|
|
50
|
+
if (text === 'unavailable') return allowUnavailable ? 'unavailable' : null;
|
|
51
|
+
if (typeof text !== 'string' || !/^[1-9]\d*(,[1-9]\d*)*$/.test(text)) return null;
|
|
52
|
+
return normalise(text.split(',').map(Number));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strict ISO-8601 timestamp (as written by toISOString) -> epoch ms, or null.
|
|
56
|
+
function parseTime(text) {
|
|
57
|
+
if (typeof text !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(text)) return null;
|
|
58
|
+
const ms = Date.parse(text);
|
|
59
|
+
return Number.isNaN(ms) ? null : ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Valid record or null (malformed or future timestamp, answer not yes/no,
|
|
63
|
+
// others not an array of positive integers).
|
|
64
|
+
function validateRecord(raw, nowMs) {
|
|
65
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
66
|
+
if (!ANSWERS.includes(raw.answer)) return null;
|
|
67
|
+
if (!Array.isArray(raw.others) || !raw.others.every(isPosInt)) return null;
|
|
68
|
+
const recordedAt = parseTime(raw.recordedAt);
|
|
69
|
+
const usedAt = parseTime(raw.usedAt);
|
|
70
|
+
if (recordedAt === null || usedAt === null) return null;
|
|
71
|
+
if (recordedAt > nowMs || usedAt > nowMs) return null;
|
|
72
|
+
return { answer: raw.answer, others: normalise(raw.others), recordedAt: raw.recordedAt, usedAt, usedAtText: raw.usedAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pure decision: reuse the record or ask again, and why.
|
|
76
|
+
// record: parsed file content (null/undefined = absent); others: array or "unavailable".
|
|
77
|
+
function decide(record, others, now) {
|
|
78
|
+
if (others === 'unavailable') return { status: 'ask', reason: 'unavailable' };
|
|
79
|
+
if (record === null || record === undefined) return { status: 'ask', reason: 'absent' };
|
|
80
|
+
const valid = validateRecord(record, now.getTime());
|
|
81
|
+
if (valid === null) return { status: 'ask', reason: 'invalid' };
|
|
82
|
+
if (now.getTime() - valid.usedAt > IDLE_WINDOW_MS) return { status: 'ask', reason: 'expired' };
|
|
83
|
+
const want = normalise(others);
|
|
84
|
+
const same = want.length === valid.others.length && want.every((n, i) => n === valid.others[i]);
|
|
85
|
+
if (!same) return { status: 'ask', reason: 'claims-changed' };
|
|
86
|
+
return { status: 'reuse', answer: valid.answer, recordedAt: valid.recordedAt };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseArgs(argv) {
|
|
90
|
+
const command = argv[0];
|
|
91
|
+
if (command !== 'check' && command !== 'record' && command !== 'clear') {
|
|
92
|
+
return { error: `usage: expected "check", "record" or "clear", got: ${command === undefined ? '(nothing)' : command}` };
|
|
93
|
+
}
|
|
94
|
+
const allowed = command === 'check' ? ['--repo', '--others']
|
|
95
|
+
: command === 'record' ? ['--repo', '--answer', '--others'] : ['--repo'];
|
|
96
|
+
let repo = null;
|
|
97
|
+
let answer;
|
|
98
|
+
let others;
|
|
99
|
+
for (let i = 1; i < argv.length; i++) {
|
|
100
|
+
const flag = argv[i];
|
|
101
|
+
if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
102
|
+
i++;
|
|
103
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
104
|
+
const value = argv[i];
|
|
105
|
+
if (flag === '--repo') repo = value;
|
|
106
|
+
else if (flag === '--answer') {
|
|
107
|
+
if (!ANSWERS.includes(value)) return { error: `usage: --answer must be yes or no, got: ${value}` };
|
|
108
|
+
answer = value;
|
|
109
|
+
} else if (flag === '--others') {
|
|
110
|
+
others = parseOthers(value, { allowUnavailable: command === 'check' });
|
|
111
|
+
if (others === null) {
|
|
112
|
+
return { error: `usage: --others must be comma-separated positive integers, "none"${command === 'check' ? ' or "unavailable"' : ''}, got: ${value}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (command === 'check' && others === undefined) return { error: 'usage: check needs --others' };
|
|
117
|
+
if (command === 'record' && answer === undefined) return { error: 'usage: record needs --answer' };
|
|
118
|
+
if (command === 'record' && others === undefined) return { error: 'usage: record needs --others' };
|
|
119
|
+
return { command, repo, answer, others };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Project root: the main worktree when dir is inside a linked worktree, else
|
|
123
|
+
// dir as given (also on any git failure or when not a git repo).
|
|
124
|
+
function resolveProjectRoot(dir) {
|
|
125
|
+
try {
|
|
126
|
+
const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
127
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
if (common && path.basename(common) === '.git') return path.dirname(common);
|
|
129
|
+
return dir;
|
|
130
|
+
} catch {
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Reads and parses the file; missing = null, corrupt = {} (invalid, never a crash).
|
|
136
|
+
function readRecord(file, fsMod) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = fsMod.readFileSync(file, 'utf8');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err && err.code === 'ENOENT') return null;
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(text);
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Atomic write: a unique temp in the same dir, then rename over the file
|
|
152
|
+
// (never an in-place truncate); the temp is removed if anything fails.
|
|
153
|
+
function writeAtomic(dir, file, text, fsMod) {
|
|
154
|
+
fsMod.mkdirSync(dir, { recursive: true });
|
|
155
|
+
const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
156
|
+
try {
|
|
157
|
+
fsMod.writeFileSync(tmp, text);
|
|
158
|
+
fsMod.renameSync(tmp, file);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
try { fsMod.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeResult(stdout, result) {
|
|
166
|
+
stdout.write(JSON.stringify(result) + '\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function main(argv, { cwd, stdout, fs: fsMod, now } = {}) {
|
|
170
|
+
const cwd_ = cwd || process.cwd();
|
|
171
|
+
const out = stdout || process.stdout;
|
|
172
|
+
const fs_ = fsMod || fs;
|
|
173
|
+
const now_ = now || new Date();
|
|
174
|
+
|
|
175
|
+
const parsed = parseArgs(argv || []);
|
|
176
|
+
if (parsed.error) {
|
|
177
|
+
process.stderr.write(parsed.error + '\n');
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
|
|
182
|
+
const root = resolveProjectRoot(repoDir);
|
|
183
|
+
const dir = path.join(root, STATE_DIR);
|
|
184
|
+
const file = path.join(dir, STATE_FILE);
|
|
185
|
+
try {
|
|
186
|
+
if (parsed.command === 'clear') {
|
|
187
|
+
if (!fs_.existsSync(file)) {
|
|
188
|
+
writeResult(out, { status: 'absent', path: file });
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
fs_.unlinkSync(file);
|
|
192
|
+
writeResult(out, { status: 'cleared', path: file });
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (parsed.command === 'record') {
|
|
197
|
+
const record = { answer: parsed.answer, others: parsed.others, recordedAt: now_.toISOString(), usedAt: now_.toISOString() };
|
|
198
|
+
writeAtomic(dir, file, JSON.stringify(record, null, 2) + '\n', fs_);
|
|
199
|
+
writeResult(out, { status: 'written', path: file, record });
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const raw = readRecord(file, fs_);
|
|
204
|
+
const decision = decide(raw, parsed.others, now_);
|
|
205
|
+
if (decision.status !== 'reuse') {
|
|
206
|
+
writeResult(out, { ...decision, path: file });
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
// Slide the idle window first: reuse is only reported once it is persisted.
|
|
210
|
+
// No lock: a concurrent record landing between read and write is overwritten,
|
|
211
|
+
// which at worst leaves a stale fingerprint that re-asks, never a blind reuse.
|
|
212
|
+
const refreshed = { ...raw, usedAt: now_.toISOString() };
|
|
213
|
+
writeAtomic(dir, file, JSON.stringify(refreshed, null, 2) + '\n', fs_);
|
|
214
|
+
writeResult(out, { ...decision, path: file });
|
|
215
|
+
return 0;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
writeResult(out, { status: 'skipped', path: file, message: 'error: ' + (err && err.message ? err.message : String(err)) });
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { IDLE_WINDOW_MS, decide, parseOthers, parseArgs, resolveProjectRoot, main };
|
|
223
|
+
|
|
224
|
+
if (require.main === module) {
|
|
225
|
+
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
|
|
226
|
+
process.exit(code);
|
|
227
|
+
}
|