@davesheffer/hunch 1.39.1 → 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 (64) hide show
  1. package/dist/cli/index.js +164 -55
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/client/state.d.ts +2 -1
  5. package/dist/client/state.js +1 -0
  6. package/dist/core/agenthook.js +7 -3
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/correction.d.ts +10 -4
  10. package/dist/core/correction.js +19 -11
  11. package/dist/core/countersign.d.ts +28 -0
  12. package/dist/core/countersign.js +50 -0
  13. package/dist/core/docanchors.js +16 -2
  14. package/dist/core/glob.d.ts +19 -0
  15. package/dist/core/glob.js +37 -0
  16. package/dist/core/paths.d.ts +27 -5
  17. package/dist/core/paths.js +51 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/reviewqueue.js +6 -1
  21. package/dist/core/served.d.ts +6 -3
  22. package/dist/core/served.js +10 -6
  23. package/dist/core/spawnCommand.js +41 -9
  24. package/dist/core/stateHttp.d.ts +1 -1
  25. package/dist/core/stateHttp.js +3 -1
  26. package/dist/core/taskReport.d.ts +2 -0
  27. package/dist/core/taskReport.js +46 -11
  28. package/dist/core/taskReportEvidence.js +42 -6
  29. package/dist/core/taskReportHook.js +14 -2
  30. package/dist/core/topics.js +1 -1
  31. package/dist/core/workspace.d.ts +9 -0
  32. package/dist/core/workspace.js +15 -0
  33. package/dist/extractors/git.d.ts +5 -0
  34. package/dist/extractors/git.js +87 -10
  35. package/dist/extractors/indexer.d.ts +10 -0
  36. package/dist/extractors/indexer.js +17 -1
  37. package/dist/extractors/k8sManifest.js +104 -16
  38. package/dist/integrations/hookmatch.d.ts +25 -0
  39. package/dist/integrations/hookmatch.js +58 -0
  40. package/dist/integrations/hooks.d.ts +38 -7
  41. package/dist/integrations/hooks.js +250 -24
  42. package/dist/integrations/providers.js +2 -17
  43. package/dist/integrations/scaffold.d.ts +1 -1
  44. package/dist/integrations/scaffold.js +25 -22
  45. package/dist/integrations/workspaceLedger.d.ts +10 -0
  46. package/dist/integrations/workspaceLedger.js +9 -1
  47. package/dist/mcp/server.d.ts +10 -0
  48. package/dist/mcp/server.js +131 -64
  49. package/dist/serve/app.d.ts +4 -0
  50. package/dist/serve/app.js +56 -36
  51. package/dist/serve/writelock.d.ts +21 -1
  52. package/dist/serve/writelock.js +157 -16
  53. package/dist/store/hunchStore.js +41 -11
  54. package/dist/store/jsonStore.d.ts +6 -4
  55. package/dist/store/jsonStore.js +225 -32
  56. package/dist/store/merge.d.ts +7 -4
  57. package/dist/store/merge.js +65 -5
  58. package/dist/store/privateMigrate.js +10 -0
  59. package/dist/store/stateBinding.js +28 -7
  60. package/dist/wiki/adopt.js +13 -2
  61. package/dist/wiki/wiki.d.ts +7 -0
  62. package/dist/wiki/wiki.js +19 -8
  63. package/package.json +1 -1
  64. package/server.json +2 -2
@@ -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
@@ -33,7 +33,12 @@ export const READY_MIN_GROUNDED = 0.7;
33
33
  * explicit roadmap/intent entry a human hasn't confirmed — counts as a review draft.
34
34
  * (Enforcement authority is granted INLINE, not by draining a background queue.) */
35
35
  export function isReviewDraft(d) {
36
- return d.status === "proposed" && !d.provenance.source.includes("human_confirmed");
36
+ // Agent testimony (agent_recorded) is deliberate intent, not a machine draft: it shows
37
+ // on the roadmap marked unconfirmed, and a human confirms it with `hunch review
38
+ // --confirm`. Keeping it out of the draft queue keeps `adopt-drafts` / `auto-review`
39
+ // from accepting or rejecting it in bulk.
40
+ return d.status === "proposed" && !d.provenance.source.includes("human_confirmed")
41
+ && !d.provenance.source.split("+").includes("agent_recorded");
37
42
  }
38
43
  /** A draft is "ready to confirm" only when the Critic actually audited it (source
39
44
  * includes "verified") AND judged it well-grounded. A high confidence number alone
@@ -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
  }
@@ -9,16 +9,48 @@
9
9
  * npm/npx launchers run as plain Node scripts (no shell at all). */
10
10
  import { existsSync } from "node:fs";
11
11
  import { posix, win32 } from "node:path";
12
- /** cmd.exe quoting for one argument: wrap when it has whitespace or shell
13
- * metacharacters; double embedded quotes. Good for test/build commands; a
14
- * deliberately hostile argument still cannot escape because the whole line is
15
- * passed as one `/s /c "..."` token. */
12
+ /** Quote one argument for a `cmd.exe /d /v:off /s /c "<line>"` launch of a
13
+ * batch file whose target program parses its command line with the MSVCRT
14
+ * rules (Node, Python, most native tools).
15
+ *
16
+ * Two parsers read the line, and a batch shim that forwards `%*` makes cmd.exe
17
+ * read it again, so the quoting must mean the same thing to both on every pass:
18
+ *
19
+ * - Every non-trivial argument is wrapped in quotes. An embedded quote becomes
20
+ * `""` (not `\"`): MSVCRT reads `""` inside a quoted argument as one literal
21
+ * quote, and cmd.exe sees two toggles, so its quote state never drifts from
22
+ * the argument boundaries and `& | < > ( ) ^` always stay inside quotes.
23
+ * A `\"` would look escaped to MSVCRT but end the quoted region for cmd.exe.
24
+ * - Backslashes are literal except before a quote, so a run of backslashes that
25
+ * precedes an embedded or closing quote is doubled.
26
+ * - `%` expands even inside quotes. It is emitted as `"^%"`: the quote closes,
27
+ * the caret escapes the percent outside quotes (cmd.exe removes the caret),
28
+ * and the quote reopens. MSVCRT joins the pieces back into one argument.
29
+ * Expansion of a forwarded `%*` is not rescanned, so a shim pass is safe too.
30
+ *
31
+ * A line break cannot be carried: cmd.exe ends the command at it and silently
32
+ * drops the rest, so such an argument is refused rather than truncated. */
16
33
  function quoteForCmd(arg) {
17
- if (arg === "")
18
- return '""';
19
- if (!/[\s"&|<>^()%!]/.test(arg))
34
+ if (/[\r\n]/.test(arg))
35
+ throw new Error("a .cmd/.bat launcher cannot receive an argument containing a line break");
36
+ if (arg !== "" && /^[A-Za-z0-9_\-.:/\\@+]+$/.test(arg))
20
37
  return arg;
21
- return `"${arg.replace(/"/g, '""')}"`;
38
+ let out = '"';
39
+ let backslashes = 0;
40
+ for (const ch of arg) {
41
+ if (ch === "\\") {
42
+ backslashes++;
43
+ continue;
44
+ }
45
+ if (ch === '"')
46
+ out += "\\".repeat(backslashes * 2) + '""';
47
+ else if (ch === "%")
48
+ out += "\\".repeat(backslashes * 2) + '"^%"';
49
+ else
50
+ out += "\\".repeat(backslashes) + ch;
51
+ backslashes = 0;
52
+ }
53
+ return out + "\\".repeat(backslashes * 2) + '"';
22
54
  }
23
55
  export function resolveSpawnCommand(command, options = {}) {
24
56
  const platform = options.platform ?? process.platform;
@@ -49,7 +81,7 @@ export function resolveSpawnCommand(command, options = {}) {
49
81
  continue;
50
82
  if (/\.(cmd|bat)$/i.test(candidate)) {
51
83
  const line = [candidate, ...args].map(quoteForCmd).join(" ");
52
- return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true, how: "cmd-shim" };
84
+ return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/v:off", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true, how: "cmd-shim" };
53
85
  }
54
86
  if (ext === "" && !/\.(exe|com)$/i.test(candidate))
55
87
  continue; // an extensionless file is not runnable on Windows
@@ -40,7 +40,7 @@ export declare const HttpHealthSchema: z.ZodObject<{
40
40
  ok: z.ZodBoolean;
41
41
  version: z.ZodString;
42
42
  protocol: z.ZodLiteral<"nuryel.state/1">;
43
- partitions: z.ZodArray<z.ZodString>;
43
+ partitions: z.ZodOptional<z.ZodArray<z.ZodString>>;
44
44
  }, z.core.$strict>;
45
45
  export declare const HttpReadResponseSchema: z.ZodObject<{
46
46
  conventions: z.ZodOptional<z.ZodObject<{
@@ -6,7 +6,9 @@ export const HttpCapabilitiesSchema = CapabilityNegotiationSchema.extend({
6
6
  principal: PrincipalSchema.pick({ id: true, kind: true, grants: true }),
7
7
  }).strict();
8
8
  export const HttpHealthSchema = z.object({
9
- ok: z.boolean(), version: z.string(), protocol: z.literal(STATE_CONTRACT_VERSION), partitions: z.array(z.string()),
9
+ ok: z.boolean(), version: z.string(), protocol: z.literal(STATE_CONTRACT_VERSION),
10
+ // Present only when the request carried a valid credential.
11
+ partitions: z.array(z.string()).optional(),
10
12
  }).strict();
11
13
  // Delivery has its own richer assertion and receipt checks in delivery.ts.
12
14
  export const HttpReadResponseSchema = ReadResponseSchema.extend({ envelope: z.record(z.string(), z.unknown()) });
@@ -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 = {}) {
@@ -216,11 +218,33 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
216
218
  // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
217
219
  // without a shell; resolve them first so a check actually runs instead of
218
220
  // silently recording exit_code null (fnd: every Windows card said "no result").
219
- const resolved = resolveSpawnCommand(command);
220
- const child = spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
221
221
  const stdout = createHash("sha256"), stderr = createHash("sha256");
222
+ const launchFailure = (error) => {
223
+ // A launch failure is a result the user must see (ENOENT is the common
224
+ // one); it is hashed like any other stderr and streamed to the caller.
225
+ const text = error instanceof Error ? error.message : String(error);
226
+ return Buffer.from(`hunch: could not start ${JSON.stringify(command[0])}: ${text}\n`);
227
+ };
228
+ const started = (() => {
229
+ try {
230
+ const resolved = resolveSpawnCommand(command);
231
+ return spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
232
+ }
233
+ catch (error) {
234
+ // An argument the launcher cannot carry, or a synchronous spawn refusal
235
+ // (EINVAL for a batch file), is the same visible failure as ENOENT.
236
+ return launchFailure(error);
237
+ }
238
+ })();
239
+ if (Buffer.isBuffer(started)) {
240
+ stderr.update(started);
241
+ options.onStderr?.(started);
242
+ resolveResult({ code: null, timedOut: false, cancelled: false, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
243
+ return;
244
+ }
245
+ const child = started;
222
246
  let timedOut = false, cancelled = false, settled = false;
223
- let cleanupTimer;
247
+ let cleanupTimer, drainTimer;
224
248
  const settle = (code) => {
225
249
  if (settled)
226
250
  return;
@@ -229,6 +253,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
229
253
  options.signal?.removeEventListener("abort", cancel);
230
254
  if (cleanupTimer)
231
255
  clearTimeout(cleanupTimer);
256
+ if (drainTimer)
257
+ clearTimeout(drainTimer);
232
258
  child.stdout.destroy();
233
259
  child.stderr.destroy();
234
260
  resolveResult({ code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
@@ -267,15 +293,25 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
267
293
  options.onStderr?.(chunk);
268
294
  } });
269
295
  child.once("error", (error) => {
270
- // A launch failure is a result the user must see (ENOENT is the common
271
- // one); it is hashed like any other stderr and streamed to the caller.
272
- const message = Buffer.from(`hunch: could not start ${JSON.stringify(command[0])}: ${error.message}\n`);
296
+ const message = launchFailure(error);
273
297
  if (!settled) {
274
298
  stderr.update(message);
275
299
  options.onStderr?.(message);
276
300
  }
277
301
  settle(null);
278
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
+ });
279
315
  child.once("close", code => settle(code));
280
316
  options.signal?.addEventListener("abort", cancel, { once: true });
281
317
  if (options.signal?.aborted)