@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/adl +2 -2
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +460 -240
- package/bin/validate-manifest +36 -16
- 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 +6 -4
- package/packages/fleet/manifest.schema.json +15 -5
- 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/gitignore-block.ts +17 -0
- package/packages/repo-controls/src/install.ts +88 -6
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 |
|
|
@@ -70,18 +88,19 @@ if ! jq -e '
|
|
|
70
88
|
(.mac.devices.m3.surface_clis // [] | any(.name == "codex" and .manager == "brew" and .command == "brew" and .update_args == ["upgrade", "--cask", "codex"] and (.report_only // false) == false)) and
|
|
71
89
|
(.mac.devices.m5.surface_clis // [] | any(.name == "codex" and .manager == "brew" and .command == "brew" and .update_args == ["upgrade", "--cask", "codex"] and (.report_only // false) == false)) and
|
|
72
90
|
([.mac.common.tools[], .linux.common.tools[], .mac.devices[].tools[], .linux.devices[].tools[]] |
|
|
73
|
-
all(.manager == "brew" or .manager == "apt" or .manager == "npm" or .manager == "pip" or .manager == "release-archive
|
|
91
|
+
all(.manager == "brew" or .manager == "apt" or .manager == "npm" or .manager == "pip" or .manager == "release-archive-latest" or .manager == "shim" or .manager == "repo-script" or .manager == "repo-dir" or .manager == "bun-compile" or .manager == "embedding-model") and
|
|
74
92
|
all((.package | type) == "string" and (.package | length) > 0) and
|
|
75
93
|
all((.executable | type) == "string" and (.executable | length) > 0) and
|
|
94
|
+
# Second review pass, 2026-09-29: the removal-path guard in bin/
|
|
95
|
+
# reconcile (executable_claimed_by_current_manifest) compares
|
|
96
|
+
# .executable paths lexically after normalizing a legacy, unvalidated
|
|
97
|
+
# previous-manifest path -- a CURRENT manifest must never need that
|
|
98
|
+
# normalization in the first place, so a "//" or "/./" segment is
|
|
99
|
+
# rejected here outright rather than silently accepted and normalized
|
|
100
|
+
# away later.
|
|
101
|
+
all((.executable | contains("//") | not) and (.executable | contains("/./") | not)) and
|
|
76
102
|
all((.reason // null) == null or (.reason | type == "string" and length > 0)) and
|
|
77
|
-
all(if .manager == "release-archive" then
|
|
78
|
-
((.version | type) == "string" and (.version | length) > 0) and
|
|
79
|
-
((.url | type) == "string" and (.url | startswith("https://"))) and
|
|
80
|
-
((.sha256 | type) == "string" and (.sha256 | test("^[0-9a-f]{64}$"))) and
|
|
81
|
-
((.archive_executable | type) == "string" and
|
|
82
|
-
(.archive_executable | test("^[^/]") and (contains("..") | not))) and
|
|
83
|
-
(.executable | startswith("/usr/local/bin/"))
|
|
84
|
-
elif .manager == "release-archive-latest" then
|
|
103
|
+
all(if .manager == "release-archive-latest" then
|
|
85
104
|
((.repo | type) == "string" and (.repo | test("^[^/[:space:]]+/[^/[:space:]]+$"))) and
|
|
86
105
|
((.asset | type) == "string" and
|
|
87
106
|
(.asset | test("^[^/]") and (contains("..") | not))) and
|
|
@@ -124,12 +143,13 @@ if ! jq -e '
|
|
|
124
143
|
((.dim | type) == "number") and
|
|
125
144
|
(has("revision") | not) and (has("source_files") | not) and (has("artifact_files") | not)
|
|
126
145
|
elif .manager == "npm" then
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
146
|
+
# Tarun, 2026-09-29 (hard rule): versions are never pinned -- every
|
|
147
|
+
# fleet tool installs latest stable. .version on npm used to be an
|
|
148
|
+
# optional pin (Handoff 5, 2026-09-26, H4); it is refused outright now,
|
|
149
|
+
# the same way release-archive-latest already refuses one, and the
|
|
150
|
+
# same day the release-archive manager (pinned version+url+sha256) was
|
|
151
|
+
# removed from the accepted managers above entirely.
|
|
152
|
+
(has("version") | not) and
|
|
133
153
|
# .allow_scripts (docs/operations/global-tools.md Topic 2, decided
|
|
134
154
|
# 2026-09-28): an explicit, per-entry allow-list of dependency package
|
|
135
155
|
# names whose install/postinstall scripts reconcile passes through as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
|
|
3
|
-
"version": "0.1.
|
|
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
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { existsSync, readFileSync } from 'node:fs'
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { mergeCursorHooksFile, mergeJsonHooksFile } from './json-merge.ts'
|
|
20
|
-
import { readLock } from './lock.ts'
|
|
20
|
+
import { type JsonHooksLockEntry, readLock } from './lock.ts'
|
|
21
21
|
import { renderOpencodePlugin } from './opencode-template.ts'
|
|
22
22
|
import {
|
|
23
23
|
DEFAULT_PROFILE_PATH,
|
|
@@ -70,7 +70,14 @@ function readExistingJson(path: string): Record<string, unknown> | undefined {
|
|
|
70
70
|
function doctorJsonSurface(surface: HookSurface, targetRepo: string, hooksDoc: CanonicalHooksDoc, lock: ReturnType<typeof readLock>): DoctorIssue[] {
|
|
71
71
|
const targetAbs = join(targetRepo, surface.file)
|
|
72
72
|
const exists = existsSync(targetAbs)
|
|
73
|
-
const
|
|
73
|
+
const lockEntry = lock.entries[surface.file]?.format === surface.format ? (lock.entries[surface.file] as JsonHooksLockEntry) : undefined
|
|
74
|
+
// Same fallback deploy.ts itself uses: a lock with no ownedEntries at all
|
|
75
|
+
// (every repo enrolled before the content-hash fix) has no hashes to
|
|
76
|
+
// give, so its OLD ownedCommands feeds the command-anywhere channel
|
|
77
|
+
// instead -- otherwise doctor would report drift for every currently-
|
|
78
|
+
// enrolled repo's untouched content that deploy itself recognizes fine.
|
|
79
|
+
const recordedEntries = lockEntry?.ownedEntries ?? {}
|
|
80
|
+
const legacyCommandsAnywhere = lockEntry !== undefined && lockEntry.ownedEntries === undefined ? lockEntry.ownedCommands : {}
|
|
74
81
|
|
|
75
82
|
let parseFailed = false
|
|
76
83
|
if (exists) {
|
|
@@ -85,21 +92,64 @@ function doctorJsonSurface(surface: HookSurface, targetRepo: string, hooksDoc: C
|
|
|
85
92
|
const existingRaw = readExistingJson(targetAbs)
|
|
86
93
|
const merged =
|
|
87
94
|
surface.format === 'cursor-hooks-v1'
|
|
88
|
-
? mergeCursorHooksFile(existingRaw, renderCursorEntries(hooksDoc, surface),
|
|
89
|
-
: mergeJsonHooksFile(existingRaw, renderJsonHooksGroups(hooksDoc, surface),
|
|
95
|
+
? mergeCursorHooksFile(existingRaw, renderCursorEntries(hooksDoc, surface), recordedEntries, legacyCommandsAnywhere)
|
|
96
|
+
: mergeJsonHooksFile(existingRaw, renderJsonHooksGroups(hooksDoc, surface), recordedEntries, legacyCommandsAnywhere)
|
|
90
97
|
|
|
91
98
|
if (!exists) {
|
|
92
99
|
return [{ severity: 'error', path: surface.file, reason: 'expected hook wiring has not been deployed' }]
|
|
93
100
|
}
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// Independent review, 2026-09-30, round 5 (finding 1, pair-scoped —
|
|
102
|
+
// never flags a same-command entry under a different matcher): a
|
|
103
|
+
// duplicated adl-owned (command, matcher) pair can sit in the file
|
|
104
|
+
// even when the merge is otherwise a structural no-op (the duplicate
|
|
105
|
+
// isn't healed into the verified original, but it also isn't
|
|
106
|
+
// restructured — nothing else changes), so this check runs
|
|
107
|
+
// UNCONDITIONALLY, never gated behind `merged.changed`.
|
|
108
|
+
const duplicateIssues: DoctorIssue[] = Object.entries(merged.duplicateCommands).flatMap(([event, commands]) =>
|
|
109
|
+
commands.map((command) => ({
|
|
110
|
+
severity: 'error' as const,
|
|
111
|
+
path: `${surface.file}#${event}`,
|
|
112
|
+
reason: `adl-owned command appears more than once (a duplicate, not a self-heal): ${command} — the hook runs twice; deploy does not remove a user's copy, so remove the extra copy by hand`,
|
|
113
|
+
})),
|
|
114
|
+
)
|
|
115
|
+
// Round 5, finding 2: more than one unverified candidate for a pair is
|
|
116
|
+
// ambiguous — deploy heals neither, so this is ALSO unconditional
|
|
117
|
+
// (never gated behind `merged.changed`, since a frozen event by
|
|
118
|
+
// definition never registers as changed).
|
|
119
|
+
const ambiguousIssues: DoctorIssue[] = merged.ambiguousConflicts.map((conflict) => ({
|
|
96
120
|
severity: 'error' as const,
|
|
97
|
-
path: `${surface.file}#${event}`,
|
|
98
|
-
reason:
|
|
99
|
-
previousOwned[event] !== undefined
|
|
100
|
-
? 'deploy-written hook entries were modified since deploy, or assets/hooks.json changed since; run deploy to update'
|
|
101
|
-
: 'expected hook wiring for this event has not been deployed',
|
|
121
|
+
path: `${surface.file}#${conflict.event}`,
|
|
122
|
+
reason: `two unverified copies of this adl-owned hook exist (ambiguous — matcher ${conflict.matcher ?? 'none'}, command "${conflict.command}"); deploy will not choose one for you, resolve by hand`,
|
|
102
123
|
}))
|
|
124
|
+
if (!merged.changed) return [...duplicateIssues, ...ambiguousIssues]
|
|
125
|
+
// Round 5, finding 2: when a heal is about to happen (not just "some
|
|
126
|
+
// drift"), doctor's own PREVIEW names exactly what would be restored —
|
|
127
|
+
// "restored adl hook <event>/<matcher>/<command>" — instead of the
|
|
128
|
+
// generic "modified since deploy" wording, matching what deploy's own
|
|
129
|
+
// output and `adl install` will say.
|
|
130
|
+
const restoredByEvent = new Map(merged.restored.map((r) => [r.event, r] as const))
|
|
131
|
+
return [
|
|
132
|
+
...duplicateIssues,
|
|
133
|
+
...ambiguousIssues,
|
|
134
|
+
...merged.changedEvents.map((event) => {
|
|
135
|
+
const restoredHere = restoredByEvent.get(event)
|
|
136
|
+
if (restoredHere !== undefined) {
|
|
137
|
+
return {
|
|
138
|
+
severity: 'error' as const,
|
|
139
|
+
path: `${surface.file}#${event}`,
|
|
140
|
+
reason: `restored adl hook ${event}/${restoredHere.matcher ?? '-'}/${restoredHere.command} — local changes to it were replaced; run deploy to apply`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
severity: 'error' as const,
|
|
145
|
+
path: `${surface.file}#${event}`,
|
|
146
|
+
reason:
|
|
147
|
+
recordedEntries[event] !== undefined || legacyCommandsAnywhere[event] !== undefined
|
|
148
|
+
? 'deploy-written hook entries were modified since deploy, or assets/hooks.json changed since; run deploy to update'
|
|
149
|
+
: 'expected hook wiring for this event has not been deployed',
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
]
|
|
103
153
|
}
|
|
104
154
|
|
|
105
155
|
function doctorOpencodeSurface(surface: HookSurface, ctx: { assetsDir: string; targetRepo: string; hooksDoc: CanonicalHooksDoc; lock: ReturnType<typeof readLock> }): DoctorIssue[] {
|