@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.7 → 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 (40) hide show
  1. package/bin/adl +2 -2
  2. package/bin/lean-ctx-observe-strip +76 -3
  3. package/bin/reconcile +175 -5
  4. package/bin/validate-manifest +19 -1
  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 +2 -0
  36. package/packages/fleet/manifest.schema.json +9 -0
  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/install.ts +88 -6
package/bin/adl CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/adl
3
- size: 82875872 bytes
4
- sha256: b562edfda0f280940a2b5874874f83c22bde7fe8cd5b9d8159c003f4b53e1f5c
3
+ size: 82920928 bytes
4
+ sha256: ecd5111c7bc2c06d56361450a0d97b8280f6d300f879714a0bd23d2c534ec61b
@@ -10,7 +10,7 @@ export PATH="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/us
10
10
  # the per-repo carrier (`adl hook lean-ctx pre-tool-use|post-tool-use`,
11
11
  # written by `adl hooks deploy`) already provides rewrite/redirect/
12
12
  # read-dedup with the repo's own config, and `observe` has no per-repo
13
- # carrier at all and is dropped outright. Five carriers, all written by
13
+ # carrier at all and is dropped outright. Six carriers, all written by
14
14
  # `lean-ctx setup`, are covered ("fix all the lean-ctx issues" -- Tarun,
15
15
  # 2026-09-29 -- widened this from an initial two-of-five scope):
16
16
  # 1. Raw, unwrapped `lean-ctx hook <subcommand>` entries -- observe,
@@ -33,6 +33,20 @@ export PATH="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/us
33
33
  # at $HOME/.claude/skills/lean-ctx, .codex/skills/lean-ctx,
34
34
  # .cursor/skills/lean-ctx, .config/opencode/skills/lean-ctx,
35
35
  # .agents/skills/lean-ctx.
36
+ # 6. Global lean-ctx hook SCRIPTS: every entry named `lean-ctx-*` directly
37
+ # under $HOME/.claude/hooks (lean-ctx-rewrite.sh, lean-ctx-redirect.sh,
38
+ # their -native counterparts, and any .bak copies). Added 2026-09-30
39
+ # because of lean-ctx's own startup refresh gate (proven from v3.10.5
40
+ # source, rust/src/hooks/mod.rs): refresh_installed_hooks() re-writes
41
+ # the global Claude hooks whenever hooks_installed_for("claude", home)
42
+ # is true, i.e. <claude_dir>/hooks/lean-ctx-rewrite.sh EXISTS or
43
+ # <claude_dir>/settings.json contains the substring `lean-ctx`.
44
+ # Stripping settings.json (shape 1) alone leaves the scripts behind and
45
+ # the gate armed, so the carrier rewrites the global hooks on its next
46
+ # start. Only names starting with `lean-ctx-` are touched; every other
47
+ # file there (enact-*, code-simplifier.mjs, lib/, symlinks to other
48
+ # repos) is left alone. Scope is $HOME/.claude only -- CLAUDE_CONFIG_DIR
49
+ # is deliberately not consulted (the tool takes --home explicitly).
36
50
  # `adl hook lean-ctx ...` (the per-repo carrier) and Orca's own hooks are
37
51
  # never touched -- see bin/lib/lean-ctx-observe-hooks.sh's own header for
38
52
  # exactly why the match can never catch either, and for the full "which
@@ -42,7 +56,7 @@ export PATH="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/us
42
56
  # that same header, not stripped. Run by bin/reconcile on every
43
57
  # reconciliation (plan AND apply, exactly like bin/kimi-hooks /
44
58
  # bin/opencode-hooks) so a hand-edited or Orca-reinstalled per-user config
45
- # never lets any of the five carriers creep back in unnoticed.
59
+ # never lets any of the six carriers creep back in unnoticed.
46
60
  #
47
61
  # Usage: lean-ctx-observe-strip --home ABSOLUTE_PATH --mode plan|apply
48
62
  #
@@ -66,7 +80,11 @@ export PATH="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/us
66
80
  # not exist, is reported and left alone. A skill directory is
67
81
  # moved (never deleted) only when its SKILL.md frontmatter says
68
82
  # `name: lean-ctx`; any other content there is left alone and
69
- # reported. Refuses outright (before touching anything) when run
83
+ # reported. The `lean-ctx-*` hook scripts under ~/.claude/hooks are
84
+ # moved (never deleted) into a sibling
85
+ # `hooks.pre-lean-ctx-observe-strip-<UTC ts>/` directory OUTSIDE
86
+ # the scanned hooks/ directory, exactly like a skill directory's
87
+ # backup, and reported one line per file. Refuses outright (before touching anything) when run
70
88
  # as literal root (EUID 0) against a --home it does not own --
71
89
  # bin/reconcile's apply path never does this itself; see
72
90
  # lean_ctx_observe_strip_guard_euid in the shared lib.
@@ -348,6 +366,60 @@ strip_skill_dir_one() {
348
366
  return 0
349
367
  }
350
368
 
369
+ # strip_hook_scripts_one <hooks-dir> -- shape 6 (see the header): moves every
370
+ # entry named `lean-ctx-*` directly under <hooks-dir> into a sibling
371
+ # `<hooks-dir>.pre-lean-ctx-observe-strip-<UTC ts>/` directory (OUTSIDE the
372
+ # scanned hooks/ directory, same reasoning as strip_skill_dir_one), so
373
+ # lean-ctx's refresh gate (`hooks/lean-ctx-rewrite.sh` exists) is disarmed.
374
+ # Matches by name prefix only, never by content; a symlink named
375
+ # `lean-ctx-*` is moved as the link itself (mv never follows it). Nothing
376
+ # else in <hooks-dir> is read or touched. Each file is reported as
377
+ # "backed up previous hook script <original> to <backup>" (two explicit
378
+ # paths, like the skill-directory note -- the backup path does not end in
379
+ # `.pre-<label>-<timestamp>`, so bin/reconcile cannot recover the original
380
+ # by suffix-stripping).
381
+ strip_hook_scripts_one() {
382
+ local hooks_dir="$1" entry
383
+ local found=()
384
+ if [[ -d "$hooks_dir" ]]; then
385
+ for entry in "$hooks_dir"/lean-ctx-*; do
386
+ if [[ -e "$entry" || -L "$entry" ]]; then
387
+ found+=("$entry")
388
+ fi
389
+ done
390
+ fi
391
+ if [[ "${#found[@]}" -eq 0 ]]; then
392
+ echo "lean-ctx-observe-strip: $hooks_dir/lean-ctx-*: not present -- nothing to strip"
393
+ return 0
394
+ fi
395
+ for entry in "${found[@]}"; do
396
+ echo "lean-ctx-observe-strip: $entry: found lean-ctx hook script"
397
+ if [[ "$mode" != apply ]]; then
398
+ echo "lean-ctx-observe-strip: would move $entry to a timestamped backup location outside $hooks_dir"
399
+ fi
400
+ done
401
+ [[ "$mode" == apply ]] || return 0
402
+
403
+ local backup_root
404
+ backup_root="${hooks_dir}.pre-lean-ctx-observe-strip-$(date -u +%Y%m%dT%H%M%SZ)"
405
+ if ! mkdir "$backup_root" 2>/dev/null; then
406
+ echo "lean-ctx-observe-strip: $hooks_dir: FAILED to create backup location $backup_root -- not modified" >&2
407
+ return 1
408
+ fi
409
+ local backup rc=0
410
+ for entry in "${found[@]}"; do
411
+ backup="$backup_root/$(basename "$entry")"
412
+ if ! mv "$entry" "$backup"; then
413
+ echo "lean-ctx-observe-strip: $entry: FAILED to move to $backup -- not modified" >&2
414
+ rc=1
415
+ continue
416
+ fi
417
+ echo "lean-ctx-observe-strip: backed up previous hook script $entry to $backup"
418
+ echo "lean-ctx-observe-strip: moved lean-ctx hook script $entry to $backup"
419
+ done
420
+ return "$rc"
421
+ }
422
+
351
423
  status=0
352
424
  for target in "${targets[@]}"; do
353
425
  strip_one "$target" || status=1
@@ -358,4 +430,5 @@ done
358
430
  for target in "${skill_dir_targets[@]}"; do
359
431
  strip_skill_dir_one "$target" || status=1
360
432
  done
433
+ strip_hook_scripts_one "$home/.claude/hooks" || status=1
361
434
  exit "$status"
package/bin/reconcile CHANGED
@@ -180,6 +180,12 @@ collect_lean_ctx_observe_strip_backups() {
180
180
  original="${BASH_REMATCH[1]}"
181
181
  backup="${BASH_REMATCH[2]}"
182
182
  record_backup "$original" "$backup"
183
+ elif [[ "$line" =~ ^lean-ctx-observe-strip:\ backed\ up\ previous\ hook\ script\ (.+)\ to\ (.+)$ ]]; then
184
+ # ~/.claude/hooks/lean-ctx-* scripts: same two-path shape as a skill
185
+ # directory (sibling "hooks.pre-lean-ctx-observe-strip-<ts>/" backup).
186
+ original="${BASH_REMATCH[1]}"
187
+ backup="${BASH_REMATCH[2]}"
188
+ record_backup "$original" "$backup"
183
189
  fi
184
190
  done <<<"$text"
185
191
  }
@@ -1979,6 +1985,37 @@ run_surface_update() {
1979
1985
  sudo -u "$administrator" -H env PATH="$surface_cli_search_path" "$resolved" "$@" </dev/null
1980
1986
  }
1981
1987
 
1988
+ # Extracts an update command's own reported failure reason from its
1989
+ # captured output, or prints nothing (empty) if none is found. Confirmed
1990
+ # live for kimi on strato and hostinger-vps-1 (2026-09-30): `kimi upgrade`
1991
+ # printed "error: failed to check for updates: CDN /latest returned HTTP
1992
+ # 521" and still exited 0. Broadened by independent review the same day:
1993
+ # optional leading whitespace before the marker, ANSI color codes stripped
1994
+ # first (real CLIs colorize their own output), and any of
1995
+ # "error:"/"Error:"/"ERROR:"/"✖ error:" -- case-insensitive by
1996
+ # construction (POSIX bracket character classes, not a sed `-I`/`I` flag,
1997
+ # which BSD sed does not support). Applied identically to every surface's
1998
+ # update path, native/npm and Homebrew-cask alike, rather than one
1999
+ # per-surface parser.
2000
+ #
2001
+ # Never `cmd1 | cmd2` piped straight into the caller's assignment: see the
2002
+ # inner `if var=$(grep ...); then` below (not `grep | sed`) for why -- under
2003
+ # this file's `set -euo pipefail`, grep's ordinary "no match" exit would
2004
+ # otherwise abort the run on every surface that DIDN'T report an error. An
2005
+ # `if`-tested command is exempt from that abort by design; the function's
2006
+ # OWN exit status is therefore never meaningful (an `if` with no `else`
2007
+ # always returns 0), so callers check the printed string's emptiness, never
2008
+ # this function's exit status.
2009
+ surface_update_error_reason() {
2010
+ local output="$1" plain line
2011
+ # LC_ALL=C: the strip is bytewise. Under en_US.UTF-8 glibc resolves [@-~]
2012
+ # by collation, so the escape's final letter would never match.
2013
+ plain="$(LC_ALL=C sed -E $'s/\x1b\\[[0-9;]*[@-~]//g' <<<"$output")"
2014
+ if line="$(grep -m1 -iE '^[[:space:]]*(✖[[:space:]]*)?error:' <<<"$plain")"; then
2015
+ sed -E 's/^[[:space:]]*(✖[[:space:]]*)?[eE][rR][rR][oO][rR]:[[:space:]]*//' <<<"$line"
2016
+ fi
2017
+ }
2018
+
1982
2019
  # Reconciles one declared surface_clis entry. report_only (hostinger's loose
1983
2020
  # /root/codex, unknown install method) is version-reported only -- run_surface_update
1984
2021
  # is never called for it, by construction (this branch returns before
@@ -2017,6 +2054,26 @@ reconcile_one_surface_cli() {
2017
2054
  echo "surface update failed: $name (exit $update_status)" >&2
2018
2055
  return 1
2019
2056
  fi
2057
+ # Same broadened check as the native/npm path below (independent
2058
+ # review, 2026-09-30): brew itself can exit 0 while its own output
2059
+ # reports a real problem. No install/upgrade claim without checking
2060
+ # for one first -- Homebrew has no version comparison in this branch
2061
+ # (it acts on cask PRESENCE, not a read version), so there is no
2062
+ # "version changed" success case to weigh it against here; any error
2063
+ # line found is a hard failure.
2064
+ local update_error_line
2065
+ update_error_line="$(surface_update_error_reason "$update_output")"
2066
+ if [[ -n "$update_error_line" ]]; then
2067
+ # Install vs upgrade distinguished in the failure wording too (second
2068
+ # coordinator pass, 2026-09-30): the same `cask_action[0]` that already
2069
+ # picks the real brew argv above also names which one failed.
2070
+ if [[ "${cask_action[0]}" == install ]]; then
2071
+ echo "surface $name: cask install failed ($update_error_line)" >&2
2072
+ else
2073
+ echo "surface $name: cask upgrade failed ($update_error_line)" >&2
2074
+ fi
2075
+ return 1
2076
+ fi
2020
2077
  if [[ "${cask_action[0]}" == install ]]; then
2021
2078
  echo "surface $name: Homebrew cask installed"
2022
2079
  else
@@ -2045,7 +2102,7 @@ reconcile_one_surface_cli() {
2045
2102
  echo "surface $name: report-only, currently ${ver:-unknown}"
2046
2103
  return 0
2047
2104
  fi
2048
- local old_version new_version update_output update_status=0
2105
+ local old_version new_version update_output update_status=0 update_error_line
2049
2106
  old_version="$(surface_cli_version "$resolved")"
2050
2107
  update_output="$(run_with_timeout "$SURFACE_UPDATE_TIMEOUT_SECONDS" run_surface_update "$resolved" ${update_args[@]+"${update_args[@]}"} 2>&1)" || update_status=$?
2051
2108
  printf '%s\n' "$update_output"
@@ -2056,12 +2113,48 @@ reconcile_one_surface_cli() {
2056
2113
  echo "surface update failed: $name (exit $update_status)" >&2
2057
2114
  return 1
2058
2115
  fi
2116
+ # A surface's update command can exit 0 while its own output says the
2117
+ # check itself failed -- confirmed live for kimi (strato + hostinger,
2118
+ # 2026-09-30): `kimi upgrade` printed
2119
+ # "error: failed to check for updates: CDN /latest returned HTTP 521" and
2120
+ # still exited 0 (AGENTS.md: no silent fallback). Read new_version BEFORE
2121
+ # judging anything (independent review, 2026-09-30): a version CHANGE is
2122
+ # proof positive the update happened, which outranks an error line the
2123
+ # command also printed (e.g. a flaky post-install self-check after a
2124
+ # real, successful install) -- reported as a note on the success line,
2125
+ # never a failure. Only an UNCHANGED version with an error line is the
2126
+ # case this whole check exists for: the command claimed nothing changed,
2127
+ # and said why, and that is reported as a failure -- wording "update
2128
+ # check failed", not "could not check for updates", since the reported
2129
+ # reason may not even be about the check itself (kept generic rather than
2130
+ # asserting which).
2131
+ #
2132
+ # An EMPTY new_version (second coordinator pass, 2026-09-30: the binary
2133
+ # broke, or --version now produces no output) must never count as
2134
+ # "updated" just because empty differs from a non-empty old_version --
2135
+ # that used to read as "surface X: 1.2.3 -> unknown", an arrow-style
2136
+ # SUCCESS line for a version we cannot actually confirm. Checked before
2137
+ # the "changed" branch below, so only a REAL, non-empty, different
2138
+ # version ever reaches it.
2059
2139
  new_version="$(surface_cli_version "$resolved")"
2060
- if [[ "$old_version" == "$new_version" ]]; then
2061
- echo "surface $name: already latest (${new_version:-unknown})"
2062
- else
2063
- echo "surface $name: ${old_version:-unknown} -> ${new_version:-unknown}"
2140
+ update_error_line="$(surface_update_error_reason "$update_output")"
2141
+ if [[ -z "$new_version" ]]; then
2142
+ echo "surface $name: update ran but version unreadable (${update_error_line:-"--version produced no output"}); was ${old_version:-unknown}" >&2
2143
+ return 1
2144
+ fi
2145
+ if [[ "$old_version" != "$new_version" ]]; then
2146
+ if [[ -n "$update_error_line" ]]; then
2147
+ echo "surface $name: updated ${old_version:-unknown} -> $new_version (reported: $update_error_line)"
2148
+ else
2149
+ echo "surface $name: ${old_version:-unknown} -> $new_version"
2150
+ fi
2151
+ return 0
2064
2152
  fi
2153
+ if [[ -n "$update_error_line" ]]; then
2154
+ echo "surface $name: update check failed ($update_error_line); kept ${old_version:-unknown}" >&2
2155
+ return 1
2156
+ fi
2157
+ echo "surface $name: already latest ($new_version)"
2065
2158
  return 0
2066
2159
  }
2067
2160
 
@@ -2692,6 +2785,83 @@ else
2692
2785
  echo "global-tools: $mode complete; no state changed"
2693
2786
  fi
2694
2787
 
2788
+ # git_maintain_repos (2026-09-30, arm-only decision the same day): an
2789
+ # explicit, closed list of repositories this device's own checkout lives
2790
+ # at OUTSIDE any recursively-scanned home directory -- the Linux VPSes'
2791
+ # root-owned /opt/adl-azdo is the confirmed case for both strato-vps-1 and
2792
+ # hostinger-vps-1 (STATUS.md's 2026-09-29 first-deployment record;
2793
+ # hostinger's own fleet-cutover --apply landed 2026-09-29T23:43Z, confirmed
2794
+ # the same day). git-maintain's own scheduling (packages/fleet/src/
2795
+ # launch-agents.ts: a dedicated compiled binary, ad-hoc signed, run by a
2796
+ # launchd LaunchAgent under TCC) is macOS-only by construction, so it never
2797
+ # reaches these checkouts on its own. This runs the job directly, on
2798
+ # reconcile's own schedule (every platform this script runs on already has
2799
+ # one), passing each declared path as `--repo` argv -- never a recursive
2800
+ # scan of a shared parent like `/opt` (which also holds unrelated,
2801
+ # explicitly off-limits directories), and never a new environment
2802
+ # variable: the list itself comes only from the manifest, and
2803
+ # GIT_MAINTAIN_DRY below is this job's own pre-existing dry/real contract,
2804
+ # driven by reconcile's own --mode exactly like every other step in this
2805
+ # file, not a knob an operator sets by hand. Absent or empty (every mac
2806
+ # device today) is a silent no-op, the same convention
2807
+ # launch_agents/scratch_repos/surface_clis already use.
2808
+ #
2809
+ # ARM-ONLY, always, on this path (independent review, 2026-09-30): the full
2810
+ # job on this SAME checkout `bin/reconcile` runs from turned out to be new,
2811
+ # unapproved, root-run behaviour once actually reasoned through, not merely
2812
+ # a self-checkout read/write ordering hazard -- see the `--arm-only`
2813
+ # decision recorded in jobs/git-maintain/src/main.ts's own header comment
2814
+ # for the full list (a silent fast-forward-driven auto-deploy of
2815
+ # `origin/integration`/main with no approval gate, colliding with
2816
+ # fleet-cutover.sh re-reading fleet-retire-list.json from this same
2817
+ # checkout after reconcile runs, `git branch -D` as root, and a fetch that
2818
+ # still runs even in dry/plan/audit mode and exits 0 on its own failure).
2819
+ # `--arm-only` narrows this call to ONLY `armHooks`: no fetch, no
2820
+ # fast-forward, no branch prune, no bundle-refresh installer, no network,
2821
+ # and no Azure DevOps identity resolution. The only remaining write is
2822
+ # `.git/config`'s `core.hooksPath`, real only in apply mode
2823
+ # (GIT_MAINTAIN_DRY=1 below makes it a `[dry] would arm ...` log line and
2824
+ # nothing else, in both plan AND audit mode). That is never a tracked file
2825
+ # this script (or anything downstream of it) reads, so arm-only has no
2826
+ # self-checkout read/write ordering hazard left to avoid -- this step stays
2827
+ # in this late position anyway, unchanged from the full-job version: it is
2828
+ # still the simplest place for it, and there is no reason to special-case
2829
+ # arm-only's position just because its own hazard is gone.
2830
+ git_maintain_repos=()
2831
+ while IFS= read -r repo; do
2832
+ [[ -n "$repo" ]] && git_maintain_repos+=("$repo")
2833
+ done < <(jq -r --arg platform "$platform" --arg device "$device" \
2834
+ '.[$platform].devices[$device].git_maintain_repos // [] | .[]' "$manifest")
2835
+ if [[ "${#git_maintain_repos[@]}" -gt 0 ]]; then
2836
+ git_maintain_repo_args=(--arm-only)
2837
+ for repo in "${git_maintain_repos[@]}"; do
2838
+ git_maintain_repo_args+=(--repo "$repo")
2839
+ done
2840
+ # Unlike default-agent-file.ts/managed-settings.ts's plan branches earlier
2841
+ # in this file (which never fail, by construction, so they run
2842
+ # unguarded), this job's dry pass still validates each declared path is a
2843
+ # real repo -- a genuine failure mode even in a non-apply mode, e.g. a
2844
+ # declared checkout that has not been cloned yet. So every non-apply mode
2845
+ # is guarded exactly like apply mode here, deliberately deviating from
2846
+ # those sibling steps: report it, collect it, never let it abort the rest
2847
+ # of this script under this file's own `set -e`. `$mode` (plan OR audit)
2848
+ # is reported verbatim -- audit is a real, distinct reconcile mode, never
2849
+ # relabeled "plan".
2850
+ if [[ "$mode" != apply ]]; then
2851
+ echo "global-tools: git-maintain (declared repos, arm-only) mode=$mode device=$device -> ${git_maintain_repos[*]}"
2852
+ if ! GIT_MAINTAIN_DRY=1 bun "$repo_root/packages/fleet/jobs/git-maintain/src/main.ts" "${git_maintain_repo_args[@]}"; then
2853
+ echo "global-tools: TOOL INSTALL FAILED: git-maintain-repos" >&2
2854
+ failed_tools+=("git-maintain-repos")
2855
+ fi
2856
+ else
2857
+ echo "global-tools: git-maintain (declared repos, arm-only) mode=apply device=$device -> ${git_maintain_repos[*]}"
2858
+ if ! bun "$repo_root/packages/fleet/jobs/git-maintain/src/main.ts" "${git_maintain_repo_args[@]}"; then
2859
+ echo "global-tools: TOOL INSTALL FAILED: git-maintain-repos" >&2
2860
+ failed_tools+=("git-maintain-repos")
2861
+ fi
2862
+ fi
2863
+ fi
2864
+
2695
2865
  # A recorded, continued-past tool failure (install_one_tool /
2696
2866
  # build_one_bun_tool / install_one_embedding_model_tool / write_one_shim)
2697
2867
  # still lets every post-pass above run -- sweep, kimi-hooks, global-agents,
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.7",
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
  }