@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
@@ -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[] {