cursedbelt-server 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/sync/http.d.ts +20 -3
  36. package/dist/server/sync/http.js +20 -14
  37. package/dist/server/sync/index.d.ts +10 -2
  38. package/dist/server/sync/index.js +9 -1
  39. package/dist/server/sync/planner.d.ts +38 -8
  40. package/dist/server/sync/planner.js +32 -8
  41. package/dist/server/sync/signal.d.ts +161 -0
  42. package/dist/server/sync/signal.js +348 -0
  43. package/dist/server/sync/timer.d.ts +63 -19
  44. package/dist/server/sync/timer.js +104 -45
  45. package/dist/server/sync/types.d.ts +0 -2
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/noTimerDialsAPeer.spec.ts +469 -0
  49. package/src/server/bench/assert.ts +192 -0
  50. package/src/server/bench/budget.spec.ts +126 -0
  51. package/src/server/bench/budget.ts +207 -0
  52. package/src/server/bench/cpuBudget.spec.ts +302 -0
  53. package/src/server/bench/cpuBudget.ts +81 -0
  54. package/src/server/bench/cpuClock.ts +119 -0
  55. package/src/server/bench/index.ts +81 -0
  56. package/src/server/bench/recorder.ts +163 -0
  57. package/src/server/bench/runBench.ts +110 -0
  58. package/src/server/d1/backup.spec.ts +121 -0
  59. package/src/server/d1/backup.ts +186 -0
  60. package/src/server/d1/fakeD1.ts +193 -0
  61. package/src/server/d1/index.ts +62 -0
  62. package/src/server/d1/kysely.spec.ts +145 -0
  63. package/src/server/d1/kysely.ts +169 -0
  64. package/src/server/d1/limits.spec.ts +90 -0
  65. package/src/server/d1/limits.ts +123 -0
  66. package/src/server/d1/local.ts +173 -0
  67. package/src/server/d1/remote.ts +182 -0
  68. package/src/server/d1/sameShape.spec.ts +279 -0
  69. package/src/server/d1/scheduling.spec.ts +120 -0
  70. package/src/server/d1/scheduling.ts +210 -0
  71. package/src/server/d1/types.ts +163 -0
  72. package/src/server/d1/values.ts +138 -0
  73. package/src/server/sync/http.ts +31 -16
  74. package/src/server/sync/index.ts +23 -1
  75. package/src/server/sync/planner.spec.ts +33 -16
  76. package/src/server/sync/planner.ts +48 -11
  77. package/src/server/sync/signal.spec.ts +306 -0
  78. package/src/server/sync/signal.ts +422 -0
  79. package/src/server/sync/timer.spec.ts +97 -16
  80. package/src/server/sync/timer.ts +124 -47
  81. package/src/server/sync/types.ts +0 -2
@@ -1,20 +1,63 @@
1
1
  /**
2
- * The in-process dialer loop — vault's `syncTimer` generalized. Runs on the dialing
3
- * half only; a failure NEVER takes the host daemon down (an unsynced instance still
4
- * serves and still accepts writes — that is the point of two independently-writable
5
- * instances). The timer is `unref`'d so it never holds the process open.
6
- *
7
- * The peer is re-resolved on EVERY tick, so linking (or revoking) a peer takes
8
- * effect without a restart. The "Sync now" fast lane polls the receiver's request
9
- * stamp every ~20s while healthy, so a button press on the far side means seconds,
10
- * not "sometime in the next interval".
2
+ * The in-process sync loop. Runs on the dialing half only; a failure NEVER takes
3
+ * the host daemon down (an unsynced instance still serves and still accepts writes
4
+ * — that is the point of two independently-writable instances). The timer is
5
+ * `unref`'d so it never holds the process open.
6
+ *
7
+ * ── 🔴 This loop does not poll, and it may never be made to again ────────────────
8
+ *
9
+ * Until 2026-09-15 this file was a dialer twice over: a `GET /requested` probe at a
10
+ * peer every 20 s while healthy (`REQUEST_POLL_MS`), and a full reconciliation every
11
+ * 5 minutes (`DEFAULT_LOOP.intervalMs`) whether or not either side had news. The
12
+ * owner ruled both out — *"There should be no polling in cb unless you can make some
13
+ * good case for it that beats the api option"* — and the case against was asked for
14
+ * and not found. Measured in `vault`: the 20 s probe alone was **68 % of that app's
15
+ * entire traffic, one request every 22 seconds, for an app with one user.**
16
+ *
17
+ * Both are gone. Every wake-up this loop books now carries a {@link WakeReason}, and
18
+ * that union is deliberately closed with no periodic member — there is no value you
19
+ * can pass to {@link schedule} that means "again in a while". That makes the poll
20
+ * unrepresentable rather than merely discouraged, which matters because
21
+ * `REQUEST_POLL_MS` did not survive as a habit; it survived as **exported public
22
+ * API**, and the next consumer to import it would have made its removal a breaking
23
+ * change instead of an edit.
24
+ *
25
+ * What replaced each half:
26
+ *
27
+ * · **Mac → peer** is a PUSH. `markDirty()` after a local write books one
28
+ * debounced run: the side that has news says so, which is what "sync now"
29
+ * should always have been.
30
+ * · **peer → Mac** is `./signal` — the dialing half holds one long-lived stream
31
+ * open and the receiver writes a frame when its log grows. The Mac is still
32
+ * purely a client: it dials OUT, listens on nothing, and no timer fires.
33
+ *
34
+ * 🔴 **`markDirty()` is now REQUIRED wiring, not an optimization.** With the steady
35
+ * interval gone it is the only thing that pushes a local write. A consumer that
36
+ * takes the handle and drops `markDirty` (which `apps/vault` did while the interval
37
+ * still covered for it) will sit clean and silent forever.
38
+ *
39
+ * The peer is re-resolved on every run, so revoking a link takes effect without a
40
+ * restart. Linking a NEW peer is a user action and is noticed at the next
41
+ * `syncNow()` / `markDirty()` rather than by a timer that was watching for it —
42
+ * call `syncNow()` after a link and it is immediate.
11
43
  */
12
44
  import { type SyncLoopConfig } from "./planner";
13
45
  import type { SyncStatusReporter } from "./status";
14
46
  import type { SyncSummary } from "./types";
15
- export declare const REQUEST_POLL_MS = 20000;
47
+ /**
48
+ * 🔴 Every reason this loop is allowed to wake up. There is deliberately no
49
+ * `"interval"` / `"poll"` member, and adding one is the change this whole file
50
+ * exists to refuse.
51
+ *
52
+ * · `boot` — one catch-up run at start.
53
+ * · `dirty` — local ops are waiting to be pushed (debounced).
54
+ * · `forced` — `syncNow()`, or a frame off `./signal` saying the peer has news.
55
+ * · `retry` — the last attempt failed. Runs only while disconnected, never
56
+ * against a healthy peer; that is a reconnect backoff, not a poll.
57
+ */
58
+ export type WakeReason = "boot" | "dirty" | "forced" | "retry";
16
59
  export interface SyncTimerDeps {
17
- /** Re-read every tick: null = not linked (quiet no-op, re-checked next tick). */
60
+ /** Re-read on every run: null = not linked (quiet no-op). */
18
61
  resolvePeer: () => {
19
62
  basePath: string;
20
63
  token: string;
@@ -26,14 +69,8 @@ export interface SyncTimerDeps {
26
69
  }) => Promise<SyncSummary>;
27
70
  /** The local op-log head — a head beyond the last synced one marks dirty. */
28
71
  head: () => number;
29
- /** Ask the peer whether its "Sync now" was pressed; null on any failure. */
30
- checkRequest?: (peer: {
31
- basePath: string;
32
- token: string;
33
- }) => Promise<number | null>;
34
72
  status?: SyncStatusReporter;
35
73
  loop?: SyncLoopConfig;
36
- requestPollMs?: number;
37
74
  now?: () => number;
38
75
  setTimer?: (fn: () => void, ms: number) => unknown;
39
76
  clearTimer?: (handle: unknown) => void;
@@ -44,9 +81,16 @@ export interface SyncTimerDeps {
44
81
  }
45
82
  export interface SyncTimerHandle {
46
83
  stop(): void;
47
- /** Run the next tick immediately (the "Sync now" button on the dialing half). */
84
+ /**
85
+ * Run now. The "Sync now" button on the dialing half, and the handler for a
86
+ * `./signal` frame — both are "something has news", which is a push.
87
+ */
48
88
  syncNow(): void;
49
- /** Mark local writes pending (call after any local op) — debounces a burst. */
89
+ /**
90
+ * Mark local writes pending — call after any local op. 🔴 Required wiring: with
91
+ * no steady interval this is what pushes a local write. A burst coalesces into
92
+ * one run via the planner's debounce.
93
+ */
50
94
  markDirty(): void;
51
95
  }
52
96
  export declare function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle;
@@ -1,16 +1,52 @@
1
1
  /**
2
- * The in-process dialer loop — vault's `syncTimer` generalized. Runs on the dialing
3
- * half only; a failure NEVER takes the host daemon down (an unsynced instance still
4
- * serves and still accepts writes — that is the point of two independently-writable
5
- * instances). The timer is `unref`'d so it never holds the process open.
2
+ * The in-process sync loop. Runs on the dialing half only; a failure NEVER takes
3
+ * the host daemon down (an unsynced instance still serves and still accepts writes
4
+ * — that is the point of two independently-writable instances). The timer is
5
+ * `unref`'d so it never holds the process open.
6
6
  *
7
- * The peer is re-resolved on EVERY tick, so linking (or revoking) a peer takes
8
- * effect without a restart. The "Sync now" fast lane polls the receiver's request
9
- * stamp every ~20s while healthy, so a button press on the far side means seconds,
10
- * not "sometime in the next interval".
7
+ * ── 🔴 This loop does not poll, and it may never be made to again ────────────────
8
+ *
9
+ * Until 2026-09-15 this file was a dialer twice over: a `GET /requested` probe at a
10
+ * peer every 20 s while healthy (`REQUEST_POLL_MS`), and a full reconciliation every
11
+ * 5 minutes (`DEFAULT_LOOP.intervalMs`) whether or not either side had news. The
12
+ * owner ruled both out — *"There should be no polling in cb unless you can make some
13
+ * good case for it that beats the api option"* — and the case against was asked for
14
+ * and not found. Measured in `vault`: the 20 s probe alone was **68 % of that app's
15
+ * entire traffic, one request every 22 seconds, for an app with one user.**
16
+ *
17
+ * Both are gone. Every wake-up this loop books now carries a {@link WakeReason}, and
18
+ * that union is deliberately closed with no periodic member — there is no value you
19
+ * can pass to {@link schedule} that means "again in a while". That makes the poll
20
+ * unrepresentable rather than merely discouraged, which matters because
21
+ * `REQUEST_POLL_MS` did not survive as a habit; it survived as **exported public
22
+ * API**, and the next consumer to import it would have made its removal a breaking
23
+ * change instead of an edit.
24
+ *
25
+ * What replaced each half:
26
+ *
27
+ * · **Mac → peer** is a PUSH. `markDirty()` after a local write books one
28
+ * debounced run: the side that has news says so, which is what "sync now"
29
+ * should always have been.
30
+ * · **peer → Mac** is `./signal` — the dialing half holds one long-lived stream
31
+ * open and the receiver writes a frame when its log grows. The Mac is still
32
+ * purely a client: it dials OUT, listens on nothing, and no timer fires.
33
+ *
34
+ * 🔴 **`markDirty()` is now REQUIRED wiring, not an optimization.** With the steady
35
+ * interval gone it is the only thing that pushes a local write. A consumer that
36
+ * takes the handle and drops `markDirty` (which `apps/vault` did while the interval
37
+ * still covered for it) will sit clean and silent forever.
38
+ *
39
+ * The peer is re-resolved on every run, so revoking a link takes effect without a
40
+ * restart. Linking a NEW peer is a user action and is noticed at the next
41
+ * `syncNow()` / `markDirty()` rather than by a timer that was watching for it —
42
+ * call `syncNow()` after a link and it is immediate.
11
43
  */
12
44
  import { isUnreachable, DEFAULT_LOOP, planNextSync, } from "./planner";
13
- export const REQUEST_POLL_MS = 20_000;
45
+ /**
46
+ * One reconciliation shortly after start: catch up on whatever happened while this
47
+ * process was down. A single shot at boot, not a cadence — the same catch-up a push
48
+ * model needs anyway.
49
+ */
14
50
  const KICKOFF_MS = 5_000;
15
51
  function defaultPeerLabel(basePath) {
16
52
  try {
@@ -29,46 +65,63 @@ export function startSyncTimer(deps) {
29
65
  const warn = deps.warn ?? ((m) => console.error(m));
30
66
  const status = deps.status;
31
67
  const peerLabel = deps.peerLabel ?? defaultPeerLabel;
32
- const requestPollMs = deps.requestPollMs ?? REQUEST_POLL_MS;
33
68
  status?.update({ enabled: true, role: "dialer" });
34
69
  const state = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: null };
35
70
  let syncedHead = deps.head();
36
71
  let stopped = false;
37
72
  let handle = null;
38
73
  let reportedUnreachable = false;
39
- let forceRun = false;
40
- let handledRequestAt = 0;
41
- let probing = false;
74
+ /**
75
+ * 🔴 Starts TRUE: the boot catch-up is a forced run, not a scheduled one.
76
+ *
77
+ * It used to fall out of the planner for free — `lastAttempt: 0` plus a steady
78
+ * `intervalMs` made the first tick always due. With no interval left, a clean
79
+ * healthy state plans nothing, so the one run this loop genuinely owes at
80
+ * startup has to be asked for explicitly. It has a reason: this process may have
81
+ * missed ops while it was down, and catching up is exactly what a push model
82
+ * does on reconnect.
83
+ */
84
+ let forceRun = true;
42
85
  let inFlight = false;
43
- const schedule = (ms) => {
86
+ /** Why the currently-booked wake exists, or null when the loop is quiet. */
87
+ let bookedFor = null;
88
+ /**
89
+ * Book the next wake-up. 🔴 The `reason` is not decoration — it is the type-level
90
+ * guard: {@link WakeReason} has no periodic member, so there is no way to spell
91
+ * "wake again in `intervalMs`" through this function. It is also read back by
92
+ * {@link SyncTimerHandle.markDirty}, which must not push an imminent forced run
93
+ * out to a debounce.
94
+ */
95
+ const schedule = (reason, ms) => {
44
96
  if (stopped)
45
97
  return;
46
98
  if (handle !== null)
47
99
  clearTimer(handle);
48
100
  handle = setTimer(tick, ms);
49
101
  handle?.unref?.();
102
+ bookedFor = reason;
50
103
  };
51
- const sleepFor = (waitMs) => {
52
- const healthy = state.consecutiveFailures === 0 && requestPollMs > 0;
53
- return Math.max(1, healthy ? Math.min(waitMs, requestPollMs) : waitMs);
104
+ /** Nothing to push, nothing to retry: cancel any pending wake and go quiet. */
105
+ const idle = () => {
106
+ if (handle !== null)
107
+ clearTimer(handle);
108
+ handle = null;
109
+ bookedFor = null;
54
110
  };
55
- const probeRequest = (peer) => {
56
- if (probing || requestPollMs === 0 || !deps.checkRequest)
111
+ /** Apply the planner's verdict — the ONE place a wake-up is booked or declined. */
112
+ const bookNext = () => {
113
+ if (stopped)
57
114
  return;
58
- probing = true;
59
- void deps
60
- .checkRequest(peer)
61
- .then((requestedAt) => {
62
- if (stopped || requestedAt === null || requestedAt <= handledRequestAt)
63
- return;
64
- handledRequestAt = requestedAt;
65
- log("[sync] sync requested from the peer — running now.");
66
- forceRun = true;
67
- schedule(1);
68
- })
69
- .finally(() => {
70
- probing = false;
71
- });
115
+ if (forceRun) {
116
+ schedule("forced", 1);
117
+ return;
118
+ }
119
+ const plan = planNextSync(state, now(), loop);
120
+ if (plan.waitMs === null || plan.reason === null) {
121
+ idle();
122
+ return;
123
+ }
124
+ schedule(plan.reason, Math.max(1, plan.waitMs));
72
125
  };
73
126
  const tick = () => {
74
127
  if (stopped)
@@ -84,15 +137,16 @@ export function startSyncTimer(deps) {
84
137
  pending: Math.max(0, head - syncedHead),
85
138
  });
86
139
  if (peer === null) {
87
- schedule(loop.intervalMs);
140
+ // Not linked. Nothing to dial and nothing to wait for — `syncNow()` or the
141
+ // next local write re-checks. A re-check timer here would be a poll at a
142
+ // peer that does not exist yet.
143
+ forceRun = false;
144
+ idle();
88
145
  return;
89
146
  }
90
147
  const plan = planNextSync(state, now(), loop);
91
148
  if (!plan.runNow && !forceRun) {
92
- const linkedAndHealthy = state.consecutiveFailures === 0 && requestPollMs > 0;
93
- schedule(sleepFor(plan.waitMs));
94
- if (linkedAndHealthy)
95
- probeRequest(peer);
149
+ bookNext();
96
150
  return;
97
151
  }
98
152
  forceRun = false;
@@ -140,31 +194,36 @@ export function startSyncTimer(deps) {
140
194
  })
141
195
  .finally(() => {
142
196
  inFlight = false;
143
- if (forceRun)
144
- schedule(1);
145
- else
146
- schedule(sleepFor(Math.max(1_000, planNextSync(state, now(), loop).waitMs)));
197
+ // A local write that landed mid-run is news the planner must see.
198
+ if (deps.head() > syncedHead && state.dirtySince === null)
199
+ state.dirtySince = now();
200
+ bookNext();
147
201
  });
148
202
  };
149
- schedule(KICKOFF_MS);
203
+ schedule("boot", KICKOFF_MS);
150
204
  return {
151
205
  stop: () => {
152
206
  stopped = true;
153
207
  if (handle !== null)
154
208
  clearTimer(handle);
209
+ handle = null;
155
210
  },
156
211
  syncNow: () => {
157
212
  if (stopped)
158
213
  return;
159
214
  forceRun = true;
160
- schedule(1);
215
+ schedule("forced", 1);
161
216
  },
162
217
  markDirty: () => {
163
218
  if (stopped)
164
219
  return;
165
220
  if (state.dirtySince === null)
166
221
  state.dirtySince = now();
167
- schedule(1);
222
+ // A forced run is already due in ~1ms and will push these ops; re-booking
223
+ // would replace it with a 3s debounce and make "Sync now" slower.
224
+ if (bookedFor === "forced")
225
+ return;
226
+ bookNext();
168
227
  },
169
228
  };
170
229
  }
@@ -67,8 +67,6 @@ export interface RemoteApi {
67
67
  ops: SyncOp[];
68
68
  peerHas: number;
69
69
  }): Promise<ApplyReport>;
70
- /** The receiver's "someone pressed Sync now here" stamp; null when unsupported. */
71
- requestedAt?(): Promise<number | null>;
72
70
  }
73
71
  /** Context the applier gets per batch — vault's concurrency bound. */
74
72
  export interface ApplyContext {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "1.1.0",
3
+ "version": "2.1.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -48,6 +48,24 @@
48
48
  "source": "./src/server/context.ts",
49
49
  "import": "./dist/server/context.js"
50
50
  },
51
+ "./bench": {
52
+ "types": "./dist/server/bench/index.d.ts",
53
+ "bun": "./src/server/bench/index.ts",
54
+ "source": "./src/server/bench/index.ts",
55
+ "import": "./dist/server/bench/index.js"
56
+ },
57
+ "./d1": {
58
+ "types": "./dist/server/d1/index.d.ts",
59
+ "bun": "./src/server/d1/index.ts",
60
+ "source": "./src/server/d1/index.ts",
61
+ "import": "./dist/server/d1/index.js"
62
+ },
63
+ "./d1/testing": {
64
+ "types": "./dist/server/d1/fakeD1.d.ts",
65
+ "bun": "./src/server/d1/fakeD1.ts",
66
+ "source": "./src/server/d1/fakeD1.ts",
67
+ "import": "./dist/server/d1/fakeD1.js"
68
+ },
51
69
  "./dropzone": {
52
70
  "types": "./dist/server/dropzone/index.d.ts",
53
71
  "bun": "./src/server/dropzone/index.ts",
@@ -182,8 +200,8 @@
182
200
  }
183
201
  },
184
202
  "dependencies": {
185
- "cursedbelt-core": "^1.0.0",
186
- "cwip": "^3.0.2",
203
+ "cursedbelt-core": "^1.2.0",
204
+ "cwip": "^4.1.0",
187
205
  "jose": "^6.2.3"
188
206
  },
189
207
  "peerDependencies": {
@@ -156,8 +156,10 @@ const sourceOf = (subpath: string): string => {
156
156
  * Resolution here is therefore explicit rather than inherited. The redirected root is
157
157
  * used when it lies outside the repo (the normal case — the runner always sets
158
158
  * `$FORGE_STATE`, and the preload's sweep then cleans up after a killed run). When it
159
- * does not, this hops to `$HOME`, which `resolveTestTmpRoot` refuses for the general
160
- * default and which this check genuinely requires: an isolated root is the whole
159
+ * does not, this hops to `$FORGE_STATE/test-scratch.noindex` — or, with no generation
160
+ * in the environment, `~/.code/test-scratch.noindex`, which is where this machine keeps
161
+ * everything it builds for itself. `resolveTestTmpRoot` refuses `$HOME` for the general
162
+ * default and this check genuinely requires an isolated root: that isolation is the whole
161
163
  * measurement. Stale siblings are swept by mtime, the same way the preload does it,
162
164
  * so the hop cannot accumulate.
163
165
  */
@@ -175,7 +177,17 @@ const fixtureRoot = (): string => {
175
177
  'a check that cannot isolate itself is not a passing check',
176
178
  );
177
179
  }
178
- const outside = `${home}/.${pkg.name}-leaf-fixture.noindex`;
180
+ // 🔴 Under `$FORGE_STATE`, or under `~/.code` — never a dotted directory of its own in
181
+ // `$HOME`. Measured 2026-09-17: this hop had left three `~/.cursedbelt*-leaf-fixture.noindex`
182
+ // trees in the owner's home directory, indistinguishable at a glance from the forty a Mac's
183
+ // real software puts there, and the sweep below only ever cleaned their INSIDES. A check that
184
+ // tidies up after itself and still leaves its address behind for ever is the shape this
185
+ // generation's home-dir rule exists to stop; `tools/check-home-dir.ts` now fails on it.
186
+ const state = process.env.FORGE_STATE?.trim();
187
+ const outside =
188
+ state !== undefined && state !== '' && !`${state}/`.startsWith(REPO_PREFIX)
189
+ ? `${state}/test-scratch.noindex/${pkg.name}-leaf-fixture`
190
+ : `${home}/.code/test-scratch.noindex/${pkg.name}-leaf-fixture`;
179
191
  try {
180
192
  const cutoff = Date.now() - 6 * 60 * 60 * 1000;
181
193
  for (const entry of readdirSync(outside)) {