@davesheffer/hunch 1.39.2 → 1.40.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 (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -1,16 +1,99 @@
1
1
  /** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
2
2
  import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
6
- /** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
7
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
8
- * agent-supplied target must be normalized before comparison — otherwise a
9
- * Windows caller passing `src\auth\session.ts` never matches the stored
10
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
6
+ /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
7
+ * path with "/" — git emits it on all OSes — so any user- or agent-supplied
8
+ * target must be normalized before comparison, otherwise a Windows caller passing
9
+ * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
10
+ * symbol names too: they contain no backslashes. This does NOT make a path
11
+ * repo-relative — an absolute path passes through with its separators flipped,
12
+ * unchanged otherwise; use `repoRelativeTarget` for that. */
11
13
  export function toPosixTarget(target) {
12
14
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
13
15
  }
16
+ /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
17
+ * to Write, or a glob whose concrete segments aren't literal): resolve the longest
18
+ * existing ancestor, then re-append the missing tail. Idempotent on an
19
+ * already-resolved path. */
20
+ export function realpathNorm(p) {
21
+ try {
22
+ return realpathSync.native(p);
23
+ }
24
+ catch {
25
+ const parent = dirname(p);
26
+ if (parent === p)
27
+ return p; // hit the root; nothing more to resolve
28
+ return join(realpathNorm(parent), basename(p));
29
+ }
30
+ }
31
+ /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
32
+ * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
33
+ * repo-relative, so a caller that hands over an absolute edit-payload path
34
+ * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
35
+ * returned empty for an absolute target that plainly had a matching rule). A
36
+ * relative target, a glob, or an absolute path outside `root` passes through
37
+ * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
38
+ *
39
+ * BOTH ends are realpath-normalized before the relative-path computation: on
40
+ * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
41
+ * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
42
+ * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
43
+ * caller treats the file as outside the repo, silently dropping all context
44
+ * (dec_e0a36efbf5). */
45
+ export function repoRelativeTarget(target, root) {
46
+ const t = toPosixTarget(target);
47
+ const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
48
+ if (!looksAbsolute)
49
+ return t;
50
+ // `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
51
+ // by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
52
+ // so an un-posix'd root and a posix'd target would decompose into differently-shaped
53
+ // ancestor chains and never share a common realpath'd prefix.
54
+ const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
55
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
56
+ return t;
57
+ return rel;
58
+ }
59
+ /** True when `target` (already repo-relative POSIX form) names a regular FILE
60
+ * that really exists inside `root`. The LAST-RESORT half of the "is this a real
61
+ * path" question: `isIndexedPath` answers it from graph data alone, but a real
62
+ * working-tree file with zero tree-sitter symbols and no covering component
63
+ * glob is invisible to it, so it fell through to the suffix tier and leaked an
64
+ * unrelated same-basename file's records (issue #334). Callers consult the
65
+ * index FIRST and only fall back here, so a deleted-but-still-indexed path and
66
+ * a time-travel (`asOf`) query keep answering exactly as before.
67
+ *
68
+ * Deliberately narrow: an absolute path (or a Windows drive letter) and any
69
+ * target escaping `root` via ".." are rejected rather than resolved — the
70
+ * caller has already run `repoRelativeTarget`, so anything still absolute is
71
+ * outside the repo. A DIRECTORY is false: directory targets must keep flowing
72
+ * to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
73
+ *
74
+ * Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
75
+ * follows symlinks, so an in-repo `link -> /outside` would otherwise make
76
+ * `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
77
+ * oracle for paths outside the repo. A symlinked file pointing at another file
78
+ * INSIDE the repo stays true. */
79
+ export function isRepoFile(root, target) {
80
+ const t = toPosixTarget(target);
81
+ if (!t || isAbsolute(t) || /^[a-zA-Z]:/.test(t))
82
+ return false;
83
+ const abs = resolve(root, t);
84
+ const rel = relative(resolve(root), abs);
85
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
86
+ return false;
87
+ try {
88
+ if (!statSync(abs).isFile())
89
+ return false;
90
+ const realRel = relative(realpathNorm(resolve(root)), realpathSync.native(abs));
91
+ return !!realRel && realRel !== ".." && !realRel.startsWith(`..${sep}`) && !isAbsolute(realRel);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
14
97
  export function hunchPaths(root) {
15
98
  const hunch = join(root, HUNCH_DIR);
16
99
  return {
@@ -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
  }
@@ -39,15 +39,41 @@ export function taskSelectionSupplements(selection, target) {
39
39
  const parts = selection.mode === "latest"
40
40
  ? `latest ${counts.latest} (ranking off: it lost its evaluation; hunch task rank-eval)`
41
41
  : [counts.latest ? "latest" : null, counts.violation ? "problem" : null, counts.relevant ? `relevant ${counts.relevant}` : null].filter(Boolean).join(" · ");
42
+ // `hash_text`: the IDENTITY of the records this selection picked, for the
43
+ // pre-edit hook's injection dedup. Every presentation field here is volatile
44
+ // between two back-to-back calls with no record change — the slot label and
45
+ // counts move with ranking warmth, and the reason text flips ("today" →
46
+ // "delivered today", or to a different top reason) because serving the block
47
+ // writes delivery receipts the next call's ranking reads back. Hashing the
48
+ // rendered line therefore made the block self-invalidating and re-sent the
49
+ // full 3-4KB grounding.
50
+ //
51
+ // What the identity KEEPS, because each is a property of the records, the
52
+ // repo or the evaluation state and never of a receipt:
53
+ // - header: the target, the picked set of record ids (sorted, so order is
54
+ // not a change), `selection.mode` (ranker vs the "latest" fallback the
55
+ // kill rule imposes — resolved from .hunch/local.json or the rank-eval
56
+ // report), and `selection.more` (gated candidates minus picks; the gates
57
+ // read superseded ids, anchor liveness and file/rule structure — scores
58
+ // only order them, so the count does not move with warmth);
59
+ // - per task: its id, its own content hash, and whether its file anchors
60
+ // are still all alive (`anchorsAlive < 1` — the fact behind the "files
61
+ // since changed" reason), so a picked task whose anchors die mid-session
62
+ // re-sends the full block instead of leaving a silently stale line.
42
63
  return [
43
64
  {
44
65
  id: "recent-tasks", kind: "recent-tasks", priority: 415,
45
66
  text: `RECENT TASKS on ${target} — ${parts} — earlier agent work here, from graph memory (advisory history, not rules): build on what was verified instead of redoing it blind.${selection.more > 0 ? ` ${selection.more} more: hunch task list ${target}.` : ""}`,
67
+ hash_text: `recent-tasks ${target} ${[...selection.picks.map((p) => p.ranked.record.id)].sort().join(",")} mode=${selection.mode ?? "ranked"} more=${selection.more}`,
46
68
  },
47
69
  ...selection.picks.map((p, i) => {
48
70
  const t = p.ranked.record;
49
71
  const reasons = p.ranked.reasons.slice(0, 2).join(" · ");
50
- return { id: t.id, kind: "recent-task", priority: 414 - i, text: `${SLOT_LABEL[p.slot]} ${t.id} · ${t.finished_at.slice(0, 10)} · "${clip(t.title, 80)}" — ${reasons} · ${summarizeRecord(t)}` };
72
+ return {
73
+ id: t.id, kind: "recent-task", priority: 414 - i,
74
+ text: `${SLOT_LABEL[p.slot]} ${t.id} · ${t.finished_at.slice(0, 10)} · "${clip(t.title, 80)}" — ${reasons} · ${summarizeRecord(t)}`,
75
+ hash_text: `${t.id}@${t.report_hash}${p.ranked.anchorsAlive < 1 ? "!stale" : ""}`,
76
+ };
51
77
  }),
52
78
  ];
53
79
  }
@@ -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. */