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.
- package/dist/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-7DTQKDO4.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-AUDMRGWF.js +189 -0
- package/dist/chunk-CPJKKHGH.js +1 -0
- package/dist/chunk-DAPXGSGD.js +12 -0
- package/dist/chunk-DOD7MUGX.js +1 -0
- package/dist/chunk-DV2BBBBJ.js +2 -0
- package/dist/chunk-EIBOS5KO.js +5 -0
- package/dist/chunk-EYWOVCSS.js +6 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-HT3MFLAV.js +34 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-JMKK26AT.js +2 -0
- package/dist/chunk-LAHVHRCS.js +2 -0
- package/dist/chunk-LOOFOBTS.js +1 -0
- package/dist/chunk-LSJC73DG.js +1 -0
- package/dist/chunk-MOKUBKE7.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-ONZLNFQ2.js +3 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-SX5VE6XA.js +110 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-UNK5HRQR.js +1 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V2RPZMLK.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-VLMQSD25.js +1 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-Y7YM5R75.js +5 -0
- package/dist/chunk-YW7NORH3.js +2 -0
- package/dist/main.js +99 -540
- package/package.json +17 -64
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -120
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -294
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -295
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/middle-prune.js +0 -76
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -735
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -429
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-facts.js +0 -35
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -447
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4778
- package/dist/update.js +0 -551
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/checkpoint.js
DELETED
|
@@ -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
|
-
}
|