@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.
- package/bin/adl +2 -2
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +175 -5
- package/bin/validate-manifest +19 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
- package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
- package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
- package/packages/agent-hooks/src/deploy/lock.ts +36 -2
- package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
- package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
- package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
- package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
- package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
- package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
- package/packages/agent-settings/src/deploy/doctor.ts +39 -3
- package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
- package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
- package/packages/agent-settings/src/deploy/lock.ts +4 -1
- package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
- package/packages/agent-settings/src/deploy/settings.ts +6 -3
- package/packages/agent-skills/dist/deploy.d.ts +2 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +15 -8
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/manifest.d.ts +1 -0
- package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
- package/packages/agent-skills/dist/manifest.js +6 -2
- package/packages/agent-skills/dist/manifest.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +15 -8
- package/packages/agent-skills/src/manifest.ts +5 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +401 -91
- package/packages/azdo/src/lib/graph-identity.ts +190 -3
- package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
- package/packages/fleet/manifest.json +2 -0
- package/packages/fleet/manifest.schema.json +9 -0
- package/packages/repo-controls/README.md +13 -6
- package/packages/repo-controls/assets/adl-config.default.toml +1 -0
- package/packages/repo-controls/src/cli.ts +6 -1
- 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:
|
|
4
|
-
sha256:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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,
|
package/bin/validate-manifest
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
169
|
-
writeJsonIfChanged(targetAbs, fileContent, changed)
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
|
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
|
}
|