@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.
Files changed (42) hide show
  1. package/bin/adl +2 -2
  2. package/bin/git +89 -8
  3. package/bin/lean-ctx-observe-strip +76 -3
  4. package/bin/reconcile +175 -5
  5. package/bin/validate-manifest +19 -1
  6. package/package.json +1 -1
  7. package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
  8. package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
  9. package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
  10. package/packages/agent-hooks/src/deploy/lock.ts +36 -2
  11. package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
  12. package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
  13. package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
  14. package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
  15. package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
  16. package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
  17. package/packages/agent-settings/src/deploy/doctor.ts +39 -3
  18. package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
  19. package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
  20. package/packages/agent-settings/src/deploy/lock.ts +4 -1
  21. package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
  22. package/packages/agent-settings/src/deploy/settings.ts +6 -3
  23. package/packages/agent-skills/dist/deploy.d.ts +2 -0
  24. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  25. package/packages/agent-skills/dist/deploy.js +15 -8
  26. package/packages/agent-skills/dist/deploy.js.map +1 -1
  27. package/packages/agent-skills/dist/manifest.d.ts +1 -0
  28. package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
  29. package/packages/agent-skills/dist/manifest.js +6 -2
  30. package/packages/agent-skills/dist/manifest.js.map +1 -1
  31. package/packages/agent-skills/src/deploy.ts +15 -8
  32. package/packages/agent-skills/src/manifest.ts +5 -2
  33. package/packages/azdo/src/azdo-repo-doctor.ts +412 -95
  34. package/packages/azdo/src/lib/graph-identity.ts +190 -3
  35. package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
  36. package/packages/fleet/manifest.json +2 -0
  37. package/packages/fleet/manifest.schema.json +9 -0
  38. package/packages/repo-controls/README.md +13 -6
  39. package/packages/repo-controls/assets/adl-config.default.toml +1 -0
  40. package/packages/repo-controls/assets/version-bump.sh +6 -1
  41. package/packages/repo-controls/src/cli.ts +6 -1
  42. 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 type { RenderedCursorEntry, RenderedGroup } from './render.ts'
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
- previousOwned: Readonly<Record<string, readonly string[]>>,
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 prevOwnedSet = new Set(previousOwned[event] ?? [])
52
- const keptGroups = existingGroups.filter((group) => {
53
- if (!isRecord(group) || !Array.isArray(group.hooks) || group.hooks.length === 0) return true
54
- return !group.hooks.every((h) => isRecord(h) && typeof h.command === 'string' && prevOwnedSet.has(h.command))
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
- previousOwned: Readonly<Record<string, readonly string[]>>,
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 prevOwnedSet = new Set(previousOwned[event] ?? [])
102
- const keptEntries = existingEntries.filter((entry) => !(isRecord(entry) && typeof entry.command === 'string' && prevOwnedSet.has(entry.command)))
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
- if (desiredEntries.length > 0) ownedCommands[event] = desiredEntries.map((e) => e.command)
105
- const merged = [...keptEntries, ...desiredEntries]
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, `${JSON.stringify(lock, null, 2)}\n`)
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
- const child = spawn(['lean-ctx', 'mcp'], { cwd, env: env as Record<string, string | undefined> })
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 {
@@ -1,7 +1,10 @@
1
+ # >>> adl managed (do not edit within this block) >>>
1
2
  minimal_overhead = true
2
3
  structure_first = true
3
4
  journal_enabled = true
4
5
  auto_capture = true
6
+ update_check_disabled = true
7
+ # <<< adl managed <<<
5
8
  shell_allowlist_extra = [
6
9
  "pi",
7
10
  "install",
@@ -1,6 +1,10 @@
1
+ # >>> adl managed (do not edit within this block) >>>
1
2
  /lean-ctx/data/
2
3
  /lean-ctx/cache/
3
4
  /lean-ctx/state/
4
5
  /lean-ctx/bin/
5
6
  /hooks/hooks.log
6
7
  /hooks/hooks-activity.log
8
+ /lean-ctx/config/lean-ctx/workspace-trust.toml
9
+ *.bak
10
+ # <<< adl managed <<<