howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
@@ -1,437 +0,0 @@
1
- /**
2
- * Turn checkpoints: a snapshot of the working tree per turn, so a run can be
3
- * rolled back after the fact, and a picker can jump to any earlier turn.
4
- *
5
- * The snapshot lives in a **shadow git repository**, not in the user's repo and
6
- * not as a stash object. Three reasons, all of which the references share
7
- * (Claude Code's checkpoints "live beside your git history, not inside it";
8
- * the same choice is documented upstream):
9
- *
10
- * 1. the user's repository gets zero extra refs, objects or history entries;
11
- * 2. the user's index and HEAD are never touched, so a restore cannot move
12
- * HEAD or discard staged work halfway through a turn;
13
- * 3. it also works when the project is not a git repository at all.
14
- *
15
- * The shadow repo is a `GIT_DIR` with `GIT_WORK_TREE` pointed at the project, so
16
- * `git add -A` / `write-tree` hash the real files without the real repo knowing.
17
- * `-A` respects `.gitignore`, so `node_modules` and build output are never copied.
18
- *
19
- * A checkpoint is a tree sha plus a commit that keeps it reachable under
20
- * `refs/howtofix/<id>`; without the ref, `git gc` could collect a tree nothing
21
- * points at. Restore is `read-tree --reset -u` into the worktree plus `clean -fd`
22
- * — note the missing `-x`: ignored files must survive a rewind.
23
- */
24
- import { execFile, execFileSync } from "node:child_process";
25
- import { promisify } from "node:util";
26
- import { createHash } from "node:crypto";
27
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
28
- import { dirname, join, resolve } from "node:path";
29
- import { AGENT_DIR } from "./paths.js";
30
- import { writeFileAtomicSync } from "./fs-atomic.js";
31
- /** Where every project's shadow repo lives: `~/.howtofix/agent/rewind/<key>`. */
32
- const REWIND_DIR = join(AGENT_DIR, "rewind");
33
- /** Ref namespace inside the shadow repo. Also its initial branch name. */
34
- const REF_PREFIX = "refs/howtofix";
35
- /** Claude Code's own cap; more than this in one session is a memory, not a history. */
36
- const MAX_PER_SESSION = 100;
37
- /**
38
- * Sessions whose newest checkpoint is older than this are swept from the store.
39
- *
40
- * The per-session cap bounds one session, not the store: every `/new` starts a
41
- * session id, and only a session *swap* drops the old one (`dropSession` in the
42
- * workbench). Quitting between sessions leaves their checkpoints — and the
43
- * shadow-repo objects their refs pin — on disk forever, so `~/.howtofix/agent/rewind`
44
- * only ever grows. Claude Code deletes a session's snapshots in a retention
45
- * sweep ~30 days after it last saved one (`cleanupPeriodDays`), and the
46
- * community shadow-git extensions do the same; 30 days is that precedent.
47
- */
48
- export const RETENTION_DAYS = 30;
49
- /**
50
- * Records the project path a store belongs to, because the directory key is a
51
- * one-way hash: without it the sweep cannot tell a deleted project from a live
52
- * one. Stores written before the marker existed age out on their own.
53
- */
54
- const PROJECT_MARKER = "project.json";
55
- /** Per-project metadata file inside the store directory. */
56
- const META_NAME = "checkpoints.json";
57
- /** The whole-directory sweep runs once per process, at the first `openStore`. */
58
- let rewindStoresSwept = false;
59
- /**
60
- * True when `root` sits inside a git work tree.
61
- *
62
- * Walks up looking for a `.git` entry — a directory in a normal clone, a file in
63
- * a worktree or submodule — so this costs a couple of `stat` calls instead of a
64
- * process spawn. A snapshot of a directory that is not a repository is not worth
65
- * taking: there is no "uncommitted work" to go back to, and the walk still costs
66
- * seconds on a large tree (measured 1.25s on a 60k-file directory, added to every
67
- * single prompt).
68
- */
69
- function insideWorkTree(root) {
70
- let dir = resolve(root);
71
- for (;;) {
72
- if (existsSync(join(dir, ".git")))
73
- return true;
74
- const parent = dirname(dir);
75
- if (parent === dir)
76
- return false;
77
- dir = parent;
78
- }
79
- }
80
- const execFileAsync = promisify(execFile);
81
- /**
82
- * `shadowGit` without blocking the event loop.
83
- *
84
- * `add -A` and `write-tree` walk the entire worktree, and a plain `execFileSync`
85
- * froze the UI for the duration: measured at 4.0s on a 180k-file tree, with the
86
- * screen not even repainting the prompt first, because the call happens in the
87
- * Enter handler. Async keeps the spinner turning and Ctrl+C alive while git
88
- * works. Only the two heavy calls use it; the cheap ones stay synchronous.
89
- */
90
- async function shadowGitAsync(store, args) {
91
- const env = { ...process.env };
92
- for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_COMMON_DIR"])
93
- delete env[key];
94
- env.GIT_DIR = store.gitDir;
95
- env.GIT_WORK_TREE = store.root;
96
- env.GIT_AUTHOR_NAME = env.GIT_COMMITTER_NAME = "howtofix";
97
- env.GIT_AUTHOR_EMAIL = env.GIT_COMMITTER_EMAIL = "howtofix@local";
98
- const { stdout } = await execFileAsync("git", args, { cwd: store.root, env, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
99
- return stdout.trim();
100
- }
101
- /** Stable per-project key, so two checkouts of one repo do not share snapshots. */
102
- function projectKey(root) {
103
- return createHash("sha1").update(root).digest("hex").slice(0, 16);
104
- }
105
- /** Open (creating on first use) the shadow repo for `root`. Idempotent. */
106
- export function openStore(root) {
107
- const absolute = resolve(root).replace(/\/+$/, "") || "/";
108
- const projectDir = join(REWIND_DIR, projectKey(absolute));
109
- const gitDir = join(projectDir, "git");
110
- const store = { root: absolute, gitDir, metaFile: join(projectDir, META_NAME), disabled: false };
111
- // The shadow repo is created once per directory and remembered, so the
112
- // "is this a repository" walk runs on the first prompt only.
113
- if (!existsSync(join(gitDir, "HEAD")) && !insideWorkTree(absolute)) {
114
- store.disabled = true;
115
- return store;
116
- }
117
- mkdirSync(projectDir, { recursive: true });
118
- if (!existsSync(join(gitDir, "HEAD"))) {
119
- mkdirSync(gitDir, { recursive: true });
120
- // `git init <path>` treats the argument as a work tree and would create
121
- // `<path>/.git`; the repo is initialised through GIT_DIR instead.
122
- execFileSync("git", ["init", "--quiet", "--initial-branch=howtofix"], { env: { ...process.env, GIT_DIR: gitDir }, stdio: ["ignore", "ignore", "pipe"] });
123
- }
124
- adoptExcludes(absolute, gitDir);
125
- writeProjectMarker(projectDir, absolute);
126
- // First store opened in this process: drop the stores of projects that are
127
- // gone or long untouched, never this one — `keep` covers the store whose
128
- // project obviously exists (and any `--continue` of an old session in it).
129
- if (!rewindStoresSwept) {
130
- rewindStoresSwept = true;
131
- sweepRewindStores({ keep: projectDir });
132
- }
133
- return store;
134
- }
135
- /**
136
- * Copy the project's `.git/info/exclude` into the shadow repo, so paths the user
137
- * deliberately keeps out of git are not snapshotted — and therefore are not
138
- * deleted by the `clean -fd` half of a restore.
139
- */
140
- function adoptExcludes(root, gitDir) {
141
- const real = join(root, ".git", "info", "exclude");
142
- try {
143
- if (!statSync(real).isFile())
144
- return;
145
- mkdirSync(join(gitDir, "info"), { recursive: true });
146
- copyFileSync(real, join(gitDir, "info", "exclude"));
147
- }
148
- catch {
149
- // No real repository, or nothing to adopt.
150
- }
151
- }
152
- /**
153
- * Remember which project a store belongs to, so `sweepRewindStores` can tell a
154
- * deleted project from a live one despite the one-way directory key. A marker
155
- * that cannot be written only costs that detection — the age check still applies.
156
- */
157
- function writeProjectMarker(projectDir, root) {
158
- const marker = join(projectDir, PROJECT_MARKER);
159
- const payload = `${JSON.stringify({ version: 1, root })}\n`;
160
- try {
161
- if (readFileSync(marker, "utf8") === payload)
162
- return;
163
- }
164
- catch {
165
- // Missing or unreadable marker: fall through and write it.
166
- }
167
- try {
168
- writeFileAtomicSync(marker, payload);
169
- }
170
- catch {
171
- // Left markerless rather than failing the open; the age check still applies.
172
- }
173
- }
174
- function readMeta(store) {
175
- try {
176
- const parsed = JSON.parse(readFileSync(store.metaFile, "utf8"));
177
- if (parsed?.version === 1 && parsed.sessions)
178
- return parsed;
179
- }
180
- catch {
181
- // Missing or unreadable: start over. The shadow repo keeps its objects, and
182
- // the next gc collects whatever nothing references any more.
183
- }
184
- return { version: 1, sessions: {} };
185
- }
186
- function writeMeta(store, meta) {
187
- mkdirSync(dirname(store.metaFile), { recursive: true });
188
- writeFileAtomicSync(store.metaFile, `${JSON.stringify(meta, null, 1)}\n`);
189
- }
190
- /** This session's checkpoints, oldest first. */
191
- export function listCheckpoints(store, sessionId) {
192
- if (store.disabled)
193
- return [];
194
- return [...(readMeta(store).sessions[sessionId] ?? [])].sort((a, b) => a.at - b.at);
195
- }
196
- /**
197
- * Snapshot the worktree and record it as this turn's checkpoint.
198
- *
199
- * A tree identical to the previous checkpoint reuses its commit: a read-only
200
- * turn costs a metadata row and no new object.
201
- *
202
- * Async because the two tree-walking calls are the slow part and this runs in the
203
- * Enter handler; see `shadowGitAsync`.
204
- */
205
- export async function createCheckpoint(store, sessionId, prompt) {
206
- if (store.disabled)
207
- return null;
208
- let tree;
209
- try {
210
- // `-A` stages modifications, deletions and new files; .gitignore still applies.
211
- await shadowGitAsync(store, ["add", "-A", "--ignore-errors", "--"]);
212
- tree = await shadowGitAsync(store, ["write-tree"]);
213
- }
214
- catch {
215
- // An unreadable or missing worktree is not worth failing a prompt over: the
216
- // turn simply has no checkpoint, and the picker will not offer to rewind it.
217
- return null;
218
- }
219
- const existing = listCheckpoints(store, sessionId);
220
- const reusable = existing.find((checkpoint) => checkpoint.tree === tree);
221
- const at = Date.now();
222
- const id = `${at.toString(36)}-${createHash("sha1").update(`${tree}:${at}`).digest("hex").slice(0, 6)}`;
223
- let commit = reusable?.commit;
224
- if (!commit) {
225
- const message = ["howtofix checkpoint", `session ${sessionId}`, `created ${new Date(at).toISOString()}`, `prompt ${prompt.slice(0, 200)}`].join("\n");
226
- commit = await shadowGitAsync(store, ["commit-tree", tree, "-m", message]);
227
- await shadowGitAsync(store, ["update-ref", `${REF_PREFIX}/${id}`, commit]);
228
- }
229
- const checkpoint = { id, tree, commit, prompt, at };
230
- const meta = readMeta(store);
231
- const list = [...(meta.sessions[sessionId] ?? []), checkpoint];
232
- // Oldest first out. Their refs are dropped so the objects become collectable.
233
- for (const victim of list.slice(0, Math.max(0, list.length - MAX_PER_SESSION))) {
234
- try {
235
- await shadowGitAsync(store, ["update-ref", "-d", `${REF_PREFIX}/${victim.id}`]);
236
- }
237
- catch {
238
- // A ref that is already gone is the outcome we wanted.
239
- }
240
- }
241
- meta.sessions[sessionId] = list.slice(-MAX_PER_SESSION);
242
- writeMeta(store, meta);
243
- return checkpoint;
244
- }
245
- /**
246
- * Put the worktree back to a checkpoint.
247
- *
248
- * Only the working tree changes. `read-tree --reset -u` restores tracked content
249
- * to that tree, then `clean -fd` removes files the checkpoint did not have —
250
- * without `-x`, so ignored files (build output, `node_modules`) are left alone.
251
- * Newer checkpoints stay in the store, so a rewind is itself undoable by picking
252
- * a later entry.
253
- */
254
- export async function restoreCheckpoint(store, checkpoint) {
255
- const removed = [];
256
- if (store.disabled)
257
- return { ok: false, output: "checkpoints are off: this directory is not a git repository", removed };
258
- try {
259
- await shadowGitAsync(store, ["read-tree", "--reset", "-u", checkpoint.tree]);
260
- }
261
- catch (error) {
262
- return { ok: false, output: error instanceof Error ? error.message : String(error), removed };
263
- }
264
- try {
265
- const cleaned = await shadowGitAsync(store, ["clean", "-fd"]);
266
- for (const line of cleaned.split("\n")) {
267
- const match = /^Removing\s+(.+)$/.exec(line.trim());
268
- if (match?.[1])
269
- removed.push(match[1]);
270
- }
271
- }
272
- catch {
273
- // A failed clean leaves files behind; the tree is still restored.
274
- }
275
- return { ok: true, output: `restored ${checkpoint.prompt || "the checkpoint"}`, removed };
276
- }
277
- /** How a checkpoint differs from what is on disk right now, one line per file. */
278
- export async function checkpointDiff(store, checkpoint) {
279
- if (store.disabled)
280
- return [];
281
- try {
282
- // The worktree is hashed into the shadow index first, so the comparison is
283
- // between two trees rather than between a tree and a stale index.
284
- await shadowGitAsync(store, ["add", "-A", "--ignore-errors", "--"]);
285
- const current = await shadowGitAsync(store, ["write-tree"]);
286
- if (current === checkpoint.tree)
287
- return [];
288
- const listed = await shadowGitAsync(store, ["diff-tree", "-r", "--name-status", "--no-commit-id", checkpoint.tree, current]);
289
- return listed
290
- .split("\n")
291
- .filter(Boolean)
292
- .map((line) => {
293
- const [status, ...rest] = line.split("\t");
294
- return `${status?.[0] ?? "?"} ${rest.at(-1) ?? ""}`;
295
- });
296
- }
297
- catch {
298
- return [];
299
- }
300
- }
301
- /**
302
- * Drop sessions whose newest checkpoint is older than the retention window,
303
- * then let git collect the objects their refs made unreachable.
304
- *
305
- * `keep` names the session to leave alone whatever its age: a long-lived
306
- * session's last snapshot can predate the window, and sweeping the session the
307
- * user is currently in would offer a rewind menu with nothing behind it.
308
- *
309
- * The sweep only ever makes the store smaller: a ref that is already gone, a
310
- * meta row that is already absent and a failed `git gc` all leave the store
311
- * correct, just less reclaimed. It is housekeeping, so it never throws.
312
- */
313
- export async function sweepExpired(store, options = {}) {
314
- const result = { droppedSessions: 0, droppedRefs: 0 };
315
- if (store.disabled)
316
- return result;
317
- const cutoff = (options.now ?? Date.now()) - RETENTION_DAYS * 24 * 60 * 60 * 1000;
318
- const meta = readMeta(store);
319
- let changed = false;
320
- for (const [sessionId, list] of Object.entries(meta.sessions)) {
321
- if (sessionId === options.keep)
322
- continue;
323
- const newest = list.reduce((latest, checkpoint) => Math.max(latest, checkpoint.at), 0);
324
- if (newest >= cutoff)
325
- continue;
326
- delete meta.sessions[sessionId];
327
- changed = true;
328
- result.droppedSessions += 1;
329
- for (const checkpoint of list) {
330
- try {
331
- await shadowGitAsync(store, ["update-ref", "-d", `${REF_PREFIX}/${checkpoint.id}`]);
332
- result.droppedRefs += 1;
333
- }
334
- catch {
335
- // A ref that is already gone is the outcome we wanted.
336
- }
337
- }
338
- }
339
- if (!changed)
340
- return result;
341
- writeMeta(store, meta);
342
- // Without this the deleted refs only mark objects collectable; `--prune=now`
343
- // reclaims the space in the same pass that promised to reclaim it.
344
- try {
345
- await shadowGitAsync(store, ["gc", "--quiet", "--prune=now"]);
346
- }
347
- catch {
348
- // Unreachable objects wait for the next sweep that collects anything.
349
- }
350
- return result;
351
- }
352
- /**
353
- * Drop store directories nothing can need any more: projects that no longer
354
- * exist, and projects whose store has not been touched for the retention
355
- * window. The per-project sweep inside `sweepExpired` bounds sessions *within*
356
- * a store; without this the store of a deleted project — shadow repo, refs and
357
- * objects included — stays on disk forever, because the directory key is a
358
- * one-way hash and nothing ever maps it back to a path that is gone.
359
- *
360
- * `keep` is the store being opened right now, skipped whatever its age. A
361
- * store without a `project.json` marker (written before this existed) is aged
362
- * out on its own signal alone: it cannot be blamed on a missing project.
363
- *
364
- * Housekeeping, like `sweepExpired`: failures are recorded and the sweep moves
365
- * on, and it never throws.
366
- */
367
- export function sweepRewindStores(options = {}) {
368
- const result = { removedDirs: 0, failed: [] };
369
- let entries;
370
- try {
371
- entries = readdirSync(REWIND_DIR, { withFileTypes: true });
372
- }
373
- catch {
374
- return result; // No store directory yet.
375
- }
376
- const cutoff = (options.now ?? Date.now()) - RETENTION_DAYS * 24 * 60 * 60 * 1000;
377
- const keep = options.keep ? resolve(options.keep) : undefined;
378
- for (const entry of entries) {
379
- if (!entry.isDirectory())
380
- continue;
381
- const dir = join(REWIND_DIR, entry.name);
382
- if (resolve(dir) === keep)
383
- continue;
384
- try {
385
- if (projectGone(dir) || lastTouched(dir) < cutoff) {
386
- rmSync(dir, { recursive: true, force: true });
387
- result.removedDirs += 1;
388
- }
389
- }
390
- catch {
391
- // A directory that cannot be read or removed stays; the next sweep retries it.
392
- result.failed.push(entry.name);
393
- }
394
- }
395
- return result;
396
- }
397
- /** True when the marker exists, names a project, and that path no longer exists. */
398
- function projectGone(dir) {
399
- try {
400
- const parsed = JSON.parse(readFileSync(join(dir, PROJECT_MARKER), "utf8"));
401
- if (typeof parsed?.root !== "string" || parsed.root.length === 0)
402
- return false;
403
- return !existsSync(parsed.root);
404
- }
405
- catch {
406
- return false; // No marker or unreadable: the age check is the only signal.
407
- }
408
- }
409
- /**
410
- * Newest sign of use: the meta file every checkpoint and session sweep
411
- * rewrites, falling back to the directory itself (creation, or any entry
412
- * added or removed inside it).
413
- */
414
- function lastTouched(dir) {
415
- try {
416
- return statSync(join(dir, META_NAME)).mtimeMs;
417
- }
418
- catch {
419
- return statSync(dir).mtimeMs;
420
- }
421
- }
422
- /** Forget a session's checkpoints (used when a session is replaced). */
423
- export async function dropSession(store, sessionId) {
424
- if (store.disabled)
425
- return;
426
- const meta = readMeta(store);
427
- for (const checkpoint of meta.sessions[sessionId] ?? []) {
428
- try {
429
- await shadowGitAsync(store, ["update-ref", "-d", `${REF_PREFIX}/${checkpoint.id}`]);
430
- }
431
- catch {
432
- // Already gone.
433
- }
434
- }
435
- delete meta.sessions[sessionId];
436
- writeMeta(store, meta);
437
- }
package/dist/claims.js DELETED
@@ -1,167 +0,0 @@
1
- /**
2
- * File claims shared across sessions.
3
- *
4
- * Two agents that write the same file produce interleaved edits that neither their
5
- * parent nor the user can attribute. Worktree isolation removes that when it applies,
6
- * but it only covers agents inside one call, and only when the project is a git
7
- * repository — two CLIs open in the same folder, or a run in a plain directory, still
8
- * race. A claim is the other half: a writer says which paths it is about to touch, and
9
- * a second writer is refused instead of silently overwriting.
10
- *
11
- * File-based on purpose. A claim has to work for processes that never talk to each
12
- * other — different terminals, different users, a CLI and a script — so it cannot live
13
- * in one process's memory, and a broker would need something running. One JSON file in
14
- * the project is the smallest thing that all of them can agree on.
15
- *
16
- * The failure mode this guards against is a **lost edit**, so the defaults lean the
17
- * other way:
18
- *
19
- * - A conflicting claim is refused, not queued. Refusing is recoverable (the caller
20
- * retries, or picks another file); an overwrite is not.
21
- * - Every failure to read the ledger reads as "unclaimed". A corrupt file must not
22
- * block work forever, and the caller it would wrongly block is the one that cannot
23
- * see why.
24
- * - Claims expire. A process that is killed cannot release what it held, and a stale
25
- * claim nobody can clear would lock a path permanently.
26
- */
27
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
29
- /** How long a claim is honoured without being refreshed. */
30
- export const CLAIM_TTL_MS = 30 * 60 * 1000;
31
- /** Where the ledger lives, relative to the workspace root. */
32
- export const CLAIMS_FILE = join(".howtofix", "claims.json");
33
- function ledgerPath(root) {
34
- return join(root, CLAIMS_FILE);
35
- }
36
- /**
37
- * Read the ledger, treating anything unreadable as empty.
38
- *
39
- * A ledger that cannot be parsed must not stop work: the alternative is a project
40
- * where a truncated file blocks every writer and the error names no file to fix.
41
- */
42
- function read(root, now) {
43
- try {
44
- const raw = readFileSync(ledgerPath(root), "utf8");
45
- const parsed = JSON.parse(raw);
46
- const claims = Array.isArray(parsed.claims) ? [...parsed.claims] : [];
47
- return claims.filter((c) => typeof c?.path === "string" &&
48
- typeof c?.holder === "string" &&
49
- typeof c?.at === "number" &&
50
- // Expiry is applied on read rather than by a sweeper, so a dead
51
- // process needs nothing to clean up after it.
52
- now - c.at < CLAIM_TTL_MS);
53
- }
54
- catch {
55
- return [];
56
- }
57
- }
58
- /**
59
- * Write the ledger through a temporary file.
60
- *
61
- * Two processes claiming at the same time is the normal case, not an edge one, and a
62
- * partial write would be read back as "no claims" by the next caller — the exact
63
- * silent failure this module exists to prevent.
64
- */
65
- function write(root, claims) {
66
- const target = ledgerPath(root);
67
- mkdirSync(dirname(target), { recursive: true });
68
- const tmp = `${target}.${process.pid}.tmp`;
69
- writeFileSync(tmp, JSON.stringify({ version: 1, claims: [...claims] }, null, 2));
70
- renameSync(tmp, target);
71
- }
72
- /** Resolve a caller-supplied path against the workspace root. */
73
- function absolute(root, path) {
74
- return isAbsolute(path) ? resolve(path) : resolve(root, path);
75
- }
76
- /**
77
- * True when the two paths overlap.
78
- *
79
- * A directory claim covers everything beneath it, and a claim on a file covers that
80
- * file. The check is on path segments, not string prefixes: `src/auth` must not claim
81
- * `src/authentication`, which a `startsWith` would.
82
- */
83
- export function overlaps(a, b) {
84
- if (a === b)
85
- return true;
86
- const withSep = (p) => (p.endsWith(sep) ? p : p + sep);
87
- return a.startsWith(withSep(b)) || b.startsWith(withSep(a));
88
- }
89
- /** Claims that are live now, expired ones already dropped. */
90
- export function activeClaims(root, now = Date.now()) {
91
- return read(root, now);
92
- }
93
- /**
94
- * Claim paths for one holder, refusing the ones already held by someone else.
95
- *
96
- * Returns what was granted and what was refused rather than throwing: a caller that
97
- * is refused one file out of three should still learn which two it can work on, and a
98
- * thrown error loses that.
99
- *
100
- * Claiming a path this holder already holds is a renewal, not a conflict — a long run
101
- * that re-states its files must not be refused by its own claim.
102
- */
103
- export function claim(request) {
104
- const now = request.now ?? Date.now();
105
- const existing = read(request.root, now);
106
- const granted = [];
107
- const conflicts = [];
108
- const added = [];
109
- for (const raw of request.paths) {
110
- if (!raw.trim())
111
- continue;
112
- const path = absolute(request.root, raw.trim());
113
- const rival = existing.find((c) => c.holder !== request.holder && overlaps(c.path, path));
114
- if (rival) {
115
- conflicts.push({
116
- path: display(request.root, path),
117
- by: rival.holder,
118
- reason: rival.reason || "no reason given",
119
- });
120
- continue;
121
- }
122
- granted.push(display(request.root, path));
123
- added.push({ path, holder: request.holder, reason: request.reason, at: now });
124
- }
125
- if (added.length > 0) {
126
- // A renewal replaces the holder's earlier entry for the same path so the file
127
- // does not grow without bound across a long session.
128
- const kept = existing.filter((c) => c.holder !== request.holder || !added.some((a) => a.path === c.path));
129
- write(request.root, [...kept, ...added]);
130
- }
131
- return { granted, conflicts };
132
- }
133
- /** Release a holder's claims, optionally only a subset. */
134
- export function release(root, holder, paths) {
135
- const now = Date.now();
136
- const existing = read(root, now);
137
- const wanted = paths?.length ? paths.map((p) => absolute(root, p.trim())).filter(Boolean) : undefined;
138
- const kept = [];
139
- const freed = [];
140
- for (const c of existing) {
141
- const mine = c.holder === holder;
142
- const selected = wanted === undefined || wanted.some((p) => overlaps(c.path, p));
143
- if (mine && selected)
144
- freed.push(display(root, c.path));
145
- else
146
- kept.push(c);
147
- }
148
- write(root, kept);
149
- return freed;
150
- }
151
- /** The holder's live claims, for a UI line or a "what am I holding" answer. */
152
- export function heldBy(root, holder, now = Date.now()) {
153
- return read(root, now).filter((c) => c.holder === holder);
154
- }
155
- /** Every live claim, for the who-is-doing-what view. */
156
- export function allClaims(root, now = Date.now()) {
157
- return read(root, now);
158
- }
159
- /** Show a path the way the caller wrote it, so a refusal names a recognisable file. */
160
- function display(root, path) {
161
- const rel = relative(root, path);
162
- return rel && !rel.startsWith("..") ? rel : path;
163
- }
164
- /** True when a project root is usable — the ledger is only written under an existing directory. */
165
- export function canClaimAt(root) {
166
- return existsSync(root);
167
- }