runlist 0.0.0 → 0.94.2
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/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
|
+
import { runLint } from './lint.mjs';
|
|
5
|
+
import { syncHubStatuses } from './sync-status.mjs';
|
|
6
|
+
import { fixMembershipBackrefs } from './fix-membership.mjs';
|
|
7
|
+
import { runSet, runTouch } from './lifecycle.mjs';
|
|
8
|
+
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
9
|
+
import { writeRenderedIndex } from './index-file.mjs';
|
|
10
|
+
import { renderCheck, renderManualFixes } from './render.mjs';
|
|
11
|
+
import { bold, dim, green, red, yellow } from './color.mjs';
|
|
12
|
+
import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
|
|
13
|
+
import { checkSkillDrift } from './skill-drift.mjs';
|
|
14
|
+
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
15
|
+
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
16
|
+
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
17
|
+
import { normalizeEol } from './frontmatter.mjs';
|
|
18
|
+
import { die, runlistVersion, relTime, toRepoPath } from './util.mjs';
|
|
19
|
+
import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
|
|
20
|
+
import { describeSessionIdentity, opencodeStatus } from './host-integration.mjs';
|
|
21
|
+
import { availableSessionId, releaseVanishedPlanClaim, surveyOwnershipClaims } from './pickup.mjs';
|
|
22
|
+
|
|
23
|
+
// Tunable thresholds for `runlist doctor --statuses` conflation detection.
|
|
24
|
+
// MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
|
|
25
|
+
// CUE_FLOOR_PCT: a target cue must claim at least this fraction of the bucket to be suggested.
|
|
26
|
+
// A bucket is overloaded only when ≥2 distinct target cues each clear the floor.
|
|
27
|
+
const MIN_BUCKET_SIZE = 10;
|
|
28
|
+
const CUE_FLOOR_PCT = 0.15;
|
|
29
|
+
|
|
30
|
+
// Cue patterns map keyword groups to candidate target statuses.
|
|
31
|
+
// A doc is scored by counting regex hits in its current_state + next_step text;
|
|
32
|
+
// the highest-scoring cue (if any) becomes its suggested bucket. Ties broken by
|
|
33
|
+
// the iteration order below (deterministic). Patterns are intentionally simple
|
|
34
|
+
// and tunable — false positives are fine, false confidence is not.
|
|
35
|
+
const CUE_PATTERNS = {
|
|
36
|
+
partial: /\b(shipped|landed|merged|complete|tail|deferred|follow[- ]?up|left[- ]?over|remaining)\b/i,
|
|
37
|
+
paused: /\b(paused?|on hold|set aside|park(?:ed|ing)?|shelv(?:ed|ing)?|frozen|hibernat)/i,
|
|
38
|
+
'queued-after': /\b(after|once|when|depends on|behind|sequenced|wait(?:ing)? for [a-z\- ]+ to (?:ship|land|merge))\b/i,
|
|
39
|
+
awaiting: /\b(awaiting|need(?:s)? (?:input|decision|approval|sign[- ]?off)|pending (?:review|approval)|asked? (?:for|about))\b/i,
|
|
40
|
+
blocked: /\b(hardware|vendor|third[- ]?party|firmware|delivery|arrival|rollout)\b/i,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Human-readable cue lists for the suggestion table.
|
|
44
|
+
const CUE_LABELS = {
|
|
45
|
+
partial: '"shipped", "landed", "tail", "deferred"',
|
|
46
|
+
paused: '"paused", "on hold", "set aside"',
|
|
47
|
+
'queued-after': '"after", "once", "depends on", "waiting on <plan>"',
|
|
48
|
+
awaiting: '"awaiting", "needs decision", "pending review"',
|
|
49
|
+
blocked: '"hardware", "vendor", "third-party", "rollout"',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// A wedged repo reports here. One failed-manual manifest makes every `set`,
|
|
53
|
+
// `archive`, `use`, `baton`, and `rename` in the repo refuse — and the error
|
|
54
|
+
// names a file the command never touched, because recovery sweeps the whole
|
|
55
|
+
// transaction root. This is the only surface that can see and clear that state.
|
|
56
|
+
function runDoctorTransactions(argv, config, opts = {}) {
|
|
57
|
+
const json = argv.includes('--json');
|
|
58
|
+
// The dispatcher strips --apply/--yes and folds them into opts.dryRun, which
|
|
59
|
+
// for doctor already means "preview unless the user asked to write".
|
|
60
|
+
const apply = !opts.dryRun;
|
|
61
|
+
const report = inspectTransactions(config.repoRoot);
|
|
62
|
+
|
|
63
|
+
const cleared = apply ? resolveTransactions(config.repoRoot) : [];
|
|
64
|
+
const clearedIds = new Set(cleared.map(item => item.id));
|
|
65
|
+
|
|
66
|
+
if (json) {
|
|
67
|
+
process.stdout.write(JSON.stringify({ transactions: report, cleared }, null, 2) + '\n');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (report.length === 0) {
|
|
72
|
+
process.stdout.write(green('✓') + ' No pending transactions — nothing is wedging mutations.\n');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.stdout.write(bold(`Transactions (${report.length})\n`));
|
|
77
|
+
for (const item of report) {
|
|
78
|
+
if (!item.readable) {
|
|
79
|
+
process.stdout.write(` ${yellow('?')} ${item.id} — ${item.reason}\n`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const blocking = item.status === 'failed-manual';
|
|
83
|
+
const mark = clearedIds.has(item.id) ? green('✓') : blocking ? yellow('!') : dim('·');
|
|
84
|
+
const note = clearedIds.has(item.id)
|
|
85
|
+
? 'cleared'
|
|
86
|
+
: item.resolvable ? 'resolvable' : (item.reason ?? item.status);
|
|
87
|
+
process.stdout.write(` ${mark} ${item.id} [${item.status}] ${item.operation} — owner ${item.ownerLiveness}, ${note}\n`);
|
|
88
|
+
for (const participant of item.participants) {
|
|
89
|
+
process.stdout.write(dim(` ${participant.state.padEnd(9)} ${toRepoPath(participant.path, config.repoRoot)}\n`));
|
|
90
|
+
}
|
|
91
|
+
for (const retained of item.retainedGitPaths) {
|
|
92
|
+
process.stdout.write(dim(` retained ${retained}\n`));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resolvable = report.filter(item => item.resolvable && !clearedIds.has(item.id));
|
|
97
|
+
if (cleared.length) {
|
|
98
|
+
process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'} whose files already agreed on one generation.\n`));
|
|
99
|
+
}
|
|
100
|
+
if (resolvable.length) {
|
|
101
|
+
process.stdout.write(`\n${resolvable.length} resolvable — the canonical files already agree on one generation.\n`);
|
|
102
|
+
process.stdout.write(dim('Run `runlist doctor --transactions --apply` to clear them (no document content is touched).\n'));
|
|
103
|
+
}
|
|
104
|
+
const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && (item.status === 'failed-manual' || !item.readable));
|
|
105
|
+
if (stuck.length) {
|
|
106
|
+
process.stdout.write(yellow(`\n${stuck.length} need manual review — generations disagree, so clearing them could lose work.\n`));
|
|
107
|
+
process.stdout.write(dim('Inspect the participant paths above against the artifacts in each transaction directory.\n'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseOlderThan(argv) {
|
|
112
|
+
const idx = argv.indexOf('--older-than');
|
|
113
|
+
if (idx === -1) return null;
|
|
114
|
+
const raw = argv[idx + 1];
|
|
115
|
+
const match = /^(\d+)([hd])$/.exec(raw ?? '');
|
|
116
|
+
if (!match) die('--older-than takes a duration like 24h or 3d');
|
|
117
|
+
return Number(match[1]) * (match[2] === 'h' ? 3600_000 : 86_400_000) ;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The counterpart to --transactions: a claim nobody will ever release wedges
|
|
121
|
+
// `set`, `archive`, `baton` and `rename` on that plan forever, and until now
|
|
122
|
+
// nothing in the tool could even show you the claims, let alone end one.
|
|
123
|
+
//
|
|
124
|
+
// Two tiers, because two very different things are being asked. A claim whose
|
|
125
|
+
// session process is provably gone is released by --apply on its own: that is
|
|
126
|
+
// runlist observing a fact, the same bar a forced hook-delivery takeover uses.
|
|
127
|
+
// A claim runlist *cannot* judge — written before it recorded the owning process,
|
|
128
|
+
// taken from a plain terminal, or held on another machine — is never released
|
|
129
|
+
// by a plain --apply, because "I can't see the owner" is not evidence the owner
|
|
130
|
+
// left. Releasing those needs --older-than, which is the user supplying the
|
|
131
|
+
// judgement runlist doesn't have, as a policy rather than a guess.
|
|
132
|
+
async function runDoctorClaims(argv, config, opts = {}) {
|
|
133
|
+
const json = argv.includes('--json');
|
|
134
|
+
const apply = !opts.dryRun;
|
|
135
|
+
const olderThanMs = parseOlderThan(argv);
|
|
136
|
+
const claims = surveyOwnershipClaims(config);
|
|
137
|
+
|
|
138
|
+
const dead = claims.filter(claim => !claim.corrupt && claim.liveness === 'dead');
|
|
139
|
+
const aged = olderThanMs === null ? [] : claims.filter(claim =>
|
|
140
|
+
!claim.corrupt && claim.liveness !== 'dead' && claim.ageMs !== null && claim.ageMs >= olderThanMs);
|
|
141
|
+
const targets = [...dead, ...aged];
|
|
142
|
+
|
|
143
|
+
const released = [];
|
|
144
|
+
// The repair runs under whatever identity the shell has, and a shell with none
|
|
145
|
+
// is a normal place to run it from — this is the command you reach for when the
|
|
146
|
+
// sessions are gone. Mirrors the synthetic id `set --dry-run` already uses.
|
|
147
|
+
const operator = availableSessionId() ?? 'doctor:claims-repair';
|
|
148
|
+
if (apply) {
|
|
149
|
+
for (const claim of targets) {
|
|
150
|
+
try {
|
|
151
|
+
// Two shapes of release, because a vanished plan has no file to write a
|
|
152
|
+
// status into. Deciding by existence here rather than by catching
|
|
153
|
+
// runSet's "File not found" keeps the bypass narrow and explicit.
|
|
154
|
+
if (existsSync(path.resolve(config.repoRoot, claim.plan))) {
|
|
155
|
+
await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `runlist doctor --claims` — the owning session was gone.' });
|
|
156
|
+
} else {
|
|
157
|
+
releaseVanishedPlanClaim(claim, config);
|
|
158
|
+
claim.vanished = true;
|
|
159
|
+
}
|
|
160
|
+
released.push(claim.plan);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
claim.error = err.message.split('\n')[0];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (json) {
|
|
168
|
+
process.stdout.write(JSON.stringify({ claims, released }, null, 2) + '\n');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (claims.length === 0) {
|
|
172
|
+
process.stdout.write(green('✓') + ' No plans are claimed — nothing can be wedged by ownership.\n');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const releasedSet = new Set(released);
|
|
177
|
+
process.stdout.write(bold(`Plan claims (${claims.length})\n`));
|
|
178
|
+
for (const claim of claims) {
|
|
179
|
+
if (claim.corrupt) {
|
|
180
|
+
process.stdout.write(` ${yellow('?')} ${path.basename(claim.recordPath)} — ${claim.reason}\n`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const mark = releasedSet.has(claim.plan) ? green('✓') : claim.liveness === 'dead' ? yellow('!') : dim('·');
|
|
184
|
+
const age = claim.since ? relTime(claim.since) : 'age unknown';
|
|
185
|
+
const note = releasedSet.has(claim.plan)
|
|
186
|
+
? (claim.vanished ? 'released (plan no longer exists)' : 'released')
|
|
187
|
+
: claim.error ?? `owner ${claim.liveness}`;
|
|
188
|
+
process.stdout.write(` ${mark} ${claim.plan} — ${age}, session ${claim.sessionId}, ${note}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (released.length) {
|
|
192
|
+
// Only the claims with a plan behind them came back as `active`; saying so
|
|
193
|
+
// of a vanished one would promise a file the next command cannot open.
|
|
194
|
+
const vanished = targets.filter(claim => claim.vanished && releasedSet.has(claim.plan)).length;
|
|
195
|
+
const revived = released.length - vanished;
|
|
196
|
+
const parts = [];
|
|
197
|
+
if (revived) parts.push(`${revived} plan${revived === 1 ? ' is' : 's are'} active again`);
|
|
198
|
+
if (vanished) parts.push(`${vanished} pinned a plan that no longer exists`);
|
|
199
|
+
process.stdout.write(green(`\n✓ Released ${released.length} claim${released.length === 1 ? '' : 's'}; ${parts.join(', ')}.\n`));
|
|
200
|
+
}
|
|
201
|
+
const pendingDead = dead.filter(claim => !releasedSet.has(claim.plan));
|
|
202
|
+
if (pendingDead.length) {
|
|
203
|
+
process.stdout.write(yellow(`\n${pendingDead.length} held by a session whose process is gone.\n`));
|
|
204
|
+
process.stdout.write(dim('Run `runlist doctor --claims --apply` to release them.\n'));
|
|
205
|
+
}
|
|
206
|
+
const unjudgeable = claims.filter(claim =>
|
|
207
|
+
!claim.corrupt && claim.liveness !== 'dead' && !releasedSet.has(claim.plan));
|
|
208
|
+
if (unjudgeable.length && olderThanMs === null) {
|
|
209
|
+
process.stdout.write(dim(`\n${unjudgeable.length} cannot be judged from here — no owning process was recorded, or it is on another machine.\n`));
|
|
210
|
+
process.stdout.write(dim('If you know those sessions are over: `runlist doctor --claims --apply --older-than 24h`.\n'));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Read-only: what session identity does runlist see, and is the current host's
|
|
215
|
+
// integration installed? Never writes — installing lives behind `runlist install`
|
|
216
|
+
// because it touches state outside the repo.
|
|
217
|
+
function runDoctorSession(argv) {
|
|
218
|
+
const identity = describeSessionIdentity({ version: runlistVersion() });
|
|
219
|
+
const oc = opencodeStatus({ version: runlistVersion() });
|
|
220
|
+
if (argv.includes('--json')) {
|
|
221
|
+
process.stdout.write(JSON.stringify({ identity, hosts: { opencode: oc } }, null, 2) + '\n');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
process.stdout.write(bold('Session identity\n'));
|
|
225
|
+
const mark = identity.scope === 'session' ? green('✓') : identity.id ? yellow('!') : red('✗');
|
|
226
|
+
process.stdout.write(` ${mark} ${identity.id ?? dim('none')}\n`);
|
|
227
|
+
process.stdout.write(` ${dim(identity.summary)}\n`);
|
|
228
|
+
for (const line of identity.advice) process.stdout.write(` → ${line}\n`);
|
|
229
|
+
|
|
230
|
+
process.stdout.write('\n' + bold('Host integration\n'));
|
|
231
|
+
if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a plugin file runlist did not write — ${oc.foreignPath}\n`);
|
|
232
|
+
else if (!oc.exists) process.stdout.write(` ${dim('·')} opencode: not installed — ${dim(oc.path)}\n`);
|
|
233
|
+
else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${runlistVersion()} — run \`runlist update\`)` : ''}\n`);
|
|
234
|
+
process.stdout.write(dim(' Claude Code ships as a plugin — `runlist install` reports both hosts.\n'));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function runDoctor(argv, config, opts = {}) {
|
|
238
|
+
if (argv.includes('--project')) {
|
|
239
|
+
runDoctorProject(config, { json: argv.includes('--json') });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (argv.includes('--statuses')) {
|
|
243
|
+
runDoctorStatuses(config, { json: argv.includes('--json') });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (argv.includes('--migrate-template')) {
|
|
247
|
+
runMigrateTemplate(argv, config, opts);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (argv.includes('--migrate-prompts')) {
|
|
251
|
+
runMigratePrompts(argv, config, opts);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (argv.includes('--frontmatter-fix')) {
|
|
255
|
+
runFrontmatterFix(config, opts);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (argv.includes('--transactions')) {
|
|
259
|
+
runDoctorTransactions(argv, config, opts);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (argv.includes('--claims')) {
|
|
263
|
+
return runDoctorClaims(argv, config, opts);
|
|
264
|
+
}
|
|
265
|
+
if (argv.includes('--session')) {
|
|
266
|
+
runDoctorSession(argv);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { dryRun, testHooks } = opts;
|
|
271
|
+
// 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
|
|
272
|
+
// for a real one — and tells the user the exact flag that flips it.
|
|
273
|
+
const modeNote = dryRun
|
|
274
|
+
? dim('[preview — run with --apply to write]')
|
|
275
|
+
: dim('[applying changes]');
|
|
276
|
+
process.stdout.write(bold('runlist doctor') + ' ' + modeNote + '\n\n');
|
|
277
|
+
if (dryRun) {
|
|
278
|
+
const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
|
|
279
|
+
.filter(name => typeof config.hooks?.[name] === 'function');
|
|
280
|
+
if (skippedHooks.length > 0) {
|
|
281
|
+
process.stdout.write(dim(`[preview] Custom ${skippedHooks.join(', ')} hook${skippedHooks.length === 1 ? '' : 's'} skipped; diagnostics and rendering below use built-in behavior only.\n\n`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Step 1: Fix broken references
|
|
286
|
+
process.stdout.write(bold('1. Fixing broken references...') + '\n');
|
|
287
|
+
fixBrokenRefs(config, { dryRun });
|
|
288
|
+
|
|
289
|
+
// Step 2: Add only child back-references already authorized by one
|
|
290
|
+
// unambiguous hub. Hub prose is never generated or edited here.
|
|
291
|
+
process.stdout.write('\n' + bold('2. Fixing membership back-references...') + '\n');
|
|
292
|
+
fixMembershipBackrefs(config, { docs: buildIndex(config).docs, dryRun, testHooks });
|
|
293
|
+
|
|
294
|
+
// Step 3: Lint --fix
|
|
295
|
+
process.stdout.write('\n' + bold('3. Fixing frontmatter issues...') + '\n');
|
|
296
|
+
runLint(['--fix'], config, { dryRun });
|
|
297
|
+
|
|
298
|
+
// Step 4: Move over-cap status prose into body sections.
|
|
299
|
+
process.stdout.write('\n' + bold('4. Fixing long frontmatter...') + '\n');
|
|
300
|
+
runFrontmatterFix(config, { dryRun });
|
|
301
|
+
|
|
302
|
+
// Step 5: Rewrite hub rows whose printed status drifted from the plan they
|
|
303
|
+
// link to. Tokens only — adding markers is a content edit to prose the user
|
|
304
|
+
// wrote, so it stays opt-in behind `runlist sync-status --adopt`.
|
|
305
|
+
process.stdout.write('\n' + bold('5. Syncing hub status rows...') + '\n');
|
|
306
|
+
const hubSync = syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun });
|
|
307
|
+
if (hubSync.fixed === 0 && hubSync.adopted === 0 && hubSync.unreadable === 0) {
|
|
308
|
+
process.stdout.write('Hub status rows are in sync.\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Step 6: Sync dates from git
|
|
312
|
+
process.stdout.write('\n' + bold('6. Syncing dates from git...') + '\n');
|
|
313
|
+
runTouch(['--git'], config, { dryRun });
|
|
314
|
+
|
|
315
|
+
// Step 7: Regenerate index. Heading always prints so numbering remains
|
|
316
|
+
// contiguous even when `index.path` isn't configured.
|
|
317
|
+
process.stdout.write('\n' + bold('7. Regenerating index...') + '\n');
|
|
318
|
+
if (!config.indexPath) {
|
|
319
|
+
process.stdout.write('No index path configured (skip).\n');
|
|
320
|
+
} else if (dryRun) {
|
|
321
|
+
process.stdout.write('[dry-run] Would regenerate index.\n');
|
|
322
|
+
} else {
|
|
323
|
+
writeRenderedIndex(() => buildIndex(config, { fast: true }), config, { testHooks });
|
|
324
|
+
process.stdout.write('Index updated.\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Step 8: Clean up retired Claude Code command scaffolding. The per-repo
|
|
328
|
+
// `.claude/commands/{plans,docs}.md` files are superseded by the runlist plugin
|
|
329
|
+
// skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
|
|
330
|
+
// Always print the heading so the numbering remains contiguous.
|
|
331
|
+
process.stdout.write('\n' + bold('8. Claude Code commands:') + '\n');
|
|
332
|
+
if (dryRun) {
|
|
333
|
+
const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
|
|
334
|
+
if (wouldRemove.length === 0) {
|
|
335
|
+
process.stdout.write('[dry-run] No retired slash-command files to remove.\n');
|
|
336
|
+
} else {
|
|
337
|
+
for (const r of wouldRemove) {
|
|
338
|
+
process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the runlist plugin).\n`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
const removed = removeGeneratedSlashCommands(config.repoRoot);
|
|
343
|
+
if (removed.length === 0) {
|
|
344
|
+
process.stdout.write('Nothing to clean up.\n');
|
|
345
|
+
} else {
|
|
346
|
+
for (const r of removed) {
|
|
347
|
+
process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the runlist plugin)\n`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Step 9: Show remaining check
|
|
353
|
+
const issueLabel = dryRun ? '9. Remaining issues in current tree (preview fixes above were not applied):' : '9. Remaining issues:';
|
|
354
|
+
process.stdout.write('\n' + bold(issueLabel) + '\n');
|
|
355
|
+
const freshIndex = buildIndex(config);
|
|
356
|
+
process.stdout.write(renderCheck(freshIndex, config));
|
|
357
|
+
const manual = renderManualFixes(freshIndex);
|
|
358
|
+
if (manual.trim()) {
|
|
359
|
+
process.stdout.write('\n' + bold('Closeout guidance') + '\n');
|
|
360
|
+
process.stdout.write(manual);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Not a numbered step and never auto-fixed: installing touches state outside
|
|
364
|
+
// the repo, which is `runlist install`'s job. Doctor's part is making sure a
|
|
365
|
+
// degraded identity is something you're told about rather than something you
|
|
366
|
+
// find out when a verb fails. Silent when there is nothing to say.
|
|
367
|
+
const identity = describeSessionIdentity({ version: runlistVersion() });
|
|
368
|
+
if (identity.advice.length) {
|
|
369
|
+
process.stdout.write('\n' + bold('Session identity') + '\n');
|
|
370
|
+
process.stdout.write(`${yellow('!')} ${identity.summary}\n`);
|
|
371
|
+
for (const line of identity.advice) process.stdout.write(dim(` → ${line}\n`));
|
|
372
|
+
process.stdout.write(dim(' `runlist doctor --session` for the full picture.\n'));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function readJsonIfPresent(filePath) {
|
|
377
|
+
try {
|
|
378
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function findDeprecatedCommandMentions(config) {
|
|
385
|
+
const docs = collectDocFiles(config);
|
|
386
|
+
const matches = [];
|
|
387
|
+
for (const filePath of docs) {
|
|
388
|
+
let raw = '';
|
|
389
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
390
|
+
if (/\b(?:dotmd|runlist|rl) status\b/.test(raw) || /\b(?:dotmd|runlist|rl) (pickup|unpickup|release|finish)\b/.test(raw)) {
|
|
391
|
+
matches.push(toRepoPath(filePath, config.repoRoot));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return matches;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Workflow-drift checks: configurations and docs that make the agent-facing
|
|
398
|
+
// verbs (`use`, `set`, `baton`) blow up at the worst moment — mid-handoff.
|
|
399
|
+
// Both failure modes came from real sessions: a repo whose plan vocab dropped
|
|
400
|
+
// `in-session` (every `runlist use` died), and a repo full of docs without
|
|
401
|
+
// frontmatter blocks (every `runlist set` died during closeout).
|
|
402
|
+
function findWorkflowDrift(config) {
|
|
403
|
+
const docsWithoutFrontmatter = [];
|
|
404
|
+
for (const filePath of collectDocFiles(config)) {
|
|
405
|
+
let raw = '';
|
|
406
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
407
|
+
if (!normalizeEol(raw).startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const planStatusGaps = [];
|
|
411
|
+
const planStatuses = config.typeStatuses?.get('plan');
|
|
412
|
+
if (planStatuses && planStatuses.size > 0) {
|
|
413
|
+
for (const required of ['in-session', 'active']) {
|
|
414
|
+
if (!planStatuses.has(required)) planStatusGaps.push(required);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { docsWithoutFrontmatter, planStatusGaps };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function runDoctorProject(config, { json = false } = {}) {
|
|
422
|
+
const cliPackage = readJsonIfPresent(new URL('../package.json', import.meta.url));
|
|
423
|
+
const repoPackage = readJsonIfPresent(path.join(config.repoRoot, 'package.json'));
|
|
424
|
+
const depVersion = repoPackage?.dependencies?.runlist
|
|
425
|
+
?? repoPackage?.devDependencies?.runlist
|
|
426
|
+
?? repoPackage?.dependencies?.['dotmd-cli']
|
|
427
|
+
?? repoPackage?.devDependencies?.['dotmd-cli']
|
|
428
|
+
?? repoPackage?.dependencies?.dotmd
|
|
429
|
+
?? repoPackage?.devDependencies?.dotmd
|
|
430
|
+
?? null;
|
|
431
|
+
const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
|
|
432
|
+
const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
|
|
433
|
+
const { docsWithoutFrontmatter, planStatusGaps } = findWorkflowDrift(config);
|
|
434
|
+
const skillDriftWarnings = checkSkillDrift(config);
|
|
435
|
+
const result = {
|
|
436
|
+
cliVersion: cliPackage?.version ?? null,
|
|
437
|
+
packageDependency: depVersion,
|
|
438
|
+
claudeCommandWarnings,
|
|
439
|
+
deprecatedCommandMentions,
|
|
440
|
+
docsWithoutFrontmatter,
|
|
441
|
+
planStatusGaps,
|
|
442
|
+
skillDriftWarnings,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
if (json) {
|
|
446
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
process.stdout.write(bold('runlist doctor --project') + '\n\n');
|
|
451
|
+
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
452
|
+
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
453
|
+
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
|
454
|
+
if (deprecatedCommandMentions.length) {
|
|
455
|
+
process.stdout.write(`- docs mentioning deprecated commands: ${deprecatedCommandMentions.length}\n`);
|
|
456
|
+
for (const file of deprecatedCommandMentions.slice(0, 10)) process.stdout.write(` - ${file}\n`);
|
|
457
|
+
} else {
|
|
458
|
+
process.stdout.write('- docs mentioning deprecated commands: 0\n');
|
|
459
|
+
}
|
|
460
|
+
if (docsWithoutFrontmatter.length) {
|
|
461
|
+
process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: runlist bulk-tag <file> --type <type> --status <status>`) + '\n');
|
|
462
|
+
for (const file of docsWithoutFrontmatter.slice(0, 10)) process.stdout.write(` - ${file}\n`);
|
|
463
|
+
} else {
|
|
464
|
+
process.stdout.write('- docs without a frontmatter block: 0\n');
|
|
465
|
+
}
|
|
466
|
+
if (planStatusGaps.length) {
|
|
467
|
+
process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`runlist use\` and \`runlist baton\` depend on these; add them to types.plan.statuses in runlist.config.mjs`) + '\n');
|
|
468
|
+
} else {
|
|
469
|
+
process.stdout.write('- plan status vocab: ok\n');
|
|
470
|
+
}
|
|
471
|
+
if (skillDriftWarnings.length) {
|
|
472
|
+
process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`runlist:canonical-workflow\` markers in both files.`) + '\n');
|
|
473
|
+
} else {
|
|
474
|
+
process.stdout.write('- canonical workflow block: in sync\n');
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function analyzeStatusBuckets(docs) {
|
|
480
|
+
const buckets = new Map();
|
|
481
|
+
for (const doc of docs) {
|
|
482
|
+
if (!doc.status) continue;
|
|
483
|
+
const key = `${doc.type ?? 'unknown'}::${doc.status}`;
|
|
484
|
+
if (!buckets.has(key)) {
|
|
485
|
+
buckets.set(key, { type: doc.type ?? null, status: doc.status, docs: [] });
|
|
486
|
+
}
|
|
487
|
+
buckets.get(key).docs.push(doc);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const suggestions = [];
|
|
491
|
+
|
|
492
|
+
for (const bucket of buckets.values()) {
|
|
493
|
+
if (bucket.docs.length < MIN_BUCKET_SIZE) continue;
|
|
494
|
+
const floor = Math.max(1, Math.ceil(bucket.docs.length * CUE_FLOOR_PCT));
|
|
495
|
+
|
|
496
|
+
const targetCounts = {};
|
|
497
|
+
let unmatchedCount = 0;
|
|
498
|
+
|
|
499
|
+
for (const doc of bucket.docs) {
|
|
500
|
+
const text = `${doc.currentState ?? ''}\n${doc.nextStep ?? ''}`;
|
|
501
|
+
let bestCue = null;
|
|
502
|
+
let bestScore = 0;
|
|
503
|
+
|
|
504
|
+
for (const [cue, pattern] of Object.entries(CUE_PATTERNS)) {
|
|
505
|
+
if (cue === bucket.status) continue;
|
|
506
|
+
const globalPat = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
|
507
|
+
const matches = text.match(globalPat);
|
|
508
|
+
const score = matches ? matches.length : 0;
|
|
509
|
+
if (score > bestScore) {
|
|
510
|
+
bestScore = score;
|
|
511
|
+
bestCue = cue;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (bestCue == null) {
|
|
516
|
+
unmatchedCount++;
|
|
517
|
+
} else {
|
|
518
|
+
targetCounts[bestCue] = (targetCounts[bestCue] ?? 0) + 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const aboveFloor = Object.entries(targetCounts)
|
|
523
|
+
.filter(([, n]) => n >= floor)
|
|
524
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
525
|
+
|
|
526
|
+
if (aboveFloor.length < 2) continue;
|
|
527
|
+
|
|
528
|
+
const splitCount = aboveFloor.reduce((s, [, n]) => s + n, 0);
|
|
529
|
+
const kept = bucket.docs.length - splitCount;
|
|
530
|
+
|
|
531
|
+
suggestions.push({
|
|
532
|
+
type: bucket.type,
|
|
533
|
+
status: bucket.status,
|
|
534
|
+
total: bucket.docs.length,
|
|
535
|
+
splits: aboveFloor.map(([target, count]) => ({
|
|
536
|
+
target,
|
|
537
|
+
count,
|
|
538
|
+
cues: CUE_LABELS[target] ?? '',
|
|
539
|
+
})),
|
|
540
|
+
kept,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
suggestions.sort((a, b) => {
|
|
545
|
+
if ((a.type ?? '') !== (b.type ?? '')) return (a.type ?? '').localeCompare(b.type ?? '');
|
|
546
|
+
return a.status.localeCompare(b.status);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return suggestions;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function runDoctorStatuses(config, { json = false } = {}) {
|
|
553
|
+
const index = buildIndex(config);
|
|
554
|
+
const suggestions = analyzeStatusBuckets(index.docs);
|
|
555
|
+
|
|
556
|
+
if (json) {
|
|
557
|
+
process.stdout.write(JSON.stringify({
|
|
558
|
+
thresholds: { minBucketSize: MIN_BUCKET_SIZE, cueFloorPct: CUE_FLOOR_PCT },
|
|
559
|
+
suggestions,
|
|
560
|
+
}, null, 2) + '\n');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
process.stdout.write(bold('runlist doctor --statuses') + '\n\n');
|
|
565
|
+
|
|
566
|
+
if (suggestions.length === 0) {
|
|
567
|
+
process.stdout.write(`No overloaded status buckets detected (min bucket size: ${MIN_BUCKET_SIZE}).\n`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (const s of suggestions) {
|
|
572
|
+
const typeLabel = s.type ? `${s.type}/` : '';
|
|
573
|
+
const patternCount = s.splits.length + (s.kept > 0 ? 1 : 0);
|
|
574
|
+
process.stdout.write(
|
|
575
|
+
bold(`${s.total} ${typeLabel}${s.status} plans cluster across ${patternCount} patterns — consider splitting:`) + '\n'
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
const targetWidth = Math.max(...s.splits.map(x => x.target.length), 'kept'.length);
|
|
579
|
+
for (const split of s.splits) {
|
|
580
|
+
const target = green(split.target.padEnd(targetWidth));
|
|
581
|
+
process.stdout.write(` ~${String(split.count).padStart(3)} → ${target} (cues: ${split.cues})\n`);
|
|
582
|
+
}
|
|
583
|
+
if (s.kept > 0) {
|
|
584
|
+
const tail = dim(`(kept in ${s.status} — no clear pattern match)`);
|
|
585
|
+
process.stdout.write(` ~${String(s.kept).padStart(3)} → ${' '.repeat(targetWidth)} ${tail}\n`);
|
|
586
|
+
}
|
|
587
|
+
process.stdout.write('\n');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
process.stdout.write(yellow('Heuristic — verify before migrating.') + '\n');
|
|
591
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Retrying rename for publishing a prepared temp over a live path.
|
|
2
|
+
//
|
|
3
|
+
// Lives in its own leaf module rather than in atomic-mutation.mjs because
|
|
4
|
+
// atomic-mutation.mjs already imports git.mjs, and git.mjs needs this helper for
|
|
5
|
+
// its .git/index publish — exporting it from atomic-mutation.mjs would close an
|
|
6
|
+
// import cycle.
|
|
7
|
+
import { renameSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
|
|
11
|
+
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES'];
|
|
12
|
+
const RENAME_RETRY_ATTEMPTS = 10;
|
|
13
|
+
const RENAME_RETRY_BACKOFF_MS = 10;
|
|
14
|
+
|
|
15
|
+
// Total the backoff can sleep across a fully exhausted retry — the sum of
|
|
16
|
+
// RENAME_RETRY_BACKOFF_MS × (1 … attempts-1). Exported so the budget can be
|
|
17
|
+
// checked against MUTATION_LOCK_TIMEOUT_MS deterministically, rather than by
|
|
18
|
+
// timing a run on a shared CI runner.
|
|
19
|
+
export const RENAME_RETRY_SLEEP_BUDGET_MS =
|
|
20
|
+
(RENAME_RETRY_BACKOFF_MS * (RENAME_RETRY_ATTEMPTS - 1) * RENAME_RETRY_ATTEMPTS) / 2;
|
|
21
|
+
|
|
22
|
+
// Windows refuses to rename onto — or away from — a path another process holds
|
|
23
|
+
// open, surfacing EPERM/EBUSY/EACCES. That holder is by construction a
|
|
24
|
+
// non-cooperating one (editor, AV, Search Indexer, `git`): runlist's path lock is
|
|
25
|
+
// advisory and only excludes other runlist processes, so a retry here is never
|
|
26
|
+
// waiting on a peer we would have serialized against anyway. POSIX never fails a
|
|
27
|
+
// rename this way, so retrying there would mask a genuinely different fault.
|
|
28
|
+
//
|
|
29
|
+
// The budget is bounded by withPathLocks' timeoutMs (2000ms default): these
|
|
30
|
+
// renames run while the lock is held, so a longer budget would trade a rare
|
|
31
|
+
// transient EPERM for common peer MutationLockErrors. 10 attempts with a 10ms
|
|
32
|
+
// linear backoff is ~450ms of sleep — comfortable headroom under 2s. Do not
|
|
33
|
+
// raise it past ~1s without also raising timeoutMs.
|
|
34
|
+
export function commitRename(from, to, testHooks) {
|
|
35
|
+
// Single seam for the platform check so the retry is reachable in tests on the
|
|
36
|
+
// POSIX machines that actually run them.
|
|
37
|
+
const windowsRenameSemantics = testHooks?.forceWindowsRenameSemantics ?? process.platform === 'win32';
|
|
38
|
+
for (let attempt = 0; ; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const injected = testHooks?.forceRenameError?.(attempt);
|
|
41
|
+
if (injected) {
|
|
42
|
+
const error = new Error(`injected rename ${injected}`);
|
|
43
|
+
error.code = injected;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
renameSync(from, to);
|
|
47
|
+
return;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!windowsRenameSemantics) throw err;
|
|
50
|
+
if (!RENAME_RETRY_CODES.includes(err?.code)) throw err;
|
|
51
|
+
if (attempt >= RENAME_RETRY_ATTEMPTS - 1) throw err;
|
|
52
|
+
Atomics.wait(sleepBuffer, 0, 0, RENAME_RETRY_BACKOFF_MS * (attempt + 1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|