@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.7 → 0.1.9
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/bin/adl +2 -2
- package/bin/git +89 -8
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +175 -5
- package/bin/validate-manifest +19 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
- package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
- package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
- package/packages/agent-hooks/src/deploy/lock.ts +36 -2
- package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
- package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
- package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
- package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
- package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
- package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
- package/packages/agent-settings/src/deploy/doctor.ts +39 -3
- package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
- package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
- package/packages/agent-settings/src/deploy/lock.ts +4 -1
- package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
- package/packages/agent-settings/src/deploy/settings.ts +6 -3
- package/packages/agent-skills/dist/deploy.d.ts +2 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +15 -8
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/manifest.d.ts +1 -0
- package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
- package/packages/agent-skills/dist/manifest.js +6 -2
- package/packages/agent-skills/dist/manifest.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +15 -8
- package/packages/agent-skills/src/manifest.ts +5 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +412 -95
- package/packages/azdo/src/lib/graph-identity.ts +190 -3
- package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
- package/packages/fleet/manifest.json +2 -0
- package/packages/fleet/manifest.schema.json +9 -0
- package/packages/repo-controls/README.md +13 -6
- package/packages/repo-controls/assets/adl-config.default.toml +1 -0
- package/packages/repo-controls/assets/version-bump.sh +6 -1
- package/packages/repo-controls/src/cli.ts +6 -1
- package/packages/repo-controls/src/install.ts +88 -6
|
@@ -12,28 +12,317 @@
|
|
|
12
12
|
* event order — this is what makes a deploy into an empty repo reproduce
|
|
13
13
|
* this repo's own committed `.claude/settings.json` / `.codex/hooks.json` /
|
|
14
14
|
* `.cursor/hooks.json` byte-for-byte (json-merge.test.ts / deploy.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* Merge contract, ownership mechanism corrected TWICE by independent
|
|
17
|
+
* reviews the same day (2026-09-29):
|
|
18
|
+
*
|
|
19
|
+
* Review 1 found: identifying "still-desired" entries by stripping their
|
|
20
|
+
* `--host=<surface>` suffix and comparing the remainder ALSO matched a
|
|
21
|
+
* user's own lookalike entry (same command text, different matcher, never
|
|
22
|
+
* written by adl) and deleted it.
|
|
23
|
+
*
|
|
24
|
+
* Review 2 found the FIRST fix (pure array-index tracking) had two of its
|
|
25
|
+
* own bugs: (a) CRITICAL — every repo enrolled before that fix has a lock
|
|
26
|
+
* with no recorded indices at all, so the very first deploy after
|
|
27
|
+
* upgrading read "owns nothing" and duplicated every adl hook; (b) HIGH —
|
|
28
|
+
* position tracking broke the moment a user inserted or removed an entry
|
|
29
|
+
* BEFORE adl's own block (everything downstream shifted, capturing/
|
|
30
|
+
* overwriting the user's entry and duplicating adl's), and could not
|
|
31
|
+
* express "a user hook appended INSIDE an adl-owned group's own `hooks`
|
|
32
|
+
* array" — that got silently deleted instead of kept.
|
|
33
|
+
*
|
|
34
|
+
* Ownership is now CONTENT-ADDRESSED (`isOwnedHook`, below): an existing
|
|
35
|
+
* hook is adl's iff EITHER (a) its own hash (type+command+statusMessage+
|
|
36
|
+
* timeper — never the group's `matcher`, which is a group-level field)
|
|
37
|
+
* matches a hash THIS package's lock recorded writing, ANYWHERE in the
|
|
38
|
+
* array (survives reordering/insertion/deletion elsewhere — closes (b)),
|
|
39
|
+
* or (b) it sits at the GROUP INDEX a recorded hook was written at and its
|
|
40
|
+
* `command` still matches (a tampered field OTHER than command, restored
|
|
41
|
+
* in place — the position signal is now a NARROW fallback, not the whole
|
|
42
|
+
* mechanism). A GROUP with a mix of adl and user hooks is SPLIT
|
|
43
|
+
* (`splitGroupHooks`): the user's hooks survive in their own group at that
|
|
44
|
+
* position; adl's slot is restored separately, right there — never
|
|
45
|
+
* deleted, never duplicated.
|
|
46
|
+
*
|
|
47
|
+
* A lock recording no hashes at all (`ownedEntries` absent — every repo
|
|
48
|
+
* enrolled before this fix; closes (a)) falls back, for exactly this one
|
|
49
|
+
* transitional deploy, to command-anywhere matching against the lock's OLD
|
|
50
|
+
* `ownedCommands` — the only signal that data has — via the SAME
|
|
51
|
+
* `legacyOwnedCommands` channel the pre-agent-hooks engine's own takeover
|
|
52
|
+
* already used (both are "we have no better proof than an exact command
|
|
53
|
+
* string" signals). This run then persists real content hashes
|
|
54
|
+
* (`ownedEntries`), so every later deploy uses the full contract.
|
|
55
|
+
*
|
|
56
|
+
* THIRD independent review (2026-09-30) found the fallback ITSELF still
|
|
57
|
+
* leaked through raw array position, in two ways:
|
|
58
|
+
* 2E — the hash omitted the group's `matcher` entirely, so a byte-
|
|
59
|
+
* identical copy of an adl hook object pasted into a DIFFERENT
|
|
60
|
+
* matcher's group still hash-matched and got captured.
|
|
61
|
+
* 2G/2F/H — the position-based fallback compared a candidate's RAW
|
|
62
|
+
* array index to `recorded`'s ORDINAL index. A user group that
|
|
63
|
+
* merely happened to sit at the same raw index adl's entry once
|
|
64
|
+
* occupied (whether by coincidence, 2G, or because an unrelated
|
|
65
|
+
* earlier insert shifted adl's own tampered entry away from it,
|
|
66
|
+
* 2F/H) was captured or missed by that coincidence alone.
|
|
67
|
+
*
|
|
68
|
+
* Both are closed by dropping index-based reasoning entirely:
|
|
69
|
+
* - `hashHook`/`hashCursorEntry` now include `matcher` AND `event` in
|
|
70
|
+
* the digest, so a lookalike with a different matcher simply never
|
|
71
|
+
* hash-matches, full stop (closes 2E).
|
|
72
|
+
* - The fallback is now a PAIR rule, not a position rule: a non-hash-
|
|
73
|
+
* matching candidate heals in place iff its (command, matcher) pair
|
|
74
|
+
* is one adl recorded for this event AND no OTHER entry ANYWHERE in
|
|
75
|
+
* the array already hash-verifies that exact pair. A verified copy
|
|
76
|
+
* existing elsewhere means THIS candidate is the user's — a
|
|
77
|
+
* lookalike or a genuine accidental duplicate — never healed (closes
|
|
78
|
+
* 2G and 2F/H at once; a raw index is never consulted).
|
|
79
|
+
*
|
|
80
|
+
* FOURTH independent review (2026-09-30, round 5) found two more real
|
|
81
|
+
* bugs, both in how that pair rule counted things PER COMMAND instead of
|
|
82
|
+
* per (command, matcher) pair, and in what it did when MULTIPLE
|
|
83
|
+
* unverified candidates shared one pair:
|
|
84
|
+
*
|
|
85
|
+
* 1. MED — `duplicateCommands` counted every existing occurrence of a
|
|
86
|
+
* RECORDED COMMAND, ignoring matcher entirely. A legit user hook
|
|
87
|
+
* that reuses one of adl's command STRINGS under a genuinely
|
|
88
|
+
* DIFFERENT matcher (already proven safe from capture by 2E/2G) got
|
|
89
|
+
* counted as "the same command appears twice" and flagged by doctor
|
|
90
|
+
* forever — deploy has nothing to change (the merge is correctly a
|
|
91
|
+
* no-op), so the error could never clear.
|
|
92
|
+
* 2. MED — the old fallback healed whichever unverified candidate for
|
|
93
|
+
* a pair it happened to encounter FIRST in iteration order. When a
|
|
94
|
+
* user's OWN customized copy of a pair coexisted with adl's real
|
|
95
|
+
* entry ALSO tampered (both unverified), the fallback picked one
|
|
96
|
+
* arbitrarily: either the user's copy got silently overwritten with
|
|
97
|
+
* no explanation (item 2, case a: adl's real entry was simply gone,
|
|
98
|
+
* only the user's customized one remained — a single unverified
|
|
99
|
+
* candidate, which is a legitimate heal, but it was never NAMED in
|
|
100
|
+
* any output), or — worse — the user's copy got treated as "the
|
|
101
|
+
* heal target" while adl's own genuinely-tampered entry was left
|
|
102
|
+
* standing as "the user's", running the hook twice forever with no
|
|
103
|
+
* error (item 2, case a2: TWO unverified candidates for one pair).
|
|
104
|
+
*
|
|
105
|
+
* DECISION (Tarun, 2026-09-30):
|
|
106
|
+
* - `duplicateCommands` (doctor's own flag) now keys on the exact
|
|
107
|
+
* (command, matcher) PAIR, never command alone: it fires only when
|
|
108
|
+
* more than one entry hash-verifies for the same pair, or an
|
|
109
|
+
* unverified entry shares a pair with a verified one. A different
|
|
110
|
+
* matcher is never counted together with anything, full stop.
|
|
111
|
+
* - Exactly one unverified candidate for a pair, and zero verified
|
|
112
|
+
* ones -> heals in place (unchanged contract: a customized variant
|
|
113
|
+
* under the SAME matcher is adl's and gets restored) but the heal is
|
|
114
|
+
* now NAMED on the result (`restored`) so deploy, doctor's preview,
|
|
115
|
+
* and `adl install` can all say so explicitly.
|
|
116
|
+
* - MORE than one unverified candidate sharing a pair, with zero
|
|
117
|
+
* verified -> genuinely ambiguous: healing either one is a guess.
|
|
118
|
+
* Neither heals. The WHOLE EVENT for that surface is left exactly as
|
|
119
|
+
* found (no group split, no restore, nothing consumed from
|
|
120
|
+
* `desired`) and reported as a conflict (`ambiguousConflicts`) —
|
|
121
|
+
* deploy.ts turns this into a real `HookConflict`, non-zero exit.
|
|
15
122
|
*/
|
|
16
|
-
import
|
|
123
|
+
import { createHash } from 'node:crypto'
|
|
124
|
+
import type { RenderedCursorEntry, RenderedGroup, RenderedHookCommand } from './render.ts'
|
|
17
125
|
|
|
18
126
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
127
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
20
128
|
}
|
|
21
129
|
|
|
130
|
+
/** sha256 of a hook's OWN fields PLUS its group's `matcher` and the event name (2026-09-30, finding 2E: matcher used to be omitted as "a group-level field", which let a byte-identical copy pasted into a different matcher's group hash-match anyway). */
|
|
131
|
+
function hashHook(hook: { readonly type: string; readonly command: string; readonly statusMessage: string; readonly timeout: number }, matcher: string | undefined, event: string): string {
|
|
132
|
+
return createHash('sha256')
|
|
133
|
+
.update(JSON.stringify({ event, matcher: matcher ?? null, type: hook.type, command: hook.command, statusMessage: hook.statusMessage, timeout: hook.timeout }))
|
|
134
|
+
.digest('hex')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** sha256 of a cursor entry's own fields, INCLUDING `matcher` (entry-level here) and the event name — same finding 2E rationale as `hashHook`. */
|
|
138
|
+
function hashCursorEntry(entry: { readonly command: string; readonly statusMessage: string; readonly timeout: number; readonly matcher?: string }, event: string): string {
|
|
139
|
+
return createHash('sha256')
|
|
140
|
+
.update(JSON.stringify({ event, command: entry.command, statusMessage: entry.statusMessage, timeout: entry.timeout, matcher: entry.matcher ?? null }))
|
|
141
|
+
.digest('hex')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface OwnedEntry {
|
|
145
|
+
readonly hash: string
|
|
146
|
+
readonly command: string
|
|
147
|
+
/** The matcher this entry was recorded under (group-level for claude/codex, entry-level for cursor) — `undefined` when adl rendered no matcher. Needed by the PAIR fallback (2G/2F/H): "no verified hash exists for this (command, matcher)" cannot be answered from `command` alone. */
|
|
148
|
+
readonly matcher?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function pairKey(command: string, matcher: string | undefined): string {
|
|
152
|
+
return `${command}\u0000${matcher ?? ''}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Builds an `OwnedEntry`, omitting `matcher` entirely when undefined (this package's `exactOptionalPropertyTypes` refuses an explicit `matcher: undefined`). */
|
|
156
|
+
function makeOwnedEntry(hash: string, command: string, matcher: string | undefined): OwnedEntry {
|
|
157
|
+
return matcher !== undefined ? { hash, command, matcher } : { hash, command }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRecordHookCommand(value: unknown): value is RenderedHookCommand {
|
|
161
|
+
return isRecord(value) && typeof value.type === 'string' && typeof value.command === 'string' && typeof value.statusMessage === 'string' && typeof value.timeout === 'number'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One RECORDED pair's decoded identity, for building conflict/restore messages without re-parsing `pairKey`'s own encoding. */
|
|
165
|
+
interface PairIdentity {
|
|
166
|
+
readonly command: string
|
|
167
|
+
readonly matcher: string | undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-pair classification for one event, computed in a first pass over
|
|
172
|
+
* EVERY existing hook-like entry before any restore/split decision is
|
|
173
|
+
* made (2026-09-30, round 5 — see this file's header for the full
|
|
174
|
+
* history). Only pairs adl actually RECORDED are ever classified; a pair
|
|
175
|
+
* with no recorded entry at all is simply not adl's concern here.
|
|
176
|
+
*
|
|
177
|
+
* - `healablePairs`: zero verified copies, EXACTLY one unverified
|
|
178
|
+
* candidate — heals in place, named in `restored`.
|
|
179
|
+
* - `ambiguousPairs`: zero verified copies, MORE than one unverified
|
|
180
|
+
* candidate — genuinely ambiguous, healing either is a guess. Ownership
|
|
181
|
+
* of NEITHER is claimed; the whole event freezes (see the merge
|
|
182
|
+
* functions below) and this pair is reported as a conflict.
|
|
183
|
+
* - `duplicateCommands`: at least one verified copy, AND (more than one
|
|
184
|
+
* verified copy, OR at least one unverified copy sharing the SAME
|
|
185
|
+
* pair) — a real duplicate sitting alongside a provably-intact
|
|
186
|
+
* original. Reported for doctor, never affects the merge itself (the
|
|
187
|
+
* verified copy stays owned via its hash regardless; the extra
|
|
188
|
+
* copy(ies) are simply the user's, kept as-is).
|
|
189
|
+
*/
|
|
190
|
+
interface PairClassification {
|
|
191
|
+
readonly healablePairs: ReadonlySet<string>
|
|
192
|
+
readonly ambiguousPairs: ReadonlySet<string>
|
|
193
|
+
readonly ambiguousIdentities: readonly PairIdentity[]
|
|
194
|
+
readonly duplicateCommands: readonly string[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function classifyPairs(
|
|
198
|
+
hookLikes: readonly { readonly command: string; readonly hash: string; readonly matcher: string | undefined }[],
|
|
199
|
+
hashSet: ReadonlySet<string>,
|
|
200
|
+
recordedPairs: ReadonlyMap<string, PairIdentity>,
|
|
201
|
+
): PairClassification {
|
|
202
|
+
const verifiedCount = new Map<string, number>()
|
|
203
|
+
const unverifiedCount = new Map<string, number>()
|
|
204
|
+
for (const { command, hash, matcher } of hookLikes) {
|
|
205
|
+
const key = pairKey(command, matcher)
|
|
206
|
+
if (!recordedPairs.has(key)) continue // only adl-recorded pairs matter for heal/ambiguity/duplicate reasoning
|
|
207
|
+
if (hashSet.has(hash)) verifiedCount.set(key, (verifiedCount.get(key) ?? 0) + 1)
|
|
208
|
+
else unverifiedCount.set(key, (unverifiedCount.get(key) ?? 0) + 1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const healablePairs = new Set<string>()
|
|
212
|
+
const ambiguousPairs = new Set<string>()
|
|
213
|
+
const ambiguousIdentities: PairIdentity[] = []
|
|
214
|
+
const duplicateCommandSet = new Set<string>()
|
|
215
|
+
for (const [key, identity] of recordedPairs) {
|
|
216
|
+
const v = verifiedCount.get(key) ?? 0
|
|
217
|
+
const u = unverifiedCount.get(key) ?? 0
|
|
218
|
+
if (v === 0 && u > 1) {
|
|
219
|
+
ambiguousPairs.add(key)
|
|
220
|
+
ambiguousIdentities.push(identity)
|
|
221
|
+
} else if (v === 0 && u === 1) {
|
|
222
|
+
healablePairs.add(key)
|
|
223
|
+
} else if (v >= 1 && (v > 1 || u >= 1)) {
|
|
224
|
+
duplicateCommandSet.add(identity.command)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: [...duplicateCommandSet].sort() }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface PairContext {
|
|
231
|
+
readonly hashSet: ReadonlySet<string>
|
|
232
|
+
readonly healablePairs: ReadonlySet<string>
|
|
233
|
+
readonly legacyCommandsAnywhere: ReadonlySet<string>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One healed hook's identity, for the `restored` report ("restored adl hook <event>/<matcher>/<command> — local changes to it were replaced"). */
|
|
237
|
+
export interface RestoredHook {
|
|
238
|
+
readonly command: string
|
|
239
|
+
readonly matcher?: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Content-addressed ownership check for one existing hook/entry. The
|
|
244
|
+
* fallback is a PAIR rule, never a position/index rule (2026-09-30,
|
|
245
|
+
* findings 2G/2F/H): a non-hash-matching candidate heals in place iff
|
|
246
|
+
* its (command, matcher) pair is in `healablePairs` — which
|
|
247
|
+
* `classifyPairs` (above) has ALREADY confirmed has zero verified
|
|
248
|
+
* copies and EXACTLY one unverified candidate, so there is no ambiguity
|
|
249
|
+
* left to resolve here.
|
|
250
|
+
*/
|
|
251
|
+
function isOwnedHook(
|
|
252
|
+
candidate: { readonly command: string },
|
|
253
|
+
candidateHash: string,
|
|
254
|
+
matcher: string | undefined,
|
|
255
|
+
ctx: PairContext,
|
|
256
|
+
): boolean {
|
|
257
|
+
if (ctx.hashSet.has(candidateHash)) return true
|
|
258
|
+
if (ctx.legacyCommandsAnywhere.has(candidate.command)) return true
|
|
259
|
+
return ctx.healablePairs.has(pairKey(candidate.command, matcher))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Splits an existing group's own `hooks` array into adl's (owned) and the user's (everything else), preserving the group's `matcher`. Collects any hook healed via the PAIR fallback (not hash, not legacy) into `restored`. */
|
|
263
|
+
function splitGroupHooks(
|
|
264
|
+
hooks: readonly unknown[],
|
|
265
|
+
matcher: string | undefined,
|
|
266
|
+
event: string,
|
|
267
|
+
ctx: PairContext,
|
|
268
|
+
restored: RestoredHook[],
|
|
269
|
+
): { readonly ownedCount: number; readonly userHooks: readonly unknown[] } {
|
|
270
|
+
let ownedCount = 0
|
|
271
|
+
const userHooks: unknown[] = []
|
|
272
|
+
for (const hook of hooks) {
|
|
273
|
+
if (!isRecordHookCommand(hook)) {
|
|
274
|
+
userHooks.push(hook)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
const hash = hashHook(hook, matcher, event)
|
|
278
|
+
if (isOwnedHook(hook, hash, matcher, ctx)) {
|
|
279
|
+
ownedCount += 1
|
|
280
|
+
if (!ctx.hashSet.has(hash) && !ctx.legacyCommandsAnywhere.has(hook.command)) {
|
|
281
|
+
restored.push(matcher !== undefined ? { command: hook.command, matcher } : { command: hook.command })
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
userHooks.push(hook)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { ownedCount, userHooks }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One event's ambiguous-pair conflict, for `JsonHooksMergeResult.ambiguousConflicts`/`CursorHooksMergeResult.ambiguousConflicts`. */
|
|
291
|
+
export interface AmbiguousPairConflict {
|
|
292
|
+
readonly event: string
|
|
293
|
+
readonly command: string
|
|
294
|
+
readonly matcher?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Builds an `AmbiguousPairConflict`, omitting `matcher` entirely when undefined (same `exactOptionalPropertyTypes` concern as `makeOwnedEntry`). */
|
|
298
|
+
function makeAmbiguousConflict(event: string, identity: PairIdentity): AmbiguousPairConflict {
|
|
299
|
+
return identity.matcher !== undefined ? { event, command: identity.command, matcher: identity.matcher } : { event, command: identity.command }
|
|
300
|
+
}
|
|
301
|
+
|
|
22
302
|
export interface JsonHooksMergeResult {
|
|
23
303
|
/** The complete object to JSON.stringify and write. */
|
|
24
304
|
readonly fileContent: Record<string, unknown>
|
|
25
|
-
/** New lock contents: surface event name -> the command strings this deploy now owns there. */
|
|
305
|
+
/** New lock contents: surface event name -> the command strings this deploy now owns there (informational — list.ts). */
|
|
26
306
|
readonly ownedCommands: Readonly<Record<string, readonly string[]>>
|
|
307
|
+
/** New lock contents: surface event name -> this deploy's own written hooks, content-addressed — the actual ownership record the next run reads back. */
|
|
308
|
+
readonly ownedEntries: Readonly<Record<string, readonly OwnedEntry[]>>
|
|
27
309
|
readonly changed: boolean
|
|
28
310
|
/** Events whose array actually differs from what was already on disk — doctor.ts's per-entry reasons. */
|
|
29
311
|
readonly changedEvents: readonly string[]
|
|
312
|
+
/** Event -> adl-owned commands with a genuine duplicate (2026-09-30, round 5, finding 1: pair-scoped — a different matcher is never counted). Set even when the merge is otherwise a structural no-op, since the duplicate can sit in an already-unchanged file. doctor.ts turns this into its own dedicated issue. */
|
|
313
|
+
readonly duplicateCommands: Readonly<Record<string, readonly string[]>>
|
|
314
|
+
/** Hooks healed via the PAIR fallback this run (not by hash, not by the legacy channel) — named so deploy/doctor/`adl install` can report "restored adl hook <event>/<matcher>/<command>" (2026-09-30, round 5, finding 2). */
|
|
315
|
+
readonly restored: readonly (RestoredHook & { readonly event: string })[]
|
|
316
|
+
/** Events frozen this run because more than one unverified candidate shared a recorded pair — genuinely ambiguous, neither healed, the event's array left exactly as found (2026-09-30, round 5, finding 2). deploy.ts turns each into a real conflict. */
|
|
317
|
+
readonly ambiguousConflicts: readonly AmbiguousPairConflict[]
|
|
30
318
|
}
|
|
31
319
|
|
|
32
320
|
/** For claude-settings / codex-hooks: `{hooks: {Event: [{matcher?, hooks: [...]}]}}`. */
|
|
33
321
|
export function mergeJsonHooksFile(
|
|
34
322
|
existing: Record<string, unknown> | undefined,
|
|
35
323
|
desired: ReadonlyMap<string, readonly RenderedGroup[]>,
|
|
36
|
-
|
|
324
|
+
recordedEntries: Readonly<Record<string, readonly OwnedEntry[]>>,
|
|
325
|
+
legacyOwnedCommands: Readonly<Record<string, readonly string[]>> = {},
|
|
37
326
|
): JsonHooksMergeResult {
|
|
38
327
|
const base: Record<string, unknown> = existing ? { ...existing } : {}
|
|
39
328
|
const existingHooks = isRecord(base.hooks) ? base.hooks : {}
|
|
@@ -43,21 +332,83 @@ export function mergeJsonHooksFile(
|
|
|
43
332
|
|
|
44
333
|
const newHooks: Record<string, unknown> = {}
|
|
45
334
|
const ownedCommands: Record<string, readonly string[]> = {}
|
|
335
|
+
const ownedEntries: Record<string, readonly OwnedEntry[]> = {}
|
|
46
336
|
const changedEvents: string[] = []
|
|
337
|
+
const duplicateCommands: Record<string, readonly string[]> = {}
|
|
338
|
+
const restored: (RestoredHook & { readonly event: string })[] = []
|
|
339
|
+
const ambiguousConflicts: AmbiguousPairConflict[] = []
|
|
47
340
|
let changed = existing === undefined
|
|
48
341
|
|
|
49
342
|
for (const event of orderedEvents) {
|
|
50
343
|
const existingGroups = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
})
|
|
344
|
+
const recorded = recordedEntries[event] ?? []
|
|
345
|
+
const hashSet = new Set(recorded.map((r) => r.hash))
|
|
346
|
+
const recordedPairs = new Map(recorded.map((r) => [pairKey(r.command, r.matcher), { command: r.command, matcher: r.matcher }] as const))
|
|
347
|
+
const legacyCommandsAnywhere = new Set(legacyOwnedCommands[event] ?? [])
|
|
56
348
|
const desiredGroups = desired.get(event) ?? []
|
|
349
|
+
|
|
350
|
+
// Pass 1: classify every existing hook-like by (command, matcher)
|
|
351
|
+
// pair BEFORE any restore/split decision — the fallback rule for one
|
|
352
|
+
// entry depends on whether ANOTHER entry (anywhere) already proves
|
|
353
|
+
// the real one is intact, or shares its own ambiguity.
|
|
354
|
+
const existingHookLikes = existingGroups.flatMap((group) =>
|
|
355
|
+
isRecord(group) && Array.isArray(group.hooks)
|
|
356
|
+
? group.hooks.filter(isRecordHookCommand).map((hook) => ({ command: hook.command, hash: hashHook(hook, group.matcher as string | undefined, event), matcher: group.matcher as string | undefined }))
|
|
357
|
+
: [],
|
|
358
|
+
)
|
|
359
|
+
const { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: dupes } = classifyPairs(existingHookLikes, hashSet, recordedPairs)
|
|
360
|
+
if (dupes.length > 0) duplicateCommands[event] = dupes
|
|
361
|
+
|
|
362
|
+
if (ambiguousPairs.size > 0) {
|
|
363
|
+
// Genuinely ambiguous: more than one unverified candidate shares a
|
|
364
|
+
// pair adl recorded. Heal neither — freeze this WHOLE event exactly
|
|
365
|
+
// as found, carry the lock's own record forward unchanged (so a
|
|
366
|
+
// later run re-evaluates the same state, never silently forgetting
|
|
367
|
+
// ownership), and report it as a conflict.
|
|
368
|
+
for (const identity of ambiguousIdentities) ambiguousConflicts.push(makeAmbiguousConflict(event, identity))
|
|
369
|
+
if (recorded.length > 0) {
|
|
370
|
+
ownedCommands[event] = recorded.map((r) => r.command)
|
|
371
|
+
ownedEntries[event] = recorded
|
|
372
|
+
}
|
|
373
|
+
if (existingGroups.length > 0) newHooks[event] = existingGroups
|
|
374
|
+
continue // no consumeDesired at all -- nothing in this event changes this run
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const ctx: PairContext = { hashSet, healablePairs, legacyCommandsAnywhere }
|
|
378
|
+
const restoredThisEvent: RestoredHook[] = []
|
|
379
|
+
|
|
380
|
+
const merged: unknown[] = []
|
|
381
|
+
let desiredCursor = 0
|
|
382
|
+
const consumeDesired = (): void => {
|
|
383
|
+
if (desiredCursor < desiredGroups.length) {
|
|
384
|
+
merged.push(desiredGroups[desiredCursor])
|
|
385
|
+
desiredCursor += 1
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const group of existingGroups) {
|
|
390
|
+
if (!isRecord(group) || !Array.isArray(group.hooks) || group.hooks.length === 0) {
|
|
391
|
+
merged.push(group)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
const { ownedCount, userHooks } = splitGroupHooks(group.hooks, group.matcher as string | undefined, event, ctx, restoredThisEvent)
|
|
395
|
+
if (ownedCount === 0) {
|
|
396
|
+
merged.push(group) // fully the user's -- untouched, no desired slot consumed
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (userHooks.length > 0) {
|
|
400
|
+
// split: the user's remainder survives in its own group, right here.
|
|
401
|
+
merged.push(group.matcher !== undefined ? { matcher: group.matcher, hooks: userHooks } : { hooks: userHooks })
|
|
402
|
+
}
|
|
403
|
+
consumeDesired() // restore/replace adl's slot at this position
|
|
404
|
+
}
|
|
405
|
+
while (desiredCursor < desiredGroups.length) consumeDesired()
|
|
406
|
+
for (const hook of restoredThisEvent) restored.push({ ...hook, event })
|
|
407
|
+
|
|
57
408
|
if (desiredGroups.length > 0) {
|
|
58
409
|
ownedCommands[event] = desiredGroups.flatMap((g) => g.hooks.map((h) => h.command))
|
|
410
|
+
ownedEntries[event] = desiredGroups.flatMap((g) => g.hooks.map((h) => makeOwnedEntry(hashHook(h, g.matcher, event), h.command, g.matcher)))
|
|
59
411
|
}
|
|
60
|
-
const merged = [...keptGroups, ...desiredGroups]
|
|
61
412
|
if (merged.length > 0) newHooks[event] = merged
|
|
62
413
|
if (JSON.stringify(existingHooks[event] ?? null) !== JSON.stringify(merged.length > 0 ? merged : null)) {
|
|
63
414
|
changed = true
|
|
@@ -66,23 +417,32 @@ export function mergeJsonHooksFile(
|
|
|
66
417
|
}
|
|
67
418
|
|
|
68
419
|
base.hooks = newHooks
|
|
69
|
-
return { fileContent: base, ownedCommands, changed, changedEvents }
|
|
420
|
+
return { fileContent: base, ownedCommands, ownedEntries, changed, changedEvents, duplicateCommands, restored, ambiguousConflicts }
|
|
70
421
|
}
|
|
71
422
|
|
|
72
423
|
export interface CursorHooksMergeResult {
|
|
73
424
|
readonly fileContent: Record<string, unknown>
|
|
74
|
-
/** New lock contents: Cursor's own event name -> the command strings this deploy now owns there. */
|
|
425
|
+
/** New lock contents: Cursor's own event name -> the command strings this deploy now owns there (informational — list.ts). */
|
|
75
426
|
readonly ownedCommands: Readonly<Record<string, readonly string[]>>
|
|
427
|
+
/** New lock contents: Cursor's own event name -> this deploy's own written entries, content-addressed. */
|
|
428
|
+
readonly ownedEntries: Readonly<Record<string, readonly OwnedEntry[]>>
|
|
76
429
|
readonly changed: boolean
|
|
77
430
|
/** Events whose array actually differs from what was already on disk — doctor.ts's per-entry reasons. */
|
|
78
431
|
readonly changedEvents: readonly string[]
|
|
432
|
+
/** Event -> adl-owned commands with a genuine duplicate — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
433
|
+
readonly duplicateCommands: Readonly<Record<string, readonly string[]>>
|
|
434
|
+
/** Entries healed via the PAIR fallback this run — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
435
|
+
readonly restored: readonly (RestoredHook & { readonly event: string })[]
|
|
436
|
+
/** Events frozen this run because of an ambiguous pair — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
437
|
+
readonly ambiguousConflicts: readonly AmbiguousPairConflict[]
|
|
79
438
|
}
|
|
80
439
|
|
|
81
|
-
/** For cursor-hooks-v1: `{version: 1, hooks: {event: [{command, statusMessage, timeout, matcher?}]}}`. */
|
|
440
|
+
/** For cursor-hooks-v1: `{version: 1, hooks: {event: [{command, statusMessage, timeout, matcher?}]}}`. No groups here — each entry is classified (and, unlike claude/codex, never split) on its own. */
|
|
82
441
|
export function mergeCursorHooksFile(
|
|
83
442
|
existing: Record<string, unknown> | undefined,
|
|
84
443
|
desired: ReadonlyMap<string, readonly RenderedCursorEntry[]>,
|
|
85
|
-
|
|
444
|
+
recordedEntries: Readonly<Record<string, readonly OwnedEntry[]>>,
|
|
445
|
+
legacyOwnedCommands: Readonly<Record<string, readonly string[]>> = {},
|
|
86
446
|
): CursorHooksMergeResult {
|
|
87
447
|
const base: Record<string, unknown> = existing ? { ...existing } : { version: 1 }
|
|
88
448
|
if (base.version === undefined) base.version = 1
|
|
@@ -93,16 +453,68 @@ export function mergeCursorHooksFile(
|
|
|
93
453
|
|
|
94
454
|
const newHooks: Record<string, unknown> = {}
|
|
95
455
|
const ownedCommands: Record<string, readonly string[]> = {}
|
|
456
|
+
const ownedEntries: Record<string, readonly OwnedEntry[]> = {}
|
|
96
457
|
const changedEvents: string[] = []
|
|
458
|
+
const duplicateCommands: Record<string, readonly string[]> = {}
|
|
459
|
+
const restored: (RestoredHook & { readonly event: string })[] = []
|
|
460
|
+
const ambiguousConflicts: AmbiguousPairConflict[] = []
|
|
97
461
|
let changed = existing === undefined
|
|
98
462
|
|
|
99
463
|
for (const event of orderedEvents) {
|
|
100
464
|
const existingEntries = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []
|
|
101
|
-
const
|
|
102
|
-
const
|
|
465
|
+
const recorded = recordedEntries[event] ?? []
|
|
466
|
+
const hashSet = new Set(recorded.map((r) => r.hash))
|
|
467
|
+
const recordedPairs = new Map(recorded.map((r) => [pairKey(r.command, r.matcher), { command: r.command, matcher: r.matcher }] as const))
|
|
468
|
+
const legacyCommandsAnywhere = new Set(legacyOwnedCommands[event] ?? [])
|
|
103
469
|
const desiredEntries = desired.get(event) ?? []
|
|
104
|
-
|
|
105
|
-
const
|
|
470
|
+
|
|
471
|
+
const existingHookLikes = existingEntries
|
|
472
|
+
.filter((entry): entry is Record<string, unknown> => isRecord(entry) && typeof entry.command === 'string')
|
|
473
|
+
.map((entry) => ({ command: entry.command as string, hash: hashCursorEntry(entry as never, event), matcher: entry.matcher as string | undefined }))
|
|
474
|
+
const { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: dupes } = classifyPairs(existingHookLikes, hashSet, recordedPairs)
|
|
475
|
+
if (dupes.length > 0) duplicateCommands[event] = dupes
|
|
476
|
+
|
|
477
|
+
if (ambiguousPairs.size > 0) {
|
|
478
|
+
for (const identity of ambiguousIdentities) ambiguousConflicts.push(makeAmbiguousConflict(event, identity))
|
|
479
|
+
if (recorded.length > 0) {
|
|
480
|
+
ownedCommands[event] = recorded.map((r) => r.command)
|
|
481
|
+
ownedEntries[event] = recorded
|
|
482
|
+
}
|
|
483
|
+
if (existingEntries.length > 0) newHooks[event] = existingEntries
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const merged: unknown[] = []
|
|
488
|
+
let desiredCursor = 0
|
|
489
|
+
for (const entry of existingEntries) {
|
|
490
|
+
if (!isRecord(entry) || typeof entry.command !== 'string') {
|
|
491
|
+
merged.push(entry)
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
const matcher = entry.matcher as string | undefined
|
|
495
|
+
const hash = hashCursorEntry(entry as never, event)
|
|
496
|
+
const isOwned = isOwnedHook(entry as { command: string }, hash, matcher, { hashSet, healablePairs, legacyCommandsAnywhere })
|
|
497
|
+
if (!isOwned) {
|
|
498
|
+
merged.push(entry)
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
if (!hashSet.has(hash) && !legacyCommandsAnywhere.has(entry.command)) {
|
|
502
|
+
restored.push(matcher !== undefined ? { command: entry.command, matcher, event } : { command: entry.command, event })
|
|
503
|
+
}
|
|
504
|
+
if (desiredCursor < desiredEntries.length) {
|
|
505
|
+
merged.push(desiredEntries[desiredCursor])
|
|
506
|
+
desiredCursor += 1
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
while (desiredCursor < desiredEntries.length) {
|
|
510
|
+
merged.push(desiredEntries[desiredCursor])
|
|
511
|
+
desiredCursor += 1
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (desiredEntries.length > 0) {
|
|
515
|
+
ownedCommands[event] = desiredEntries.map((e) => e.command)
|
|
516
|
+
ownedEntries[event] = desiredEntries.map((e) => makeOwnedEntry(hashCursorEntry(e, event), e.command, e.matcher))
|
|
517
|
+
}
|
|
106
518
|
if (merged.length > 0) newHooks[event] = merged
|
|
107
519
|
if (JSON.stringify(existingHooks[event] ?? null) !== JSON.stringify(merged.length > 0 ? merged : null)) {
|
|
108
520
|
changed = true
|
|
@@ -111,5 +523,5 @@ export function mergeCursorHooksFile(
|
|
|
111
523
|
}
|
|
112
524
|
|
|
113
525
|
base.hooks = newHooks
|
|
114
|
-
return { fileContent: base, ownedCommands, changed, changedEvents }
|
|
526
|
+
return { fileContent: base, ownedCommands, ownedEntries, changed, changedEvents, duplicateCommands, restored, ambiguousConflicts }
|
|
115
527
|
}
|
|
@@ -17,10 +17,41 @@ import { dirname, join } from 'node:path'
|
|
|
17
17
|
|
|
18
18
|
export const LOCK_RELATIVE_PATH = '.agents/agent-hooks.lock.json'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One adl-written hook's content-addressed identity: `hash` is sha256 of
|
|
22
|
+
* its OWN fields PLUS its `matcher` and event name (2026-09-30, finding
|
|
23
|
+
* 2E — matcher used to be left out as "a group-level field", which let a
|
|
24
|
+
* byte-identical hook object pasted into a DIFFERENT matcher's group
|
|
25
|
+
* still hash-match); `command` and `matcher` are kept alongside the hash
|
|
26
|
+
* for the PAIR fallback (json-merge.ts's own header has the full rule):
|
|
27
|
+
* a non-hash-matching entry heals in place only when no OTHER entry
|
|
28
|
+
* anywhere already hash-verifies this exact (command, matcher) pair —
|
|
29
|
+
* never by array position/index, which the second independent review
|
|
30
|
+
* already ruled unreliable.
|
|
31
|
+
*/
|
|
32
|
+
export interface OwnedHookEntry {
|
|
33
|
+
readonly hash: string
|
|
34
|
+
readonly command: string
|
|
35
|
+
readonly matcher?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export interface JsonHooksLockEntry {
|
|
21
39
|
readonly format: 'claude-settings' | 'codex-hooks' | 'cursor-hooks-v1'
|
|
22
|
-
/** Surface event name -> the exact command strings (with --host) this deploy wrote into that event's array. */
|
|
40
|
+
/** Surface event name -> the exact command strings (with --host) this deploy wrote into that event's array — informational only (list.ts); ownership itself is `ownedEntries`, below. */
|
|
23
41
|
readonly ownedCommands: Readonly<Record<string, readonly string[]>>
|
|
42
|
+
/**
|
|
43
|
+
* Surface event name -> this deploy's own written hooks, content-
|
|
44
|
+
* addressed (second independent review, 2026-09-29: the FIRST fix here,
|
|
45
|
+
* pure array-index tracking, broke the moment a user inserted or
|
|
46
|
+
* removed an entry before adl's own block, and could not express
|
|
47
|
+
* "split a group" when a user hook was appended inside one — see
|
|
48
|
+
* json-merge.ts's header for the corrected rule). Absent on a lock
|
|
49
|
+
* written before EITHER ownership fix existed (every repo enrolled
|
|
50
|
+
* before this one) — every reader treats that as "derive ownership
|
|
51
|
+
* once from `ownedCommands` by exact command match, then persist real
|
|
52
|
+
* entries here," never as malformed.
|
|
53
|
+
*/
|
|
54
|
+
readonly ownedEntries?: Readonly<Record<string, readonly OwnedHookEntry[]>>
|
|
24
55
|
}
|
|
25
56
|
|
|
26
57
|
export interface OpencodePluginLockEntry {
|
|
@@ -58,8 +89,11 @@ export function readLock(targetRepo: string): HookLock {
|
|
|
58
89
|
return { version: 1, entries: record.entries as Record<string, HookLockEntry> }
|
|
59
90
|
}
|
|
60
91
|
|
|
92
|
+
/** Skips the write entirely when the serialized content is already byte-identical (independent review, 2026-09-29, finding 8: a genuine no-op run must not touch the lock file's mtime either). */
|
|
61
93
|
export function writeLock(targetRepo: string, lock: HookLock): void {
|
|
62
94
|
const path = lockPathFor(targetRepo)
|
|
95
|
+
const next = `${JSON.stringify(lock, null, 2)}\n`
|
|
96
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === next) return
|
|
63
97
|
mkdirSync(dirname(path), { recursive: true })
|
|
64
|
-
writeFileSync(path,
|
|
98
|
+
writeFileSync(path, next)
|
|
65
99
|
}
|
|
@@ -71,6 +71,22 @@ export function resolveLeanCtxDir(root: string): string {
|
|
|
71
71
|
return decoded.value.hooks.leanCtxDir ?? DEFAULT_LEAN_CTX_DIR
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Set on EVERY `lean-ctx` child this package spawns, managed repo or not.
|
|
76
|
+
*
|
|
77
|
+
* lean-ctx 3.10.5's MCP `initialize` handler runs a startup maintenance
|
|
78
|
+
* block that calls `refresh_installed_hooks()`, re-writing raw
|
|
79
|
+
* `lean-ctx hook …` entries into the GLOBAL `~/.claude/settings.json`
|
|
80
|
+
* (upstream `rust/src/server/server_handler.rs` ~L180-209, issue #1879).
|
|
81
|
+
* `[setup] auto_inject_*` does not gate it; the only switch in that release
|
|
82
|
+
* is `LEAN_CTX_HEADLESS` (any value), checked first in that block. Measured
|
|
83
|
+
* 2026-09-30 on a real $HOME: unset → nine global hook entries re-appeared
|
|
84
|
+
* seconds after start; set → none, and the same 12 MCP tools are served.
|
|
85
|
+
* Those global hooks are what put lean-ctx's global shell allowlist in front
|
|
86
|
+
* of agents in unrelated, unmanaged folders — lean-ctx stays repo-local only.
|
|
87
|
+
*/
|
|
88
|
+
export const LEAN_CTX_HEADLESS_ENV: Readonly<Record<'LEAN_CTX_HEADLESS', string>> = { LEAN_CTX_HEADLESS: '1' }
|
|
89
|
+
|
|
74
90
|
/**
|
|
75
91
|
* Build the env for a `lean-ctx` subprocess rooted at repository `root`,
|
|
76
92
|
* merged over `base` (normally `inv.env`/`process.env`) so every other
|
|
@@ -87,6 +103,7 @@ export function leanCtxEnv(
|
|
|
87
103
|
LEAN_CTX_DATA_DIR: join(root, leanCtxDir, 'data'),
|
|
88
104
|
LEAN_CTX_STATE_DIR: join(root, leanCtxDir, 'state'),
|
|
89
105
|
LEAN_CTX_CACHE_DIR: join(root, leanCtxDir, 'cache'),
|
|
106
|
+
...LEAN_CTX_HEADLESS_ENV,
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
import { join } from 'node:path'
|
|
39
39
|
import { findGitRoot, MARKER, realRootIO, type RootIO } from '../gating/root.ts'
|
|
40
|
-
import { leanCtxEnvFor } from './env.ts'
|
|
40
|
+
import { LEAN_CTX_HEADLESS_ENV, leanCtxEnvFor } from './env.ts'
|
|
41
41
|
|
|
42
42
|
export interface LeanCtxMcpEnvResult {
|
|
43
43
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
@@ -111,7 +111,9 @@ export async function runLeanCtxMcp(
|
|
|
111
111
|
const { env, note } = resolveLeanCtxMcpEnv(cwd, baseEnv, deps.io ?? realRootIO)
|
|
112
112
|
if (note) writeStderr(note)
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Headless in an unmanaged folder too: see LEAN_CTX_HEADLESS_ENV — without
|
|
115
|
+
// it this very start re-writes lean-ctx's hooks into the global settings.
|
|
116
|
+
const child = spawn(['lean-ctx', 'mcp'], { cwd, env: { ...env, ...LEAN_CTX_HEADLESS_ENV } })
|
|
115
117
|
|
|
116
118
|
const onSigint = () => {
|
|
117
119
|
try {
|