@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
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: 74d2d0af04a268afff30746e0d68d9d49d8e92c347e913330393c90362c99aff
package/bin/git CHANGED
@@ -621,8 +621,76 @@ case "$command_name" in
621
621
  *) exec "$real_git" ${args[@]+"${args[@]}"} ;;
622
622
  esac
623
623
 
624
+ # Whether option ARG, for network subcommand SUB, consumes the NEXT argv
625
+ # element as its value. Incident 2026-09-30 (Azure DevOps push 16505 recorded
626
+ # as the HUMAN "Tarun Rana" for an agent session's `git push -u origin
627
+ # feat/adl-install`): one shared value-option list treated `-u` as taking a
628
+ # value for EVERY subcommand. It only does for ls-remote/clone
629
+ # (`--upload-pack <exec>`); for push it is `--set-upstream` and for fetch/pull
630
+ # `--update-head-ok`, both flags. `origin` was swallowed as the "value",
631
+ # `feat/adl-install` became the "remote", `remote get-url` failed, and the
632
+ # push went to plain git and the human's credential helper. The table is
633
+ # therefore PER SUBCOMMAND (verified against git-push/fetch/pull/ls-remote/
634
+ # clone(1)). Options whose argument is optional (`--force-with-lease`,
635
+ # `--recurse-submodules` on fetch/pull/clone, `-S`, `-r`) only take it
636
+ # attached (`--opt=value`) and so never consume the next element.
637
+ network_option_takes_value() {
638
+ local sub="$1" arg="$2" short_values long_values chars i ch
639
+ local fetch_long=' depth deepen shallow-since shallow-exclude negotiation-tip negotiation-restrict negotiation-include filter refmap jobs server-option upload-pack exec submodule-prefix recurse-submodules-default bundle-uri '
640
+ case "$sub" in
641
+ push) short_values='o'; long_values=' push-option receive-pack exec repo recurse-submodules ' ;;
642
+ fetch) short_values='jo'; long_values="$fetch_long" ;;
643
+ pull) short_values='josX'; long_values="$fetch_long strategy strategy-option cleanup into-name " ;;
644
+ ls-remote) short_values='ou'; long_values=' upload-pack exec server-option sort ' ;;
645
+ clone) short_values='obucj'; long_values=' origin branch upload-pack template config separate-git-dir ref-format jobs bundle-uri depth shallow-since shallow-exclude reference reference-if-able server-option filter revision object-format ' ;;
646
+ *) return 1 ;;
647
+ esac
648
+ case "$arg" in
649
+ --|--*=*) return 1 ;;
650
+ --*) [[ "$long_values" == *" ${arg#--} "* ]] ;;
651
+ -?) [[ "$short_values" == *"${arg#-}"* ]] ;;
652
+ -*)
653
+ # Clustered short options (`-uf`, `-fo x`): the first value-taking
654
+ # letter ends the cluster; it consumes the next element only when it is
655
+ # the LAST letter (otherwise the rest of the cluster is its value).
656
+ chars="${arg#-}"
657
+ for ((i = 0; i < ${#chars}; i += 1)); do
658
+ ch="${chars:i:1}"
659
+ if [[ "$short_values" == *"$ch"* ]]; then
660
+ ((i == ${#chars} - 1)) && return 0
661
+ return 1
662
+ fi
663
+ done
664
+ return 1
665
+ ;;
666
+ *) return 1 ;;
667
+ esac
668
+ }
669
+
670
+ # True when OPERAND (an unresolved would-be remote name) is really a local
671
+ # path or a URL, which plain git handles without any named remote: an
672
+ # absolute/relative/home path, an existing path (relative to any leading
673
+ # `-C <dir>`), a `scheme://` URL, or scp-like `host:path`.
674
+ is_path_or_url_operand() {
675
+ local operand="$1" base='' i
676
+ case "$operand" in
677
+ /*|./*|../*|~*|.|..|*://*) return 0 ;;
678
+ esac
679
+ is_scp_like_url "$operand" && return 0
680
+ for ((i = 0; i < command_index; i += 1)); do
681
+ if [[ "${args[i]}" == -C && $((i + 1)) -lt command_index ]]; then
682
+ case "${args[i + 1]}" in
683
+ /*) base="${args[i + 1]}" ;;
684
+ *) base="${base:+$base/}${args[i + 1]}" ;;
685
+ esac
686
+ fi
687
+ done
688
+ [[ -e "${base:+$base/}$operand" ]]
689
+ }
690
+
624
691
  remote_url=''
625
692
  remote_name='origin'
693
+ remote_explicit=0
626
694
 
627
695
  # A direct URL is authoritative for every network command. Scan it before
628
696
  # resolving a remote name so clone, ls-remote, and explicit fetch/push/pull URLs never
@@ -635,24 +703,27 @@ for ((index = command_index + 1; index < ${#args[@]}; index += 1)); do
635
703
  done
636
704
 
637
705
  if [[ -z "$remote_url" ]]; then
638
- # The first positional argument to fetch, pull, push, and ls-remote is the selected
639
- # remote. Skip the common options that carry a following value; unknown
640
- # options are skipped rather than treated as a remote name.
706
+ # The first positional argument to fetch, pull, push, and ls-remote is the
707
+ # selected remote. Skip options (and the value of those that take one FOR
708
+ # THIS SUBCOMMAND, see network_option_takes_value); other options are
709
+ # skipped rather than treated as a remote name.
641
710
  for ((index = command_index + 1; index < ${#args[@]}; index += 1)); do
642
711
  argument="${args[index]}"
643
712
  case "$argument" in
644
713
  --)
645
714
  ((index += 1))
646
- ((index < ${#args[@]})) && remote_name="${args[index]}"
715
+ if ((index < ${#args[@]})); then
716
+ remote_name="${args[index]}"
717
+ remote_explicit=1
718
+ fi
647
719
  break
648
720
  ;;
649
- -c|-j|-o|-u|--depth|--deepen|--filter|--jobs|--negotiation-tip|--push-option|--receive-pack|--refmap|--recurse-submodules|--server-option|--shallow-exclude|--shallow-since|--strategy|--strategy-option|--upload-pack)
650
- ((index += 1))
651
- ;;
652
- -?*|--*=*)
721
+ -?*)
722
+ network_option_takes_value "$command_name" "$argument" && ((index += 1))
653
723
  ;;
654
724
  *)
655
725
  remote_name="$argument"
726
+ remote_explicit=1
656
727
  break
657
728
  ;;
658
729
  esac
@@ -661,6 +732,16 @@ fi
661
732
 
662
733
  if [[ -z "$remote_url" ]]; then
663
734
  if ! remote_url="$("$real_git" "${args[@]:0:command_index}" remote get-url "$remote_name" 2>/dev/null)"; then
735
+ # FAIL CLOSED (incident 2026-09-30, above): an EXPLICITLY named remote
736
+ # that does not resolve, and is not a local path or a URL, must never be
737
+ # handed to plain git -- it would authenticate as the human via the
738
+ # keychain/credential helper if the name was really an Azure DevOps
739
+ # remote we mis-scanned. clone has no remote name (its operand is the
740
+ # source), and an unnamed default remote keeps today's pass-through.
741
+ if [[ "$command_name" != clone && "$remote_explicit" == 1 ]] && ! is_path_or_url_operand "$remote_name"; then
742
+ printf "git: cannot resolve remote '%s' for %s; refusing to pass an unresolved remote to plain git (it may authenticate as the human)\n" "$remote_name" "$command_name" >&2
743
+ exit 1
744
+ fi
664
745
  exec "$real_git" ${args[@]+"${args[@]}"}
665
746
  fi
666
747
  fi
@@ -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.9",
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": [