@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.
- package/dist/cli/index.js +97 -50
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/core/correction.js +12 -7
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +21 -13
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- 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
|
package/dist/core/served.d.ts
CHANGED
|
@@ -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
|
|
51
|
-
* passive delivery/hook callers
|
|
52
|
-
|
|
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. */
|
package/dist/core/served.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
89
|
-
* passive delivery/hook callers
|
|
90
|
-
|
|
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. */
|
package/dist/core/taskReport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
302
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/workspace.js
CHANGED
|
@@ -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 };
|