@davesheffer/hunch 1.39.2 → 1.39.3

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 (46) hide show
  1. package/dist/cli/index.js +97 -50
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/core/correction.js +12 -7
  5. package/dist/core/docanchors.js +16 -2
  6. package/dist/core/glob.d.ts +19 -0
  7. package/dist/core/glob.js +37 -0
  8. package/dist/core/paths.d.ts +27 -5
  9. package/dist/core/paths.js +51 -6
  10. package/dist/core/procstart.d.ts +94 -0
  11. package/dist/core/procstart.js +244 -0
  12. package/dist/core/served.d.ts +6 -3
  13. package/dist/core/served.js +10 -6
  14. package/dist/core/taskReport.d.ts +2 -0
  15. package/dist/core/taskReport.js +46 -11
  16. package/dist/core/taskReportEvidence.js +17 -1
  17. package/dist/core/taskReportHook.js +14 -2
  18. package/dist/core/workspace.d.ts +9 -0
  19. package/dist/core/workspace.js +15 -0
  20. package/dist/extractors/git.d.ts +5 -0
  21. package/dist/extractors/git.js +87 -10
  22. package/dist/extractors/indexer.d.ts +10 -0
  23. package/dist/extractors/indexer.js +17 -1
  24. package/dist/extractors/k8sManifest.js +104 -16
  25. package/dist/integrations/hookmatch.d.ts +25 -0
  26. package/dist/integrations/hookmatch.js +58 -0
  27. package/dist/integrations/hooks.d.ts +38 -7
  28. package/dist/integrations/hooks.js +250 -24
  29. package/dist/integrations/providers.js +2 -17
  30. package/dist/integrations/scaffold.d.ts +1 -1
  31. package/dist/integrations/scaffold.js +24 -21
  32. package/dist/integrations/workspaceLedger.d.ts +10 -0
  33. package/dist/integrations/workspaceLedger.js +9 -1
  34. package/dist/mcp/server.d.ts +10 -0
  35. package/dist/mcp/server.js +21 -13
  36. package/dist/serve/writelock.d.ts +21 -1
  37. package/dist/serve/writelock.js +157 -16
  38. package/dist/store/hunchStore.js +41 -11
  39. package/dist/store/jsonStore.d.ts +6 -4
  40. package/dist/store/jsonStore.js +225 -32
  41. package/dist/store/merge.d.ts +7 -4
  42. package/dist/store/merge.js +65 -5
  43. package/dist/store/privateMigrate.js +10 -0
  44. package/dist/wiki/adopt.js +13 -2
  45. package/package.json +1 -1
  46. package/server.json +2 -2
@@ -0,0 +1,94 @@
1
+ /** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
2
+ * printed — it is an identity component, never a quantity, so it is never
3
+ * converted to a Number (which would lose precision above 2^53 and invite
4
+ * arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
5
+ * numeric fields are only unambiguous AFTER the last `)`. starttime is overall
6
+ * field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
7
+ export declare function starttimeTicks(stat: string): string | null;
8
+ /** Reader seam so the pure token logic is testable off linux. */
9
+ interface ProcReader {
10
+ read: (path: string) => string;
11
+ readlink: (path: string) => string;
12
+ }
13
+ /** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
14
+ * for `pid` ("self" for us), or `null` when any piece is missing or malformed.
15
+ * Linux only, and no spawn: every piece is a file read.
16
+ *
17
+ * The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
18
+ * when tokenizing another pid: a pid we can see in our `/proc` is by definition
19
+ * addressed in our namespace, so that is the namespace the token belongs to.
20
+ *
21
+ * The TIME namespace matters because field 22 is rendered relative to the
22
+ * READER's time namespace: the same live process yields different ticks to
23
+ * readers in different time namespaces, so a token compared across them would
24
+ * read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
25
+ * `/proc/self/ns/time` at all — that one missing link is normalized to "0"
26
+ * (every process on such a kernel shares the same, only, time namespace).
27
+ *
28
+ * `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
29
+ * or not-remounted /proc inside a container), in which case `/proc/<pid>` names
30
+ * a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
31
+ * different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
32
+ * /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
33
+ * proof that the mount we are reading is our own namespace's. */
34
+ export declare function linuxStartToken(pid: number | "self", io?: ProcReader, selfPid?: number): string | null;
35
+ /** OUR process-instance token, or `null` off linux / when /proc proves nothing.
36
+ * Only a NON-NULL token is memoized: a process's own identity never changes,
37
+ * but a transient failure to READ it (a momentarily unreadable /proc) must not
38
+ * be frozen into a permanent "we have no identity" for the life of the process. */
39
+ export declare function selfStartToken(): string | null;
40
+ /** The process-instance token of whoever currently holds `pid`, or `null` when
41
+ * that cannot be established (not linux, pid gone, /proc hidden). */
42
+ export declare function startTokenOf(pid: number): string | null;
43
+ /** What the recorded owner is, relative to whoever holds its pid NOW.
44
+ * - `same` — byte-identical token: the genuine owner, still running.
45
+ * - `recycled` — the pid is held by a DIFFERENT process instance.
46
+ * - `unprobeable` — the pid number carries no meaning here (another boot, another
47
+ * namespace, or a token read on a platform that has none), so
48
+ * neither "alive" nor "dead" is evidence.
49
+ * - `unknown` — no token recorded, or none readable now: not proven either way. */
50
+ export type InstanceVerdict = "same" | "recycled" | "unprobeable" | "unknown";
51
+ /** The whole verdict, pure and injectable. String comparison only — no clocks. */
52
+ export declare function ownerInstance(ownerStart: string | undefined, pid: number, self?: string | null, tokenOf?: (pid: number) => string | null, platform?: NodeJS.Platform): InstanceVerdict;
53
+ /** Nonces of the locks THIS process currently holds.
54
+ *
55
+ * The own-pid fallback for platforms without tokens: when the token path says
56
+ * "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
57
+ * lock is not one WE took — most likely a PREDECESSOR that happened to have this
58
+ * pid (the container-restart shape) — so the record falls to the age rule
59
+ * instead of our own liveness vouching for a corpse forever.
60
+ *
61
+ * This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
62
+ * (verified by grep), so every holder in a process shares this set; if a worker
63
+ * ever takes these locks, this fallback must be revisited. It is also per MODULE
64
+ * INSTANCE: were this module ever loaded twice in one process (a cache-busting
65
+ * import, a duplicated dependency), one copy would not recognise the other's
66
+ * held nonce and could age-steal its live lock. */
67
+ export declare const heldLockNonces: Set<string>;
68
+ /** What a contender should do about a SAME-HOST lock owner.
69
+ * - `stale` — provably not the owner's process any more: take it over.
70
+ * - `live` — authoritative, however old the file looks: never steal it.
71
+ * - `age` — nothing is proven; fall back to the caller's own age rule. */
72
+ export type SameHostVerdict = "stale" | "live" | "age";
73
+ /** The parts of a lock record this decision reads. Both lock formats supply it. */
74
+ export interface SameHostOwner {
75
+ pid: number;
76
+ start?: string;
77
+ nonce?: string;
78
+ }
79
+ /** Everything the decision consults, injected so every row is unit-testable. */
80
+ export interface SameHostEnv {
81
+ alive: (pid: number) => boolean;
82
+ self: string | null;
83
+ tokenOf: (pid: number) => string | null;
84
+ platform: NodeJS.Platform;
85
+ selfPid: number;
86
+ heldNonces: ReadonlySet<string>;
87
+ }
88
+ /**
89
+ * THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
90
+ * `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
91
+ * process state beyond what `env` hands it.
92
+ */
93
+ export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
94
+ export {};
@@ -0,0 +1,244 @@
1
+ /**
2
+ * When is a live PID NOT the process that took a lock?
3
+ *
4
+ * Every cross-process lock here trusts a same-host owner while `kill(pid, 0)`
5
+ * succeeds — age alone cannot tell a slow writer from a dead one, so a live PID
6
+ * is authoritative (src/serve/writelock.ts, jsonStore's `.rmw-lock`). That trust
7
+ * has one hole: PIDs are recycled. A container restart routinely hands the new
8
+ * `hunch serve` the SAME pid on the SAME hostname, so a lock left by the dead
9
+ * process names its successor's own pid — provably alive, never taken over, and
10
+ * every write after it refuses forever (issues #287, #293).
11
+ *
12
+ * The disambiguator is IDENTITY, compared by STRING EQUALITY: a lock records the
13
+ * process-instance token of whoever took it, and the current holder of that pid
14
+ * is the same process only if its token is byte-identical. No arithmetic, no
15
+ * ordering, no tolerance — a token either matches or it does not.
16
+ *
17
+ * WHY NOT CLOCKS. The previous draft compared the lock file's mtime against
18
+ * start-time arithmetic. Those are two different clocks: mtime comes from the
19
+ * FILESYSTEM (which on a network or virtualized volume is another machine's),
20
+ * start times from the HOST's boot-relative accounting. Filesystem/host skew, an
21
+ * NTP step forward, or a suspend/resume makes a genuinely live owner look as if
22
+ * it started after its own lock — a false "reused" that STEALS a live lock and
23
+ * puts two writers inside the mutex. Reproduced by simply `utimes`-ing a held
24
+ * lock 30 s into the past. Nothing in this file may read a clock.
25
+ *
26
+ * REMAINING GAP. Only linux exposes a spawn-free, stable process-instance id
27
+ * (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces). On
28
+ * macOS and Windows a DIFFERENT live process that recycled the owner's pid
29
+ * therefore stays "not proven" and keeps the lock until the age rule applies —
30
+ * today's behaviour, and the safe direction. `ps -o lstart` and PowerShell's
31
+ * `Get-Process ... StartTime` were rejected: both cost a spawn per probe on the
32
+ * contended path, and both return locale/TZ-dependent strings we would have to
33
+ * parse back into a comparable identity. Gating such a probe on age does not
34
+ * rescue them either: the CONTENDER can read the current holder's start time,
35
+ * but a verdict needs something to compare it WITH — either a start time the
36
+ * OWNER recorded when it took the lock (which puts that spawn on every writing
37
+ * process, not just the contended path) or a start-time-vs-mtime comparison,
38
+ * i.e. exactly the cross-clock arithmetic rejected above.
39
+ *
40
+ * UNSUPPORTED TOPOLOGIES. A linux-written token carries its boot id and its pid
41
+ * and time namespaces, so a lock written under another boot or another namespace
42
+ * is now reported as "unprobeable" — the recorded pid number means nothing here,
43
+ * and the age rule governs, exactly as it does for a foreign host. That covers a
44
+ * reboot, two machines sharing a hostname over a network volume, two containers
45
+ * sharing hostname + volume, and a restarted container with a fresh namespace.
46
+ * That "reported as unprobeable" holds only while OUR OWN token is readable: a
47
+ * null self token on linux means our own /proc proved nothing, and those records
48
+ * then get main's pid rule instead of the age rule (no regression from main, but
49
+ * not the age rule either). CRIU checkpoint/restore is NOT supported: a restored
50
+ * process keeps its pid but gets a new boot id / namespace and a new starttime,
51
+ * while our own token is memoized from before the checkpoint — so a restored
52
+ * holder may judge itself against a token that no longer describes it. Tokenless
53
+ * records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
54
+ * get main's unchanged pid rule — a live same-host pid keeps its lock, a dead one
55
+ * is stealable — with exactly one exception, the own-pid + foreign-nonce row
56
+ * described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
57
+ * "live forever".
58
+ */
59
+ import { readFileSync, readlinkSync } from "node:fs";
60
+ /** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
61
+ * printed — it is an identity component, never a quantity, so it is never
62
+ * converted to a Number (which would lose precision above 2^53 and invite
63
+ * arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
64
+ * numeric fields are only unambiguous AFTER the last `)`. starttime is overall
65
+ * field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
66
+ export function starttimeTicks(stat) {
67
+ const close = stat.lastIndexOf(")");
68
+ if (close < 0)
69
+ return null;
70
+ const ticks = stat.slice(close + 1).trim().split(/\s+/)[19];
71
+ return ticks !== undefined && /^\d+$/.test(ticks) ? ticks : null;
72
+ }
73
+ const realProc = {
74
+ read: (path) => readFileSync(path, "utf8"),
75
+ readlink: (path) => readlinkSync(path),
76
+ };
77
+ /** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
78
+ * for `pid` ("self" for us), or `null` when any piece is missing or malformed.
79
+ * Linux only, and no spawn: every piece is a file read.
80
+ *
81
+ * The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
82
+ * when tokenizing another pid: a pid we can see in our `/proc` is by definition
83
+ * addressed in our namespace, so that is the namespace the token belongs to.
84
+ *
85
+ * The TIME namespace matters because field 22 is rendered relative to the
86
+ * READER's time namespace: the same live process yields different ticks to
87
+ * readers in different time namespaces, so a token compared across them would
88
+ * read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
89
+ * `/proc/self/ns/time` at all — that one missing link is normalized to "0"
90
+ * (every process on such a kernel shares the same, only, time namespace).
91
+ *
92
+ * `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
93
+ * or not-remounted /proc inside a container), in which case `/proc/<pid>` names
94
+ * a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
95
+ * different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
96
+ * /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
97
+ * proof that the mount we are reading is our own namespace's. */
98
+ export function linuxStartToken(pid, io = realProc, selfPid = process.pid) {
99
+ if (pid !== "self" && (!Number.isInteger(pid) || pid <= 0))
100
+ return null;
101
+ try {
102
+ // Validate our /proc VIEW first — it governs every path below, including the
103
+ // "self" one (a foreign view makes even our own token describe someone else).
104
+ if (io.readlink("/proc/self").trim() !== String(selfPid))
105
+ return null;
106
+ const bootId = io.read("/proc/sys/kernel/random/boot_id").trim();
107
+ if (!bootId || !/^[0-9a-fA-F-]+$/.test(bootId))
108
+ return null;
109
+ // `readlink /proc/self/ns/pid` reads `pid:[4026531836]`.
110
+ const pidns = /^pid:\[(\d+)\]$/.exec(io.readlink("/proc/self/ns/pid").trim())?.[1];
111
+ if (pidns === undefined)
112
+ return null;
113
+ let timens;
114
+ try {
115
+ timens = /^time:\[(\d+)\]$/.exec(io.readlink("/proc/self/ns/time").trim())?.[1] ?? "";
116
+ }
117
+ catch {
118
+ timens = "0"; // kernel < 5.6: no time namespaces exist, so there is only one
119
+ }
120
+ if (timens === "")
121
+ return null; // the link exists but is not the shape we know
122
+ const ticks = starttimeTicks(io.read(`/proc/${pid === "self" ? "self" : String(pid)}/stat`));
123
+ if (ticks === null)
124
+ return null;
125
+ return `${bootId}:${pidns}:${timens}:${ticks}`;
126
+ }
127
+ catch {
128
+ return null; // no /proc, hidepid, a pid that died mid-read — all unknown
129
+ }
130
+ }
131
+ let selfToken;
132
+ /** OUR process-instance token, or `null` off linux / when /proc proves nothing.
133
+ * Only a NON-NULL token is memoized: a process's own identity never changes,
134
+ * but a transient failure to READ it (a momentarily unreadable /proc) must not
135
+ * be frozen into a permanent "we have no identity" for the life of the process. */
136
+ export function selfStartToken() {
137
+ if (selfToken === undefined || selfToken === null) {
138
+ const token = process.platform === "linux" ? linuxStartToken("self") : null;
139
+ if (token !== null)
140
+ selfToken = token;
141
+ return token;
142
+ }
143
+ return selfToken;
144
+ }
145
+ /** The process-instance token of whoever currently holds `pid`, or `null` when
146
+ * that cannot be established (not linux, pid gone, /proc hidden). */
147
+ export function startTokenOf(pid) {
148
+ if (process.platform !== "linux")
149
+ return null;
150
+ if (pid === process.pid)
151
+ return selfStartToken();
152
+ return linuxStartToken(pid);
153
+ }
154
+ /** The whole verdict, pure and injectable. String comparison only — no clocks. */
155
+ export function ownerInstance(ownerStart, pid, self = selfStartToken(), tokenOf = startTokenOf, platform = process.platform) {
156
+ // A legacy or non-linux owner recorded no token: today's behaviour, unchanged.
157
+ if (ownerStart === undefined || !/^[0-9a-fA-F-]+:\d+:\d+:\d+$/.test(ownerStart))
158
+ return "unknown";
159
+ if (self === null) {
160
+ // We cannot speak the owner's language. ON linux that only means our own
161
+ // /proc proved nothing (hidepid, no mount, a foreign view): the pre-token pid
162
+ // rule applies unchanged ("unknown"), because handing a live same-host owner
163
+ // to the AGE rule would steal a slow writer's lock that was never stealable
164
+ // before. OFF linux a token-bearing record was written by a DIFFERENT OS
165
+ // instance that merely shares our hostname and volume (a linux container on
166
+ // this machine), so its pid number means nothing here: age governs.
167
+ return platform === "linux" ? "unknown" : "unprobeable";
168
+ }
169
+ // Written under another kernel boot or another pid/time namespace: the recorded
170
+ // pid NUMBER means nothing in ours — a live pid here is a stranger, a dead one
171
+ // is no evidence of the owner's death. Age governs instead.
172
+ const prefix = (token) => token.slice(0, token.lastIndexOf(":"));
173
+ if (prefix(ownerStart) !== prefix(self))
174
+ return "unprobeable";
175
+ const now = tokenOf(pid);
176
+ if (now === null)
177
+ return "unknown"; // died between probes, or /proc hidden for it
178
+ return now === ownerStart ? "same" : "recycled";
179
+ }
180
+ /** Nonces of the locks THIS process currently holds.
181
+ *
182
+ * The own-pid fallback for platforms without tokens: when the token path says
183
+ * "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
184
+ * lock is not one WE took — most likely a PREDECESSOR that happened to have this
185
+ * pid (the container-restart shape) — so the record falls to the age rule
186
+ * instead of our own liveness vouching for a corpse forever.
187
+ *
188
+ * This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
189
+ * (verified by grep), so every holder in a process shares this set; if a worker
190
+ * ever takes these locks, this fallback must be revisited. It is also per MODULE
191
+ * INSTANCE: were this module ever loaded twice in one process (a cache-busting
192
+ * import, a duplicated dependency), one copy would not recognise the other's
193
+ * held nonce and could age-steal its live lock. */
194
+ export const heldLockNonces = new Set();
195
+ /**
196
+ * THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
197
+ * `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
198
+ * process state beyond what `env` hands it.
199
+ */
200
+ export function judgeSameHostOwner(owner, env) {
201
+ // 1. A lock THIS process holds is never stolen — not by a recycled-pid guess,
202
+ // not by age. Our own held nonce outranks every other signal.
203
+ if (owner.nonce !== undefined && env.heldNonces.has(owner.nonce))
204
+ return "live";
205
+ const verdict = ownerInstance(owner.start, owner.pid, env.self, env.tokenOf, env.platform);
206
+ // 2. The pid number means nothing under this boot/namespace: neither its life
207
+ // nor its death is evidence. The caller's age rule governs, as for a
208
+ // foreign host.
209
+ if (verdict === "unprobeable")
210
+ return "age";
211
+ // 3. A dead pid whose number IS meaningful here: the owner is gone.
212
+ if (!env.alive(owner.pid))
213
+ return "stale";
214
+ // 4. Alive, but a different process instance holds that pid (the recycled-pid
215
+ // hole, proven by string equality).
216
+ if (verdict === "recycled")
217
+ return "stale";
218
+ // 5. Alive and byte-identical: the genuine owner, however old the file looks.
219
+ if (verdict === "same")
220
+ return "live";
221
+ // 6. "unknown": no token on one side or the other. A live pid is never stolen
222
+ // on a guess, with one exception — a record naming OUR OWN pid whose nonce
223
+ // we do NOT hold (row 1 already returned for one we do). That is the
224
+ // container-restart shape: a predecessor with this pid left the lock, we
225
+ // are alive, and nobody would ever take it over.
226
+ //
227
+ // WHY "age" AND NOT AN IMMEDIATE STEAL, ON EVERY PLATFORM. A nonce we do
228
+ // not hold proves only that the record is not THIS process's lock — NOT
229
+ // that whoever wrote it is dead. "Our pid" can be a LIVE NEIGHBOUR's in
230
+ // another pid space that happens to share our hostname and our volume: on
231
+ // linux, two containers sharing hostname + volume but not the pid namespace
232
+ // (k8s sidecars, `--net=host`); off linux, a linux GUEST of this machine
233
+ // (WSL2 shares the Windows hostname and /mnt/c by default; Docker Desktop
234
+ // bind mounts do the same). main never stole this shape at all — that is
235
+ // issue #287's forever-deadlock — so the age rule is what ends the deadlock,
236
+ // within the caller's own stale window, without ever stealing a FRESH lock
237
+ // that a live writer may still hold.
238
+ if (owner.pid === env.selfPid && owner.nonce !== undefined)
239
+ return "age";
240
+ // A nonce-less own-pid record stays live: there is nothing to compare, and a
241
+ // live pid is never stolen on a guess.
242
+ return "live";
243
+ }
244
+ //# sourceMappingURL=procstart.js.map
@@ -47,9 +47,12 @@ export interface ServedSummary {
47
47
  rows: ServedRow[];
48
48
  recent: ServedReceipt[];
49
49
  }
50
- /** Shared observation ledger. Explicit report operations surface failures; only
51
- * passive delivery/hook callers may degrade to best-effort recording. */
52
- export declare function withServedDatabase<T>(root: string, run: (db: DatabaseSync) => T): T;
50
+ /** Shared observation ledger. Explicit report operations surface failures and may
51
+ * pass a longer wait for a competing writer; passive delivery/hook callers keep the
52
+ * 100 ms default and may degrade to best-effort recording. */
53
+ export declare function withServedDatabase<T>(root: string, run: (db: DatabaseSync) => T, options?: {
54
+ busyTimeoutMs?: number;
55
+ }): T;
53
56
  /** Append delivery receipts. Never throws — a receipt must never cost a delivery. */
54
57
  export declare function recordServed(root: string, entries: readonly ServedEntry[]): void;
55
58
  /** The ledger, aggregated per record. Never throws; an unreadable ledger reads as empty. */
@@ -67,12 +67,15 @@ function ensureReceiptColumns(db) {
67
67
  existing.add(name);
68
68
  }
69
69
  }
70
- function openServedDb(root) {
70
+ function openServedDb(root, busyTimeoutMs = 100) {
71
71
  sqlite ??= loadSqlite();
72
72
  const dir = join(root, ".hunch-cache");
73
73
  mkdirSync(dir, { recursive: true });
74
74
  const db = new sqlite.DatabaseSync(join(dir, "served.db"));
75
- db.exec("PRAGMA busy_timeout = 100");
75
+ // Rollback-journal mode is deliberate: WAL would create and delete -wal/-shm
76
+ // siblings on every open, which file watchers on `.hunch-cache/served.db*` see
77
+ // as ledger changes.
78
+ db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.trunc(busyTimeoutMs))}`);
76
79
  db.exec(`CREATE TABLE IF NOT EXISTS served (
77
80
  at TEXT NOT NULL,
78
81
  session TEXT,
@@ -85,11 +88,12 @@ function openServedDb(root) {
85
88
  ensureReceiptColumns(db);
86
89
  return db;
87
90
  }
88
- /** Shared observation ledger. Explicit report operations surface failures; only
89
- * passive delivery/hook callers may degrade to best-effort recording. */
90
- export function withServedDatabase(root, run) {
91
+ /** Shared observation ledger. Explicit report operations surface failures and may
92
+ * pass a longer wait for a competing writer; passive delivery/hook callers keep the
93
+ * 100 ms default and may degrade to best-effort recording. */
94
+ export function withServedDatabase(root, run, options = {}) {
91
95
  assertReportPath(root, ".hunch-cache", "served.db");
92
- const db = openServedDb(root);
96
+ const db = openServedDb(root, options.busyTimeoutMs);
93
97
  try {
94
98
  return run(db);
95
99
  }
@@ -206,6 +206,8 @@ export interface LessonHistory {
206
206
  export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
207
207
  /** The task an aliased prompt identity reports to, or the identity itself. */
208
208
  export declare function resolveReportTask(root: string, id: string): string;
209
+ /** Whether a task id names a real task of THIS repository/worktree. */
210
+ export declare function reportTaskExists(root: string, id: string): boolean;
209
211
  /** The lookup is derived, never evidence. Backfill at most 64 events and
210
212
  * 512 KB per read. Parse outside the writer transaction so a history view cannot
211
213
  * hold the writer lock while validating a large legacy ledger. */
@@ -99,7 +99,10 @@ const TaskSchema = z.object({
99
99
  export const LessonReferenceSchema = z.object({
100
100
  kind: safeText(64), record_id: safeText(512), content_hash: hashSchema.optional(),
101
101
  }).strict();
102
- function taskDb(root, run) {
102
+ /** Explicit report operations wait out a competing writer; hook/delivery paths keep
103
+ * the ledger's 100 ms default so a receipt never costs a delivery. */
104
+ const PATIENT_BUSY_MS = 5_000;
105
+ function taskDb(root, run, patient = false) {
103
106
  return withServedDatabase(root, (db) => {
104
107
  db.exec(`CREATE TABLE IF NOT EXISTS report_tasks (
105
108
  task_id TEXT PRIMARY KEY, scope TEXT NOT NULL, body TEXT NOT NULL
@@ -115,10 +118,13 @@ function taskDb(root, run) {
115
118
  );
116
119
  CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
117
120
  CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
118
- INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
119
121
  CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
122
+ // Seed only when the row is missing: an unconditional INSERT made every pure
123
+ // read take the writer lock. INSERT OR IGNORE still settles the race.
124
+ if (!db.prepare("SELECT 1 FROM report_history_progress WHERE id = 1").get())
125
+ db.exec("INSERT OR IGNORE INTO report_history_progress VALUES (1, 0)");
120
126
  return run(db);
121
- });
127
+ }, patient ? { busyTimeoutMs: PATIENT_BUSY_MS } : {});
122
128
  }
123
129
  /** A prompt identity that reports to another prompt's task: a host notification
124
130
  * turn continues the session's latest task instead of opening a row. Explicit,
@@ -134,6 +140,11 @@ export function aliasReportTask(root, aliasId, taskId) {
134
140
  export function resolveReportTask(root, id) {
135
141
  return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
136
142
  }
143
+ /** Whether a task id names a real task of THIS repository/worktree. */
144
+ export function reportTaskExists(root, id) {
145
+ TaskIdSchema.parse(id);
146
+ return taskDb(root, db => !!db.prepare("SELECT 1 FROM report_tasks WHERE task_id = ? AND scope IN (?, ?, ?)").get(id, ...scopePair(root)));
147
+ }
137
148
  function deliveryRecords(kind, body) {
138
149
  if (kind === "save")
139
150
  return [ReportSaveSchema.parse(body).record];
@@ -298,10 +309,12 @@ function newestSessionTask(db, root, sessionKey) {
298
309
  export function latestSessionTask(root, sessionKey) {
299
310
  return taskDb(root, db => newestSessionTask(db, root, sessionKey));
300
311
  }
301
- /** Check-starts still inside their own timeout plus the grace window, minus the
302
- * results that arrived: while positive, a runner may still deliver a result. */
312
+ /** Check-starts still inside their own timeout plus the grace window whose own
313
+ * result has not arrived: while positive, a runner may still deliver a result.
314
+ * A result clears the start it names (its `check_id`) and nothing else — netting
315
+ * results against starts let a finished old check hide a running new one. */
303
316
  function pendingChecks(db, taskId) {
304
- const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
317
+ const { pending } = db.prepare(`SELECT COUNT(*) AS pending FROM report_events s WHERE s.task_id = ? AND s.kind = 'check-start' AND (julianday('now') - julianday(s.at)) * 86400000 < COALESCE(json_extract(s.body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} AND NOT EXISTS (SELECT 1 FROM report_events c WHERE c.task_id = s.task_id AND c.kind = 'check' AND json_extract(c.body, '$.check_id') = s.event_id)`).get(taskId);
305
318
  return pending ?? 0;
306
319
  }
307
320
  /** Tasks of a session that an earlier prompt left open are over once the
@@ -353,6 +366,9 @@ function appendEvent(root, taskId, kind, body, eventId) {
353
366
  if (!/^(hev|hocc)_[a-f0-9]{24}$/.test(id))
354
367
  throw new Error("invalid report event identity");
355
368
  const contentHash = reportHash(body);
369
+ // Explicit CLI/agent operations; passive observers (delivery, save, refusal,
370
+ // conformance) keep the default so an observation never costs a delivery.
371
+ const patient = kind === "check-start" || kind === "check" || kind === "claim";
356
372
  return taskDb(root, db => transaction(db, () => {
357
373
  const task = readTask(db, root, taskId);
358
374
  const prior = db.prepare("SELECT task_id, kind, content_hash FROM report_events WHERE event_id = ?").get(id);
@@ -408,7 +424,7 @@ function appendEvent(root, taskId, kind, body, eventId) {
408
424
  const seq = Number(inserted.lastInsertRowid);
409
425
  db.prepare("UPDATE report_history_progress SET through_rowid = ? WHERE id = 1 AND through_rowid = ?").run(seq, seq - 1);
410
426
  return id;
411
- }));
427
+ }), patient);
412
428
  }
413
429
  /** The record revisions among `records` that this task has not received before.
414
430
  * Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
@@ -472,12 +488,32 @@ export function recordReportConformance(root, taskId, conformance) {
472
488
  throw new Error("rule evaluation does not name a record revision delivered in this task");
473
489
  return appendEvent(root, taskId, "conformance", value);
474
490
  }
491
+ /** SQLITE_BUSY (5) / SQLITE_LOCKED (6); node:sqlite carries the extended code. */
492
+ function isBusyError(error) {
493
+ const code = error.errcode;
494
+ if (typeof code === "number")
495
+ return (code & 0xff) === 5 || (code & 0xff) === 6;
496
+ return /database is locked/i.test(error?.message ?? "");
497
+ }
475
498
  /** Only the local runner calls this. MCP never accepts a claimed successful check. */
476
499
  export function recordReportCheck(root, taskId, check) {
477
500
  const value = ReportCheckSchema.parse(check);
478
501
  if (!value.check_id)
479
502
  throw new Error("verification result requires a reserved check identity");
480
- return appendEvent(root, taskId, "check", value, `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`);
503
+ const id = `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`;
504
+ // A result that took minutes to produce must not be lost to a busy ledger. The
505
+ // write is idempotent (deterministic id; appendEvent returns the existing id for
506
+ // identical content), so a retry is safe. Each attempt already waits out
507
+ // PATIENT_BUSY_MS in BEGIN IMMEDIATE; no sleep between them.
508
+ for (let attempt = 1;; attempt++) {
509
+ try {
510
+ return appendEvent(root, taskId, "check", value, id);
511
+ }
512
+ catch (error) {
513
+ if (attempt >= 3 || !isBusyError(error))
514
+ throw error;
515
+ }
516
+ }
481
517
  }
482
518
  /** A start without a result blocks completion only while the runner could still
483
519
  * deliver one: its own timeout plus a minute of grace. After that the runner is
@@ -509,14 +545,13 @@ export function finishReportTask(root, taskId, state = "completed", options = {}
509
545
  return task;
510
546
  }
511
547
  if (state === "completed") {
512
- const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
513
- if ((pending ?? 0) > 0)
548
+ if (pendingChecks(db, taskId) > 0)
514
549
  throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
515
550
  }
516
551
  const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
517
552
  db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
518
553
  return finished;
519
- }));
554
+ }), by === "agent");
520
555
  }
521
556
  /** A report with no observation of any kind. Presentation surfaces may stay
522
557
  * silent for it; the task row itself is retained so "never touched Hunch" is
@@ -197,6 +197,8 @@ export function runReportConformance(root, store, taskId) {
197
197
  * not a verdict. */
198
198
  export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
199
199
  export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
200
+ /** After the command itself exits, everything it wrote is already in the pipe; the window only lets those buffered chunks be read before settling. */
201
+ const EXIT_DRAIN_MS = 500;
200
202
  /** A deliberately explicit command wrapper. The caller chooses the command;
201
203
  * reports never execute commands automatically to validate submitted claims. */
202
204
  export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
@@ -242,7 +244,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
242
244
  }
243
245
  const child = started;
244
246
  let timedOut = false, cancelled = false, settled = false;
245
- let cleanupTimer;
247
+ let cleanupTimer, drainTimer;
246
248
  const settle = (code) => {
247
249
  if (settled)
248
250
  return;
@@ -251,6 +253,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
251
253
  options.signal?.removeEventListener("abort", cancel);
252
254
  if (cleanupTimer)
253
255
  clearTimeout(cleanupTimer);
256
+ if (drainTimer)
257
+ clearTimeout(drainTimer);
254
258
  child.stdout.destroy();
255
259
  child.stderr.destroy();
256
260
  resolveResult({ code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
@@ -296,6 +300,18 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
296
300
  }
297
301
  settle(null);
298
302
  });
303
+ // `close` waits for EVERY holder of the stdio pipes; a command that leaves a
304
+ // helper inheriting stdout (dev server, watcher) and exits 0 would otherwise
305
+ // burn the whole budget and be recorded as a timeout (#304). The command's own
306
+ // `exit` is the result; settle on it after a short drain of the buffered output.
307
+ child.once("exit", code => {
308
+ if (settled)
309
+ return;
310
+ // The command finished inside its budget; the drain window is not part of it.
311
+ if (code !== null && !timedOut)
312
+ clearTimeout(timer);
313
+ drainTimer = setTimeout(() => settle(code), EXIT_DRAIN_MS);
314
+ });
299
315
  child.once("close", code => settle(code));
300
316
  options.signal?.addEventListener("abort", cancel, { once: true });
301
317
  if (options.signal?.aborted)
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
- import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
8
+ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  /** The exact task identity a native host prompt maps to. */
@@ -85,7 +85,19 @@ function identity(root, provider, event) {
85
85
  export function hookReportTaskId(root, provider, event) {
86
86
  try {
87
87
  const id = identity(root, provider, event);
88
- return id === "legacy" ? null : id;
88
+ if (!id || id === "legacy")
89
+ return null;
90
+ // A subagent's tool call carries the prompt's session and prompt identity
91
+ // plus its own agent_id, and no prompt ever opens a task under that
92
+ // identity (subagents never fire UserPromptSubmit): its deliveries and
93
+ // denials belong to the prompt's task. An agent-scoped task that DOES
94
+ // exist keeps its own evidence, and without a prompt task nothing is guessed.
95
+ if (event.agent_id && event.session_id && event.prompt_id && !reportTaskExists(root, id)) {
96
+ const parent = resolveReportTask(root, promptTaskId(root, event.session_id, event.prompt_id, null, provider));
97
+ if (reportTaskExists(root, parent))
98
+ return parent;
99
+ }
100
+ return id;
89
101
  }
90
102
  catch {
91
103
  return null;
@@ -253,4 +253,13 @@ export interface PrunePlan {
253
253
  /** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
254
254
  * proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
255
255
  export declare function pruneRefusal(b: WorkspaceBranch, wt: WorkspaceWorktree | undefined): string | null;
256
+ /** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
257
+ export declare function publicWorkspaceRecordPath(id: string): string;
258
+ /** The manual recipe for removing a workspace record that lives in the repo-tracked
259
+ * `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
260
+ * a tracked deletion so a stale clone can never erase team history (the same stance
261
+ * `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
262
+ * the working tree and wedge every later auto-commit. The removal is a normal, reviewable
263
+ * git change the human makes instead. */
264
+ export declare function publicWorkspaceRemovalRecipe(id: string, machine: string): string;
256
265
  export declare function planPrune(live: Workspace, others: readonly Workspace[]): PrunePlan;
@@ -346,6 +346,21 @@ export function pruneRefusal(b, wt) {
346
346
  return wt.dirty === null ? "worktree state could not be read" : "worktree has uncommitted or untracked changes";
347
347
  return null;
348
348
  }
349
+ /** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
350
+ export function publicWorkspaceRecordPath(id) {
351
+ return `.hunch/workspaces/${id}.json`;
352
+ }
353
+ /** The manual recipe for removing a workspace record that lives in the repo-tracked
354
+ * `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
355
+ * a tracked deletion so a stale clone can never erase team history (the same stance
356
+ * `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
357
+ * the working tree and wedge every later auto-commit. The removal is a normal, reviewable
358
+ * git change the human makes instead. */
359
+ export function publicWorkspaceRemovalRecipe(id, machine) {
360
+ const path = publicWorkspaceRecordPath(id);
361
+ return ` · git rm ${path} (never committed? delete the file instead)\n`
362
+ + ` · git commit -m "hunch: forget workspace ${machine}"`;
363
+ }
349
364
  export function planPrune(live, others) {
350
365
  const mine = pruneStepsFor(live, { skip: pruneRefusal });
351
366
  const plan = { local: mine.steps, others: {}, skipped: mine.skipped };