@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.6 → 0.1.8

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 (41) hide show
  1. package/bin/adl +2 -2
  2. package/bin/lean-ctx-observe-strip +76 -3
  3. package/bin/reconcile +460 -240
  4. package/bin/validate-manifest +36 -16
  5. package/package.json +1 -1
  6. package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
  7. package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
  8. package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
  9. package/packages/agent-hooks/src/deploy/lock.ts +36 -2
  10. package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
  11. package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
  12. package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
  13. package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
  14. package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
  15. package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
  16. package/packages/agent-settings/src/deploy/doctor.ts +39 -3
  17. package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
  18. package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
  19. package/packages/agent-settings/src/deploy/lock.ts +4 -1
  20. package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
  21. package/packages/agent-settings/src/deploy/settings.ts +6 -3
  22. package/packages/agent-skills/dist/deploy.d.ts +2 -0
  23. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  24. package/packages/agent-skills/dist/deploy.js +15 -8
  25. package/packages/agent-skills/dist/deploy.js.map +1 -1
  26. package/packages/agent-skills/dist/manifest.d.ts +1 -0
  27. package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
  28. package/packages/agent-skills/dist/manifest.js +6 -2
  29. package/packages/agent-skills/dist/manifest.js.map +1 -1
  30. package/packages/agent-skills/src/deploy.ts +15 -8
  31. package/packages/agent-skills/src/manifest.ts +5 -2
  32. package/packages/azdo/src/azdo-repo-doctor.ts +401 -91
  33. package/packages/azdo/src/lib/graph-identity.ts +190 -3
  34. package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
  35. package/packages/fleet/manifest.json +6 -4
  36. package/packages/fleet/manifest.schema.json +15 -5
  37. package/packages/repo-controls/README.md +13 -6
  38. package/packages/repo-controls/assets/adl-config.default.toml +1 -0
  39. package/packages/repo-controls/src/cli.ts +6 -1
  40. package/packages/repo-controls/src/gitignore-block.ts +17 -0
  41. package/packages/repo-controls/src/install.ts +88 -6
@@ -43,10 +43,28 @@ if ! jq -e '
43
43
  all(.minimum_free_gib >= 1) and
44
44
  all((.home | type) == "string" and (.home | startswith("/"))) and
45
45
  all((.default_agent | type) == "string" and (.default_agent | test("^agent-[a-z0-9-]+$"))) and
46
- all((keys - ["administrator", "default_agent", "home", "launch_agents", "minimum_free_gib", "schedule_minute", "scratch_repos", "brew_upgrade_all", "surface_clis", "tools"] | length) == 0) and
46
+ all((keys - ["administrator", "default_agent", "home", "launch_agents", "minimum_free_gib", "schedule_minute", "scratch_repos", "brew_upgrade_all", "surface_clis", "git_maintain_repos", "tools"] | length) == 0) and
47
47
  all((.scratch_repos // []) | type == "array" and (. | unique | length) == length and
48
48
  all(type == "string" and startswith("/") and . != "/" and
49
49
  (endswith("/") | not) and (contains("..") | not) and (test("\\s") | not))) and
50
+ # git_maintain_repos (2026-09-30): an explicit, closed list of
51
+ # repositories bin/reconcile passes to git-maintain via --repo argv --
52
+ # never a recursive scan root, deliberately, since these devices own
53
+ # checkouts that sit at a fixed path outside any device home (the Linux
54
+ # VPSes use /opt/adl-azdo) and a recursive scan of a shared parent like
55
+ # /opt would also walk into unrelated, explicitly off-limits
56
+ # directories. The azagent/vsts.agent substring bans are exactly that
57
+ # guard, enforced at the schema layer (case-insensitively -- a manifest
58
+ # value could be typed AzAgent2 as easily as azagent2) so a mistyped or
59
+ # copy-pasted entry can never point there. git-maintain itself refuses
60
+ # the same names again, case-insensitively and by realpath too, at the
61
+ # point of actual use (jobs/git-maintain/src/main.ts,
62
+ # isForbiddenAzurePipelinesPath) -- this schema check alone only ever
63
+ # sees the manifest literal string, never what a symlink resolves to.
64
+ all((.git_maintain_repos // []) | type == "array" and (. | unique | length) == length and
65
+ all(type == "string" and startswith("/") and . != "/" and
66
+ (endswith("/") | not) and (contains("..") | not) and (test("\\s") | not) and
67
+ (ascii_downcase | contains("azagent") | not) and (ascii_downcase | contains("vsts.agent") | not))) and
50
68
  all((.brew_upgrade_all // false) | type == "boolean") and
51
69
  all(
52
70
  (.surface_clis // []) as $surfaces |
@@ -70,18 +88,19 @@ if ! jq -e '
70
88
  (.mac.devices.m3.surface_clis // [] | any(.name == "codex" and .manager == "brew" and .command == "brew" and .update_args == ["upgrade", "--cask", "codex"] and (.report_only // false) == false)) and
71
89
  (.mac.devices.m5.surface_clis // [] | any(.name == "codex" and .manager == "brew" and .command == "brew" and .update_args == ["upgrade", "--cask", "codex"] and (.report_only // false) == false)) and
72
90
  ([.mac.common.tools[], .linux.common.tools[], .mac.devices[].tools[], .linux.devices[].tools[]] |
73
- all(.manager == "brew" or .manager == "apt" or .manager == "npm" or .manager == "pip" or .manager == "release-archive" or .manager == "release-archive-latest" or .manager == "shim" or .manager == "repo-script" or .manager == "repo-dir" or .manager == "bun-compile" or .manager == "embedding-model") and
91
+ all(.manager == "brew" or .manager == "apt" or .manager == "npm" or .manager == "pip" or .manager == "release-archive-latest" or .manager == "shim" or .manager == "repo-script" or .manager == "repo-dir" or .manager == "bun-compile" or .manager == "embedding-model") and
74
92
  all((.package | type) == "string" and (.package | length) > 0) and
75
93
  all((.executable | type) == "string" and (.executable | length) > 0) and
94
+ # Second review pass, 2026-09-29: the removal-path guard in bin/
95
+ # reconcile (executable_claimed_by_current_manifest) compares
96
+ # .executable paths lexically after normalizing a legacy, unvalidated
97
+ # previous-manifest path -- a CURRENT manifest must never need that
98
+ # normalization in the first place, so a "//" or "/./" segment is
99
+ # rejected here outright rather than silently accepted and normalized
100
+ # away later.
101
+ all((.executable | contains("//") | not) and (.executable | contains("/./") | not)) and
76
102
  all((.reason // null) == null or (.reason | type == "string" and length > 0)) and
77
- all(if .manager == "release-archive" then
78
- ((.version | type) == "string" and (.version | length) > 0) and
79
- ((.url | type) == "string" and (.url | startswith("https://"))) and
80
- ((.sha256 | type) == "string" and (.sha256 | test("^[0-9a-f]{64}$"))) and
81
- ((.archive_executable | type) == "string" and
82
- (.archive_executable | test("^[^/]") and (contains("..") | not))) and
83
- (.executable | startswith("/usr/local/bin/"))
84
- elif .manager == "release-archive-latest" then
103
+ all(if .manager == "release-archive-latest" then
85
104
  ((.repo | type) == "string" and (.repo | test("^[^/[:space:]]+/[^/[:space:]]+$"))) and
86
105
  ((.asset | type) == "string" and
87
106
  (.asset | test("^[^/]") and (contains("..") | not))) and
@@ -124,12 +143,13 @@ if ! jq -e '
124
143
  ((.dim | type) == "number") and
125
144
  (has("revision") | not) and (has("source_files") | not) and (has("artifact_files") | not)
126
145
  elif .manager == "npm" then
127
- # .version is optional for npm: absent means update_policy=latest-stable
128
- # tracks whatever npm resolves; present pins the install_one_tool
129
- # $package@$version spec (Handoff 5, 2026-09-26, H4). No checksum field
130
- # exists for npm -- there is no release-archive-style sha256 pin here,
131
- # and none is required or accepted.
132
- ((has("version") | not) or ((.version | type) == "string" and (.version | length) > 0)) and
146
+ # Tarun, 2026-09-29 (hard rule): versions are never pinned -- every
147
+ # fleet tool installs latest stable. .version on npm used to be an
148
+ # optional pin (Handoff 5, 2026-09-26, H4); it is refused outright now,
149
+ # the same way release-archive-latest already refuses one, and the
150
+ # same day the release-archive manager (pinned version+url+sha256) was
151
+ # removed from the accepted managers above entirely.
152
+ (has("version") | not) and
133
153
  # .allow_scripts (docs/operations/global-tools.md Topic 2, decided
134
154
  # 2026-09-28): an explicit, per-entry allow-list of dependency package
135
155
  # names whose install/postinstall scripts reconcile passes through as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
5
5
  "license": "UNLICENSED",
6
6
  "os": [
@@ -15,7 +15,7 @@
15
15
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
16
16
  import { dirname, join } from 'node:path'
17
17
  import { renderOpencodePlugin } from './opencode-template.ts'
18
- import { mergeCursorHooksFile, mergeJsonHooksFile } from './json-merge.ts'
18
+ import { type AmbiguousPairConflict, mergeCursorHooksFile, mergeJsonHooksFile, type RestoredHook } from './json-merge.ts'
19
19
  import {
20
20
  legacyFileHash,
21
21
  type LegacyLockDoc,
@@ -37,6 +37,18 @@ export interface DeployHooksOptions {
37
37
  readonly profile?: HookDeployProfile
38
38
  /** Defaults to loading `profile.source` relative to `dirname(assetsDir)`. */
39
39
  readonly hooksDoc?: CanonicalHooksDoc
40
+ /**
41
+ * Computes the exact same written/unchanged/conflicts result WITHOUT
42
+ * writing anything — not the target files, not the lock, not the legacy
43
+ * lock (independent review, 2026-09-29: `adl install --dry-run` used to
44
+ * run this for real against a throwaway `cpSync` sandbox instead, which
45
+ * risked rewriting symlinks to absolute paths, copying `node_modules`,
46
+ * and touching a worktree's real `.git`; a real plan mode has none of
47
+ * that). Every filesystem WRITE below is gated on `!dryRun`; every READ
48
+ * (existing file content, the current lock) still runs for real, since
49
+ * an accurate plan needs the real current state.
50
+ */
51
+ readonly dryRun?: boolean
40
52
  }
41
53
 
42
54
  export interface HookConflict {
@@ -44,11 +56,41 @@ export interface HookConflict {
44
56
  readonly reason: string
45
57
  }
46
58
 
59
+ /** One hook healed via the PAIR fallback (not hash, not the legacy channel) this run — see json-merge.ts's `RestoredHook` for the full rationale (2026-09-30, round 5, finding 2). */
60
+ export interface RestoredHookReport {
61
+ readonly file: string
62
+ readonly event: string
63
+ readonly command: string
64
+ readonly matcher?: string
65
+ }
66
+
67
+ function makeRestoredReport(file: string, hook: RestoredHook & { readonly event: string }): RestoredHookReport {
68
+ return hook.matcher !== undefined ? { file, event: hook.event, command: hook.command, matcher: hook.matcher } : { file, event: hook.event, command: hook.command }
69
+ }
70
+
71
+ function ambiguousConflictReason(c: AmbiguousPairConflict): string {
72
+ const matcherPart = c.matcher !== undefined ? ` (matcher "${c.matcher}")` : ' (no matcher)'
73
+ return `ambiguous duplicate adl-owned hook in event '${c.event}': more than one unverified candidate for "${c.command}"${matcherPart}; neither healed, left untouched — resolve by hand`
74
+ }
75
+
47
76
  export interface DeployHooksResult {
48
77
  readonly written: readonly string[]
49
78
  readonly unchanged: readonly string[]
50
79
  readonly conflicts: readonly HookConflict[]
51
80
  readonly lockPath: string
81
+ /**
82
+ * Surface files whose lock entry moved from the legacy
83
+ * (`ownedCommands`-only) shape to the content-addressed one
84
+ * (`ownedEntries`) on THIS run (independent review, 2026-09-30, finding
85
+ * 7). This can be non-empty even when the surface's own `written`/
86
+ * `unchanged` outcome looks like nothing happened — the deployed JSON
87
+ * file can stay byte-identical while the LOCK itself is rewritten, and
88
+ * that rewrite deserves its own truthful line in the CLI's output
89
+ * rather than being folded silently into "unchanged".
90
+ */
91
+ readonly lockMigrated: readonly string[]
92
+ /** Hooks healed via the pair fallback this run, named explicitly (round 5, finding 2) so deploy's own output, doctor's preview, and `adl install` can all say "restored adl hook <event>/<matcher>/<command> — local changes to it were replaced". */
93
+ readonly restored: readonly RestoredHookReport[]
52
94
  }
53
95
 
54
96
  function readExistingJson(path: string): Record<string, unknown> | undefined {
@@ -65,14 +107,15 @@ function readExistingJson(path: string): Record<string, unknown> | undefined {
65
107
  return doc as Record<string, unknown>
66
108
  }
67
109
 
68
- function writeJsonIfChanged(path: string, content: Record<string, unknown>, changed: boolean): void {
69
- if (!changed) return
110
+ function writeJsonIfChanged(path: string, content: Record<string, unknown>, changed: boolean, dryRun: boolean): void {
111
+ if (!changed || dryRun) return
70
112
  mkdirSync(dirname(path), { recursive: true })
71
113
  writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`)
72
114
  }
73
115
 
74
116
  export function deployHooks(options: DeployHooksOptions): DeployHooksResult {
75
117
  const { assetsDir, targetRepo } = options
118
+ const dryRun = options.dryRun === true
76
119
 
77
120
  if (!existsSync(assetsDir) || !statSync(assetsDir).isDirectory()) {
78
121
  throw new Error(`agent-hooks assets directory not found: ${assetsDir}`)
@@ -91,19 +134,21 @@ export function deployHooks(options: DeployHooksOptions): DeployHooksResult {
91
134
  const written: string[] = []
92
135
  const unchanged: string[] = []
93
136
  const conflicts: HookConflict[] = []
137
+ const lockMigrated: string[] = []
138
+ const restored: RestoredHookReport[] = []
94
139
  const takeover = { any: false }
95
140
 
96
141
  for (const surface of profile.surfaces) {
97
- deploySurface(surface, { assetsDir, targetRepo, hooksDoc, lock, legacyDoc, takeover, newEntries, written, unchanged, conflicts })
142
+ deploySurface(surface, { assetsDir, targetRepo, hooksDoc, lock, legacyDoc, takeover, newEntries, written, unchanged, conflicts, lockMigrated, restored, dryRun })
98
143
  }
99
144
 
100
- writeLock(targetRepo, { version: 1, entries: newEntries })
145
+ if (!dryRun) writeLock(targetRepo, { version: 1, entries: newEntries })
101
146
 
102
147
  // Once anything was actually taken over, this package's own bookkeeping in
103
148
  // the legacy lock is stale — ownership has fully moved to the lock just
104
149
  // written above. A legacy lock present but untouched by any takeover (no
105
150
  // hash matched, or this repo never had one) is left exactly as found.
106
- if (takeover.any && legacyDoc !== undefined && legacyLockPath !== undefined) {
151
+ if (!dryRun && takeover.any && legacyDoc !== undefined && legacyLockPath !== undefined) {
107
152
  const opencodeSurface = profile.surfaces.find((s) => s.format === 'opencode-plugin')
108
153
  if (opencodeSurface !== undefined) stripLegacyOwnership(legacyDoc, legacyLockPath, opencodeSurface.file)
109
154
  }
@@ -113,6 +158,8 @@ export function deployHooks(options: DeployHooksOptions): DeployHooksResult {
113
158
  unchanged: unchanged.sort(),
114
159
  conflicts: conflicts.sort((a, b) => a.file.localeCompare(b.file)),
115
160
  lockPath: lockPathFor(targetRepo),
161
+ lockMigrated: lockMigrated.sort(),
162
+ restored,
116
163
  }
117
164
  }
118
165
 
@@ -129,6 +176,9 @@ interface DeploySurfaceContext {
129
176
  readonly written: string[]
130
177
  readonly unchanged: string[]
131
178
  readonly conflicts: HookConflict[]
179
+ readonly lockMigrated: string[]
180
+ readonly restored: RestoredHookReport[]
181
+ readonly dryRun: boolean
132
182
  }
133
183
 
134
184
  function deploySurface(surface: HookSurface, ctx: DeploySurfaceContext): void {
@@ -147,8 +197,10 @@ function deploySurface(surface: HookSurface, ctx: DeploySurfaceContext): void {
147
197
  const rendered = renderOpencodePlugin({ assetsDir: ctx.assetsDir, surface, hooksDoc: ctx.hooksDoc })
148
198
  const changed = !exists || readFileSync(targetAbs, 'utf8') !== rendered
149
199
  if (changed) {
150
- mkdirSync(dirname(targetAbs), { recursive: true })
151
- writeFileSync(targetAbs, rendered)
200
+ if (!ctx.dryRun) {
201
+ mkdirSync(dirname(targetAbs), { recursive: true })
202
+ writeFileSync(targetAbs, rendered)
203
+ }
152
204
  ctx.written.push(surface.file)
153
205
  } else {
154
206
  ctx.unchanged.push(surface.file)
@@ -157,25 +209,53 @@ function deploySurface(surface: HookSurface, ctx: DeploySurfaceContext): void {
157
209
  return
158
210
  }
159
211
 
160
- const ownOwned = prevEntry !== undefined && prevEntry.format === surface.format ? prevEntry.ownedCommands : {}
212
+ // Ownership is content-addressed (json-merge.ts's own header, second
213
+ // independent review 2026-09-29): `recordedEntries` is THIS package's own
214
+ // prior write, read straight from the lock as hash+command pairs, never
215
+ // derived from command text alone. A lock with no `ownedEntries` at all
216
+ // (every repo enrolled before this fix) has no hashes to give — for
217
+ // exactly this one transitional deploy, its OLD `ownedCommands` feeds the
218
+ // command-anywhere fallback channel instead, unioned with
219
+ // `legacyTakeover.ownedByEvent` (the pre-agent-hooks engine's own
220
+ // hash-verified takeover signal — a different legacy source, same
221
+ // "command text is the only proof we have" shape).
222
+ const sameFormatPrev = prevEntry !== undefined && prevEntry.format === surface.format ? prevEntry : undefined
223
+ const isLegacyShapedLock = sameFormatPrev !== undefined && sameFormatPrev.ownedEntries === undefined
224
+ // Independent review, 2026-09-30, finding 7: this transitional deploy
225
+ // rewrites the LOCK from the legacy (`ownedCommands`-only) shape to the
226
+ // content-addressed one — report it, since the deployed JSON file can
227
+ // stay byte-identical while that rewrite happens underneath it.
228
+ if (isLegacyShapedLock) ctx.lockMigrated.push(surface.file)
229
+ const recordedEntries = sameFormatPrev?.ownedEntries ?? {}
230
+ const ownCommandsLegacyFallback = isLegacyShapedLock ? sameFormatPrev.ownedCommands : {}
161
231
  const existingRaw = readExistingJson(targetAbs)
162
232
  const legacyTakeover = legacyTakeoverForJsonHooksSurface(existingRaw, ctx.legacyDoc, surface.file)
163
233
  if (legacyTakeover.matched) ctx.takeover.any = true
164
- const previousOwned = unionOwnedCommands(ownOwned, legacyTakeover.ownedByEvent)
234
+ const legacyCommandsAnywhere = unionOwnedCommands(ownCommandsLegacyFallback, legacyTakeover.ownedByEvent)
165
235
 
166
236
  if (surface.format === 'cursor-hooks-v1') {
167
237
  const desired = renderCursorEntries(ctx.hooksDoc, surface)
168
- const { fileContent, ownedCommands, changed } = mergeCursorHooksFile(existingRaw, desired, previousOwned)
169
- writeJsonIfChanged(targetAbs, fileContent, changed)
170
- ;(changed ? ctx.written : ctx.unchanged).push(surface.file)
171
- ctx.newEntries[surface.file] = { format: 'cursor-hooks-v1', ownedCommands }
238
+ const { fileContent, ownedCommands, ownedEntries, changed, restored, ambiguousConflicts } = mergeCursorHooksFile(existingRaw, desired, recordedEntries, legacyCommandsAnywhere)
239
+ writeJsonIfChanged(targetAbs, fileContent, changed, ctx.dryRun)
240
+ // Independent review, 2026-09-30, round 5, finding 4: a legacy lock
241
+ // migration rewrites the LOCK for real even when this surface's own
242
+ // deployed content stays byte-identical — count that as "written",
243
+ // not "unchanged", so a caller reading these two arrays (which is
244
+ // exactly what "up to date (no changes)" and "written: N" do) never
245
+ // reports a pure no-op when something real did change on disk.
246
+ ;(changed || isLegacyShapedLock ? ctx.written : ctx.unchanged).push(surface.file)
247
+ ctx.newEntries[surface.file] = { format: 'cursor-hooks-v1', ownedCommands, ownedEntries }
248
+ for (const hook of restored) ctx.restored.push(makeRestoredReport(surface.file, hook))
249
+ for (const conflict of ambiguousConflicts) ctx.conflicts.push({ file: surface.file, reason: ambiguousConflictReason(conflict) })
172
250
  return
173
251
  }
174
252
 
175
253
  // claude-settings | codex-hooks
176
254
  const desired = renderJsonHooksGroups(ctx.hooksDoc, surface)
177
- const { fileContent, ownedCommands, changed } = mergeJsonHooksFile(existingRaw, desired, previousOwned)
178
- writeJsonIfChanged(targetAbs, fileContent, changed)
179
- ;(changed ? ctx.written : ctx.unchanged).push(surface.file)
180
- ctx.newEntries[surface.file] = { format: surface.format, ownedCommands }
255
+ const { fileContent, ownedCommands, ownedEntries, changed, restored, ambiguousConflicts } = mergeJsonHooksFile(existingRaw, desired, recordedEntries, legacyCommandsAnywhere)
256
+ writeJsonIfChanged(targetAbs, fileContent, changed, ctx.dryRun)
257
+ ;(changed || isLegacyShapedLock ? ctx.written : ctx.unchanged).push(surface.file)
258
+ ctx.newEntries[surface.file] = { format: surface.format, ownedCommands, ownedEntries }
259
+ for (const hook of restored) ctx.restored.push(makeRestoredReport(surface.file, hook))
260
+ for (const conflict of ambiguousConflicts) ctx.conflicts.push({ file: surface.file, reason: ambiguousConflictReason(conflict) })
181
261
  }
@@ -17,7 +17,7 @@
17
17
  import { existsSync, readFileSync } from 'node:fs'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { mergeCursorHooksFile, mergeJsonHooksFile } from './json-merge.ts'
20
- import { readLock } from './lock.ts'
20
+ import { type JsonHooksLockEntry, readLock } from './lock.ts'
21
21
  import { renderOpencodePlugin } from './opencode-template.ts'
22
22
  import {
23
23
  DEFAULT_PROFILE_PATH,
@@ -70,7 +70,14 @@ function readExistingJson(path: string): Record<string, unknown> | undefined {
70
70
  function doctorJsonSurface(surface: HookSurface, targetRepo: string, hooksDoc: CanonicalHooksDoc, lock: ReturnType<typeof readLock>): DoctorIssue[] {
71
71
  const targetAbs = join(targetRepo, surface.file)
72
72
  const exists = existsSync(targetAbs)
73
- const previousOwned = lock.entries[surface.file]?.format === surface.format ? (lock.entries[surface.file] as { ownedCommands: Record<string, readonly string[]> }).ownedCommands : {}
73
+ const lockEntry = lock.entries[surface.file]?.format === surface.format ? (lock.entries[surface.file] as JsonHooksLockEntry) : undefined
74
+ // Same fallback deploy.ts itself uses: a lock with no ownedEntries at all
75
+ // (every repo enrolled before the content-hash fix) has no hashes to
76
+ // give, so its OLD ownedCommands feeds the command-anywhere channel
77
+ // instead -- otherwise doctor would report drift for every currently-
78
+ // enrolled repo's untouched content that deploy itself recognizes fine.
79
+ const recordedEntries = lockEntry?.ownedEntries ?? {}
80
+ const legacyCommandsAnywhere = lockEntry !== undefined && lockEntry.ownedEntries === undefined ? lockEntry.ownedCommands : {}
74
81
 
75
82
  let parseFailed = false
76
83
  if (exists) {
@@ -85,21 +92,64 @@ function doctorJsonSurface(surface: HookSurface, targetRepo: string, hooksDoc: C
85
92
  const existingRaw = readExistingJson(targetAbs)
86
93
  const merged =
87
94
  surface.format === 'cursor-hooks-v1'
88
- ? mergeCursorHooksFile(existingRaw, renderCursorEntries(hooksDoc, surface), previousOwned)
89
- : mergeJsonHooksFile(existingRaw, renderJsonHooksGroups(hooksDoc, surface), previousOwned)
95
+ ? mergeCursorHooksFile(existingRaw, renderCursorEntries(hooksDoc, surface), recordedEntries, legacyCommandsAnywhere)
96
+ : mergeJsonHooksFile(existingRaw, renderJsonHooksGroups(hooksDoc, surface), recordedEntries, legacyCommandsAnywhere)
90
97
 
91
98
  if (!exists) {
92
99
  return [{ severity: 'error', path: surface.file, reason: 'expected hook wiring has not been deployed' }]
93
100
  }
94
- if (!merged.changed) return []
95
- return merged.changedEvents.map((event) => ({
101
+ // Independent review, 2026-09-30, round 5 (finding 1, pair-scoped —
102
+ // never flags a same-command entry under a different matcher): a
103
+ // duplicated adl-owned (command, matcher) pair can sit in the file
104
+ // even when the merge is otherwise a structural no-op (the duplicate
105
+ // isn't healed into the verified original, but it also isn't
106
+ // restructured — nothing else changes), so this check runs
107
+ // UNCONDITIONALLY, never gated behind `merged.changed`.
108
+ const duplicateIssues: DoctorIssue[] = Object.entries(merged.duplicateCommands).flatMap(([event, commands]) =>
109
+ commands.map((command) => ({
110
+ severity: 'error' as const,
111
+ path: `${surface.file}#${event}`,
112
+ reason: `adl-owned command appears more than once (a duplicate, not a self-heal): ${command} — the hook runs twice; deploy does not remove a user's copy, so remove the extra copy by hand`,
113
+ })),
114
+ )
115
+ // Round 5, finding 2: more than one unverified candidate for a pair is
116
+ // ambiguous — deploy heals neither, so this is ALSO unconditional
117
+ // (never gated behind `merged.changed`, since a frozen event by
118
+ // definition never registers as changed).
119
+ const ambiguousIssues: DoctorIssue[] = merged.ambiguousConflicts.map((conflict) => ({
96
120
  severity: 'error' as const,
97
- path: `${surface.file}#${event}`,
98
- reason:
99
- previousOwned[event] !== undefined
100
- ? 'deploy-written hook entries were modified since deploy, or assets/hooks.json changed since; run deploy to update'
101
- : 'expected hook wiring for this event has not been deployed',
121
+ path: `${surface.file}#${conflict.event}`,
122
+ reason: `two unverified copies of this adl-owned hook exist (ambiguous — matcher ${conflict.matcher ?? 'none'}, command "${conflict.command}"); deploy will not choose one for you, resolve by hand`,
102
123
  }))
124
+ if (!merged.changed) return [...duplicateIssues, ...ambiguousIssues]
125
+ // Round 5, finding 2: when a heal is about to happen (not just "some
126
+ // drift"), doctor's own PREVIEW names exactly what would be restored —
127
+ // "restored adl hook <event>/<matcher>/<command>" — instead of the
128
+ // generic "modified since deploy" wording, matching what deploy's own
129
+ // output and `adl install` will say.
130
+ const restoredByEvent = new Map(merged.restored.map((r) => [r.event, r] as const))
131
+ return [
132
+ ...duplicateIssues,
133
+ ...ambiguousIssues,
134
+ ...merged.changedEvents.map((event) => {
135
+ const restoredHere = restoredByEvent.get(event)
136
+ if (restoredHere !== undefined) {
137
+ return {
138
+ severity: 'error' as const,
139
+ path: `${surface.file}#${event}`,
140
+ reason: `restored adl hook ${event}/${restoredHere.matcher ?? '-'}/${restoredHere.command} — local changes to it were replaced; run deploy to apply`,
141
+ }
142
+ }
143
+ return {
144
+ severity: 'error' as const,
145
+ path: `${surface.file}#${event}`,
146
+ reason:
147
+ recordedEntries[event] !== undefined || legacyCommandsAnywhere[event] !== undefined
148
+ ? 'deploy-written hook entries were modified since deploy, or assets/hooks.json changed since; run deploy to update'
149
+ : 'expected hook wiring for this event has not been deployed',
150
+ }
151
+ }),
152
+ ]
103
153
  }
104
154
 
105
155
  function doctorOpencodeSurface(surface: HookSurface, ctx: { assetsDir: string; targetRepo: string; hooksDoc: CanonicalHooksDoc; lock: ReturnType<typeof readLock> }): DoctorIssue[] {