@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
|
@@ -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[] {
|
|
@@ -12,28 +12,317 @@
|
|
|
12
12
|
* event order — this is what makes a deploy into an empty repo reproduce
|
|
13
13
|
* this repo's own committed `.claude/settings.json` / `.codex/hooks.json` /
|
|
14
14
|
* `.cursor/hooks.json` byte-for-byte (json-merge.test.ts / deploy.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* Merge contract, ownership mechanism corrected TWICE by independent
|
|
17
|
+
* reviews the same day (2026-09-29):
|
|
18
|
+
*
|
|
19
|
+
* Review 1 found: identifying "still-desired" entries by stripping their
|
|
20
|
+
* `--host=<surface>` suffix and comparing the remainder ALSO matched a
|
|
21
|
+
* user's own lookalike entry (same command text, different matcher, never
|
|
22
|
+
* written by adl) and deleted it.
|
|
23
|
+
*
|
|
24
|
+
* Review 2 found the FIRST fix (pure array-index tracking) had two of its
|
|
25
|
+
* own bugs: (a) CRITICAL — every repo enrolled before that fix has a lock
|
|
26
|
+
* with no recorded indices at all, so the very first deploy after
|
|
27
|
+
* upgrading read "owns nothing" and duplicated every adl hook; (b) HIGH —
|
|
28
|
+
* position tracking broke the moment a user inserted or removed an entry
|
|
29
|
+
* BEFORE adl's own block (everything downstream shifted, capturing/
|
|
30
|
+
* overwriting the user's entry and duplicating adl's), and could not
|
|
31
|
+
* express "a user hook appended INSIDE an adl-owned group's own `hooks`
|
|
32
|
+
* array" — that got silently deleted instead of kept.
|
|
33
|
+
*
|
|
34
|
+
* Ownership is now CONTENT-ADDRESSED (`isOwnedHook`, below): an existing
|
|
35
|
+
* hook is adl's iff EITHER (a) its own hash (type+command+statusMessage+
|
|
36
|
+
* timeper — never the group's `matcher`, which is a group-level field)
|
|
37
|
+
* matches a hash THIS package's lock recorded writing, ANYWHERE in the
|
|
38
|
+
* array (survives reordering/insertion/deletion elsewhere — closes (b)),
|
|
39
|
+
* or (b) it sits at the GROUP INDEX a recorded hook was written at and its
|
|
40
|
+
* `command` still matches (a tampered field OTHER than command, restored
|
|
41
|
+
* in place — the position signal is now a NARROW fallback, not the whole
|
|
42
|
+
* mechanism). A GROUP with a mix of adl and user hooks is SPLIT
|
|
43
|
+
* (`splitGroupHooks`): the user's hooks survive in their own group at that
|
|
44
|
+
* position; adl's slot is restored separately, right there — never
|
|
45
|
+
* deleted, never duplicated.
|
|
46
|
+
*
|
|
47
|
+
* A lock recording no hashes at all (`ownedEntries` absent — every repo
|
|
48
|
+
* enrolled before this fix; closes (a)) falls back, for exactly this one
|
|
49
|
+
* transitional deploy, to command-anywhere matching against the lock's OLD
|
|
50
|
+
* `ownedCommands` — the only signal that data has — via the SAME
|
|
51
|
+
* `legacyOwnedCommands` channel the pre-agent-hooks engine's own takeover
|
|
52
|
+
* already used (both are "we have no better proof than an exact command
|
|
53
|
+
* string" signals). This run then persists real content hashes
|
|
54
|
+
* (`ownedEntries`), so every later deploy uses the full contract.
|
|
55
|
+
*
|
|
56
|
+
* THIRD independent review (2026-09-30) found the fallback ITSELF still
|
|
57
|
+
* leaked through raw array position, in two ways:
|
|
58
|
+
* 2E — the hash omitted the group's `matcher` entirely, so a byte-
|
|
59
|
+
* identical copy of an adl hook object pasted into a DIFFERENT
|
|
60
|
+
* matcher's group still hash-matched and got captured.
|
|
61
|
+
* 2G/2F/H — the position-based fallback compared a candidate's RAW
|
|
62
|
+
* array index to `recorded`'s ORDINAL index. A user group that
|
|
63
|
+
* merely happened to sit at the same raw index adl's entry once
|
|
64
|
+
* occupied (whether by coincidence, 2G, or because an unrelated
|
|
65
|
+
* earlier insert shifted adl's own tampered entry away from it,
|
|
66
|
+
* 2F/H) was captured or missed by that coincidence alone.
|
|
67
|
+
*
|
|
68
|
+
* Both are closed by dropping index-based reasoning entirely:
|
|
69
|
+
* - `hashHook`/`hashCursorEntry` now include `matcher` AND `event` in
|
|
70
|
+
* the digest, so a lookalike with a different matcher simply never
|
|
71
|
+
* hash-matches, full stop (closes 2E).
|
|
72
|
+
* - The fallback is now a PAIR rule, not a position rule: a non-hash-
|
|
73
|
+
* matching candidate heals in place iff its (command, matcher) pair
|
|
74
|
+
* is one adl recorded for this event AND no OTHER entry ANYWHERE in
|
|
75
|
+
* the array already hash-verifies that exact pair. A verified copy
|
|
76
|
+
* existing elsewhere means THIS candidate is the user's — a
|
|
77
|
+
* lookalike or a genuine accidental duplicate — never healed (closes
|
|
78
|
+
* 2G and 2F/H at once; a raw index is never consulted).
|
|
79
|
+
*
|
|
80
|
+
* FOURTH independent review (2026-09-30, round 5) found two more real
|
|
81
|
+
* bugs, both in how that pair rule counted things PER COMMAND instead of
|
|
82
|
+
* per (command, matcher) pair, and in what it did when MULTIPLE
|
|
83
|
+
* unverified candidates shared one pair:
|
|
84
|
+
*
|
|
85
|
+
* 1. MED — `duplicateCommands` counted every existing occurrence of a
|
|
86
|
+
* RECORDED COMMAND, ignoring matcher entirely. A legit user hook
|
|
87
|
+
* that reuses one of adl's command STRINGS under a genuinely
|
|
88
|
+
* DIFFERENT matcher (already proven safe from capture by 2E/2G) got
|
|
89
|
+
* counted as "the same command appears twice" and flagged by doctor
|
|
90
|
+
* forever — deploy has nothing to change (the merge is correctly a
|
|
91
|
+
* no-op), so the error could never clear.
|
|
92
|
+
* 2. MED — the old fallback healed whichever unverified candidate for
|
|
93
|
+
* a pair it happened to encounter FIRST in iteration order. When a
|
|
94
|
+
* user's OWN customized copy of a pair coexisted with adl's real
|
|
95
|
+
* entry ALSO tampered (both unverified), the fallback picked one
|
|
96
|
+
* arbitrarily: either the user's copy got silently overwritten with
|
|
97
|
+
* no explanation (item 2, case a: adl's real entry was simply gone,
|
|
98
|
+
* only the user's customized one remained — a single unverified
|
|
99
|
+
* candidate, which is a legitimate heal, but it was never NAMED in
|
|
100
|
+
* any output), or — worse — the user's copy got treated as "the
|
|
101
|
+
* heal target" while adl's own genuinely-tampered entry was left
|
|
102
|
+
* standing as "the user's", running the hook twice forever with no
|
|
103
|
+
* error (item 2, case a2: TWO unverified candidates for one pair).
|
|
104
|
+
*
|
|
105
|
+
* DECISION (Tarun, 2026-09-30):
|
|
106
|
+
* - `duplicateCommands` (doctor's own flag) now keys on the exact
|
|
107
|
+
* (command, matcher) PAIR, never command alone: it fires only when
|
|
108
|
+
* more than one entry hash-verifies for the same pair, or an
|
|
109
|
+
* unverified entry shares a pair with a verified one. A different
|
|
110
|
+
* matcher is never counted together with anything, full stop.
|
|
111
|
+
* - Exactly one unverified candidate for a pair, and zero verified
|
|
112
|
+
* ones -> heals in place (unchanged contract: a customized variant
|
|
113
|
+
* under the SAME matcher is adl's and gets restored) but the heal is
|
|
114
|
+
* now NAMED on the result (`restored`) so deploy, doctor's preview,
|
|
115
|
+
* and `adl install` can all say so explicitly.
|
|
116
|
+
* - MORE than one unverified candidate sharing a pair, with zero
|
|
117
|
+
* verified -> genuinely ambiguous: healing either one is a guess.
|
|
118
|
+
* Neither heals. The WHOLE EVENT for that surface is left exactly as
|
|
119
|
+
* found (no group split, no restore, nothing consumed from
|
|
120
|
+
* `desired`) and reported as a conflict (`ambiguousConflicts`) —
|
|
121
|
+
* deploy.ts turns this into a real `HookConflict`, non-zero exit.
|
|
15
122
|
*/
|
|
16
|
-
import
|
|
123
|
+
import { createHash } from 'node:crypto'
|
|
124
|
+
import type { RenderedCursorEntry, RenderedGroup, RenderedHookCommand } from './render.ts'
|
|
17
125
|
|
|
18
126
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
127
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
20
128
|
}
|
|
21
129
|
|
|
130
|
+
/** sha256 of a hook's OWN fields PLUS its group's `matcher` and the event name (2026-09-30, finding 2E: matcher used to be omitted as "a group-level field", which let a byte-identical copy pasted into a different matcher's group hash-match anyway). */
|
|
131
|
+
function hashHook(hook: { readonly type: string; readonly command: string; readonly statusMessage: string; readonly timeout: number }, matcher: string | undefined, event: string): string {
|
|
132
|
+
return createHash('sha256')
|
|
133
|
+
.update(JSON.stringify({ event, matcher: matcher ?? null, type: hook.type, command: hook.command, statusMessage: hook.statusMessage, timeout: hook.timeout }))
|
|
134
|
+
.digest('hex')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** sha256 of a cursor entry's own fields, INCLUDING `matcher` (entry-level here) and the event name — same finding 2E rationale as `hashHook`. */
|
|
138
|
+
function hashCursorEntry(entry: { readonly command: string; readonly statusMessage: string; readonly timeout: number; readonly matcher?: string }, event: string): string {
|
|
139
|
+
return createHash('sha256')
|
|
140
|
+
.update(JSON.stringify({ event, command: entry.command, statusMessage: entry.statusMessage, timeout: entry.timeout, matcher: entry.matcher ?? null }))
|
|
141
|
+
.digest('hex')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface OwnedEntry {
|
|
145
|
+
readonly hash: string
|
|
146
|
+
readonly command: string
|
|
147
|
+
/** The matcher this entry was recorded under (group-level for claude/codex, entry-level for cursor) — `undefined` when adl rendered no matcher. Needed by the PAIR fallback (2G/2F/H): "no verified hash exists for this (command, matcher)" cannot be answered from `command` alone. */
|
|
148
|
+
readonly matcher?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function pairKey(command: string, matcher: string | undefined): string {
|
|
152
|
+
return `${command}\u0000${matcher ?? ''}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Builds an `OwnedEntry`, omitting `matcher` entirely when undefined (this package's `exactOptionalPropertyTypes` refuses an explicit `matcher: undefined`). */
|
|
156
|
+
function makeOwnedEntry(hash: string, command: string, matcher: string | undefined): OwnedEntry {
|
|
157
|
+
return matcher !== undefined ? { hash, command, matcher } : { hash, command }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRecordHookCommand(value: unknown): value is RenderedHookCommand {
|
|
161
|
+
return isRecord(value) && typeof value.type === 'string' && typeof value.command === 'string' && typeof value.statusMessage === 'string' && typeof value.timeout === 'number'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One RECORDED pair's decoded identity, for building conflict/restore messages without re-parsing `pairKey`'s own encoding. */
|
|
165
|
+
interface PairIdentity {
|
|
166
|
+
readonly command: string
|
|
167
|
+
readonly matcher: string | undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-pair classification for one event, computed in a first pass over
|
|
172
|
+
* EVERY existing hook-like entry before any restore/split decision is
|
|
173
|
+
* made (2026-09-30, round 5 — see this file's header for the full
|
|
174
|
+
* history). Only pairs adl actually RECORDED are ever classified; a pair
|
|
175
|
+
* with no recorded entry at all is simply not adl's concern here.
|
|
176
|
+
*
|
|
177
|
+
* - `healablePairs`: zero verified copies, EXACTLY one unverified
|
|
178
|
+
* candidate — heals in place, named in `restored`.
|
|
179
|
+
* - `ambiguousPairs`: zero verified copies, MORE than one unverified
|
|
180
|
+
* candidate — genuinely ambiguous, healing either is a guess. Ownership
|
|
181
|
+
* of NEITHER is claimed; the whole event freezes (see the merge
|
|
182
|
+
* functions below) and this pair is reported as a conflict.
|
|
183
|
+
* - `duplicateCommands`: at least one verified copy, AND (more than one
|
|
184
|
+
* verified copy, OR at least one unverified copy sharing the SAME
|
|
185
|
+
* pair) — a real duplicate sitting alongside a provably-intact
|
|
186
|
+
* original. Reported for doctor, never affects the merge itself (the
|
|
187
|
+
* verified copy stays owned via its hash regardless; the extra
|
|
188
|
+
* copy(ies) are simply the user's, kept as-is).
|
|
189
|
+
*/
|
|
190
|
+
interface PairClassification {
|
|
191
|
+
readonly healablePairs: ReadonlySet<string>
|
|
192
|
+
readonly ambiguousPairs: ReadonlySet<string>
|
|
193
|
+
readonly ambiguousIdentities: readonly PairIdentity[]
|
|
194
|
+
readonly duplicateCommands: readonly string[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function classifyPairs(
|
|
198
|
+
hookLikes: readonly { readonly command: string; readonly hash: string; readonly matcher: string | undefined }[],
|
|
199
|
+
hashSet: ReadonlySet<string>,
|
|
200
|
+
recordedPairs: ReadonlyMap<string, PairIdentity>,
|
|
201
|
+
): PairClassification {
|
|
202
|
+
const verifiedCount = new Map<string, number>()
|
|
203
|
+
const unverifiedCount = new Map<string, number>()
|
|
204
|
+
for (const { command, hash, matcher } of hookLikes) {
|
|
205
|
+
const key = pairKey(command, matcher)
|
|
206
|
+
if (!recordedPairs.has(key)) continue // only adl-recorded pairs matter for heal/ambiguity/duplicate reasoning
|
|
207
|
+
if (hashSet.has(hash)) verifiedCount.set(key, (verifiedCount.get(key) ?? 0) + 1)
|
|
208
|
+
else unverifiedCount.set(key, (unverifiedCount.get(key) ?? 0) + 1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const healablePairs = new Set<string>()
|
|
212
|
+
const ambiguousPairs = new Set<string>()
|
|
213
|
+
const ambiguousIdentities: PairIdentity[] = []
|
|
214
|
+
const duplicateCommandSet = new Set<string>()
|
|
215
|
+
for (const [key, identity] of recordedPairs) {
|
|
216
|
+
const v = verifiedCount.get(key) ?? 0
|
|
217
|
+
const u = unverifiedCount.get(key) ?? 0
|
|
218
|
+
if (v === 0 && u > 1) {
|
|
219
|
+
ambiguousPairs.add(key)
|
|
220
|
+
ambiguousIdentities.push(identity)
|
|
221
|
+
} else if (v === 0 && u === 1) {
|
|
222
|
+
healablePairs.add(key)
|
|
223
|
+
} else if (v >= 1 && (v > 1 || u >= 1)) {
|
|
224
|
+
duplicateCommandSet.add(identity.command)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: [...duplicateCommandSet].sort() }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface PairContext {
|
|
231
|
+
readonly hashSet: ReadonlySet<string>
|
|
232
|
+
readonly healablePairs: ReadonlySet<string>
|
|
233
|
+
readonly legacyCommandsAnywhere: ReadonlySet<string>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One healed hook's identity, for the `restored` report ("restored adl hook <event>/<matcher>/<command> — local changes to it were replaced"). */
|
|
237
|
+
export interface RestoredHook {
|
|
238
|
+
readonly command: string
|
|
239
|
+
readonly matcher?: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Content-addressed ownership check for one existing hook/entry. The
|
|
244
|
+
* fallback is a PAIR rule, never a position/index rule (2026-09-30,
|
|
245
|
+
* findings 2G/2F/H): a non-hash-matching candidate heals in place iff
|
|
246
|
+
* its (command, matcher) pair is in `healablePairs` — which
|
|
247
|
+
* `classifyPairs` (above) has ALREADY confirmed has zero verified
|
|
248
|
+
* copies and EXACTLY one unverified candidate, so there is no ambiguity
|
|
249
|
+
* left to resolve here.
|
|
250
|
+
*/
|
|
251
|
+
function isOwnedHook(
|
|
252
|
+
candidate: { readonly command: string },
|
|
253
|
+
candidateHash: string,
|
|
254
|
+
matcher: string | undefined,
|
|
255
|
+
ctx: PairContext,
|
|
256
|
+
): boolean {
|
|
257
|
+
if (ctx.hashSet.has(candidateHash)) return true
|
|
258
|
+
if (ctx.legacyCommandsAnywhere.has(candidate.command)) return true
|
|
259
|
+
return ctx.healablePairs.has(pairKey(candidate.command, matcher))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Splits an existing group's own `hooks` array into adl's (owned) and the user's (everything else), preserving the group's `matcher`. Collects any hook healed via the PAIR fallback (not hash, not legacy) into `restored`. */
|
|
263
|
+
function splitGroupHooks(
|
|
264
|
+
hooks: readonly unknown[],
|
|
265
|
+
matcher: string | undefined,
|
|
266
|
+
event: string,
|
|
267
|
+
ctx: PairContext,
|
|
268
|
+
restored: RestoredHook[],
|
|
269
|
+
): { readonly ownedCount: number; readonly userHooks: readonly unknown[] } {
|
|
270
|
+
let ownedCount = 0
|
|
271
|
+
const userHooks: unknown[] = []
|
|
272
|
+
for (const hook of hooks) {
|
|
273
|
+
if (!isRecordHookCommand(hook)) {
|
|
274
|
+
userHooks.push(hook)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
const hash = hashHook(hook, matcher, event)
|
|
278
|
+
if (isOwnedHook(hook, hash, matcher, ctx)) {
|
|
279
|
+
ownedCount += 1
|
|
280
|
+
if (!ctx.hashSet.has(hash) && !ctx.legacyCommandsAnywhere.has(hook.command)) {
|
|
281
|
+
restored.push(matcher !== undefined ? { command: hook.command, matcher } : { command: hook.command })
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
userHooks.push(hook)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { ownedCount, userHooks }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One event's ambiguous-pair conflict, for `JsonHooksMergeResult.ambiguousConflicts`/`CursorHooksMergeResult.ambiguousConflicts`. */
|
|
291
|
+
export interface AmbiguousPairConflict {
|
|
292
|
+
readonly event: string
|
|
293
|
+
readonly command: string
|
|
294
|
+
readonly matcher?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Builds an `AmbiguousPairConflict`, omitting `matcher` entirely when undefined (same `exactOptionalPropertyTypes` concern as `makeOwnedEntry`). */
|
|
298
|
+
function makeAmbiguousConflict(event: string, identity: PairIdentity): AmbiguousPairConflict {
|
|
299
|
+
return identity.matcher !== undefined ? { event, command: identity.command, matcher: identity.matcher } : { event, command: identity.command }
|
|
300
|
+
}
|
|
301
|
+
|
|
22
302
|
export interface JsonHooksMergeResult {
|
|
23
303
|
/** The complete object to JSON.stringify and write. */
|
|
24
304
|
readonly fileContent: Record<string, unknown>
|
|
25
|
-
/** New lock contents: surface event name -> the command strings this deploy now owns there. */
|
|
305
|
+
/** New lock contents: surface event name -> the command strings this deploy now owns there (informational — list.ts). */
|
|
26
306
|
readonly ownedCommands: Readonly<Record<string, readonly string[]>>
|
|
307
|
+
/** New lock contents: surface event name -> this deploy's own written hooks, content-addressed — the actual ownership record the next run reads back. */
|
|
308
|
+
readonly ownedEntries: Readonly<Record<string, readonly OwnedEntry[]>>
|
|
27
309
|
readonly changed: boolean
|
|
28
310
|
/** Events whose array actually differs from what was already on disk — doctor.ts's per-entry reasons. */
|
|
29
311
|
readonly changedEvents: readonly string[]
|
|
312
|
+
/** Event -> adl-owned commands with a genuine duplicate (2026-09-30, round 5, finding 1: pair-scoped — a different matcher is never counted). Set even when the merge is otherwise a structural no-op, since the duplicate can sit in an already-unchanged file. doctor.ts turns this into its own dedicated issue. */
|
|
313
|
+
readonly duplicateCommands: Readonly<Record<string, readonly string[]>>
|
|
314
|
+
/** Hooks healed via the PAIR fallback this run (not by hash, not by the legacy channel) — named so deploy/doctor/`adl install` can report "restored adl hook <event>/<matcher>/<command>" (2026-09-30, round 5, finding 2). */
|
|
315
|
+
readonly restored: readonly (RestoredHook & { readonly event: string })[]
|
|
316
|
+
/** Events frozen this run because more than one unverified candidate shared a recorded pair — genuinely ambiguous, neither healed, the event's array left exactly as found (2026-09-30, round 5, finding 2). deploy.ts turns each into a real conflict. */
|
|
317
|
+
readonly ambiguousConflicts: readonly AmbiguousPairConflict[]
|
|
30
318
|
}
|
|
31
319
|
|
|
32
320
|
/** For claude-settings / codex-hooks: `{hooks: {Event: [{matcher?, hooks: [...]}]}}`. */
|
|
33
321
|
export function mergeJsonHooksFile(
|
|
34
322
|
existing: Record<string, unknown> | undefined,
|
|
35
323
|
desired: ReadonlyMap<string, readonly RenderedGroup[]>,
|
|
36
|
-
|
|
324
|
+
recordedEntries: Readonly<Record<string, readonly OwnedEntry[]>>,
|
|
325
|
+
legacyOwnedCommands: Readonly<Record<string, readonly string[]>> = {},
|
|
37
326
|
): JsonHooksMergeResult {
|
|
38
327
|
const base: Record<string, unknown> = existing ? { ...existing } : {}
|
|
39
328
|
const existingHooks = isRecord(base.hooks) ? base.hooks : {}
|
|
@@ -43,21 +332,83 @@ export function mergeJsonHooksFile(
|
|
|
43
332
|
|
|
44
333
|
const newHooks: Record<string, unknown> = {}
|
|
45
334
|
const ownedCommands: Record<string, readonly string[]> = {}
|
|
335
|
+
const ownedEntries: Record<string, readonly OwnedEntry[]> = {}
|
|
46
336
|
const changedEvents: string[] = []
|
|
337
|
+
const duplicateCommands: Record<string, readonly string[]> = {}
|
|
338
|
+
const restored: (RestoredHook & { readonly event: string })[] = []
|
|
339
|
+
const ambiguousConflicts: AmbiguousPairConflict[] = []
|
|
47
340
|
let changed = existing === undefined
|
|
48
341
|
|
|
49
342
|
for (const event of orderedEvents) {
|
|
50
343
|
const existingGroups = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
})
|
|
344
|
+
const recorded = recordedEntries[event] ?? []
|
|
345
|
+
const hashSet = new Set(recorded.map((r) => r.hash))
|
|
346
|
+
const recordedPairs = new Map(recorded.map((r) => [pairKey(r.command, r.matcher), { command: r.command, matcher: r.matcher }] as const))
|
|
347
|
+
const legacyCommandsAnywhere = new Set(legacyOwnedCommands[event] ?? [])
|
|
56
348
|
const desiredGroups = desired.get(event) ?? []
|
|
349
|
+
|
|
350
|
+
// Pass 1: classify every existing hook-like by (command, matcher)
|
|
351
|
+
// pair BEFORE any restore/split decision — the fallback rule for one
|
|
352
|
+
// entry depends on whether ANOTHER entry (anywhere) already proves
|
|
353
|
+
// the real one is intact, or shares its own ambiguity.
|
|
354
|
+
const existingHookLikes = existingGroups.flatMap((group) =>
|
|
355
|
+
isRecord(group) && Array.isArray(group.hooks)
|
|
356
|
+
? group.hooks.filter(isRecordHookCommand).map((hook) => ({ command: hook.command, hash: hashHook(hook, group.matcher as string | undefined, event), matcher: group.matcher as string | undefined }))
|
|
357
|
+
: [],
|
|
358
|
+
)
|
|
359
|
+
const { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: dupes } = classifyPairs(existingHookLikes, hashSet, recordedPairs)
|
|
360
|
+
if (dupes.length > 0) duplicateCommands[event] = dupes
|
|
361
|
+
|
|
362
|
+
if (ambiguousPairs.size > 0) {
|
|
363
|
+
// Genuinely ambiguous: more than one unverified candidate shares a
|
|
364
|
+
// pair adl recorded. Heal neither — freeze this WHOLE event exactly
|
|
365
|
+
// as found, carry the lock's own record forward unchanged (so a
|
|
366
|
+
// later run re-evaluates the same state, never silently forgetting
|
|
367
|
+
// ownership), and report it as a conflict.
|
|
368
|
+
for (const identity of ambiguousIdentities) ambiguousConflicts.push(makeAmbiguousConflict(event, identity))
|
|
369
|
+
if (recorded.length > 0) {
|
|
370
|
+
ownedCommands[event] = recorded.map((r) => r.command)
|
|
371
|
+
ownedEntries[event] = recorded
|
|
372
|
+
}
|
|
373
|
+
if (existingGroups.length > 0) newHooks[event] = existingGroups
|
|
374
|
+
continue // no consumeDesired at all -- nothing in this event changes this run
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const ctx: PairContext = { hashSet, healablePairs, legacyCommandsAnywhere }
|
|
378
|
+
const restoredThisEvent: RestoredHook[] = []
|
|
379
|
+
|
|
380
|
+
const merged: unknown[] = []
|
|
381
|
+
let desiredCursor = 0
|
|
382
|
+
const consumeDesired = (): void => {
|
|
383
|
+
if (desiredCursor < desiredGroups.length) {
|
|
384
|
+
merged.push(desiredGroups[desiredCursor])
|
|
385
|
+
desiredCursor += 1
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const group of existingGroups) {
|
|
390
|
+
if (!isRecord(group) || !Array.isArray(group.hooks) || group.hooks.length === 0) {
|
|
391
|
+
merged.push(group)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
const { ownedCount, userHooks } = splitGroupHooks(group.hooks, group.matcher as string | undefined, event, ctx, restoredThisEvent)
|
|
395
|
+
if (ownedCount === 0) {
|
|
396
|
+
merged.push(group) // fully the user's -- untouched, no desired slot consumed
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (userHooks.length > 0) {
|
|
400
|
+
// split: the user's remainder survives in its own group, right here.
|
|
401
|
+
merged.push(group.matcher !== undefined ? { matcher: group.matcher, hooks: userHooks } : { hooks: userHooks })
|
|
402
|
+
}
|
|
403
|
+
consumeDesired() // restore/replace adl's slot at this position
|
|
404
|
+
}
|
|
405
|
+
while (desiredCursor < desiredGroups.length) consumeDesired()
|
|
406
|
+
for (const hook of restoredThisEvent) restored.push({ ...hook, event })
|
|
407
|
+
|
|
57
408
|
if (desiredGroups.length > 0) {
|
|
58
409
|
ownedCommands[event] = desiredGroups.flatMap((g) => g.hooks.map((h) => h.command))
|
|
410
|
+
ownedEntries[event] = desiredGroups.flatMap((g) => g.hooks.map((h) => makeOwnedEntry(hashHook(h, g.matcher, event), h.command, g.matcher)))
|
|
59
411
|
}
|
|
60
|
-
const merged = [...keptGroups, ...desiredGroups]
|
|
61
412
|
if (merged.length > 0) newHooks[event] = merged
|
|
62
413
|
if (JSON.stringify(existingHooks[event] ?? null) !== JSON.stringify(merged.length > 0 ? merged : null)) {
|
|
63
414
|
changed = true
|
|
@@ -66,23 +417,32 @@ export function mergeJsonHooksFile(
|
|
|
66
417
|
}
|
|
67
418
|
|
|
68
419
|
base.hooks = newHooks
|
|
69
|
-
return { fileContent: base, ownedCommands, changed, changedEvents }
|
|
420
|
+
return { fileContent: base, ownedCommands, ownedEntries, changed, changedEvents, duplicateCommands, restored, ambiguousConflicts }
|
|
70
421
|
}
|
|
71
422
|
|
|
72
423
|
export interface CursorHooksMergeResult {
|
|
73
424
|
readonly fileContent: Record<string, unknown>
|
|
74
|
-
/** New lock contents: Cursor's own event name -> the command strings this deploy now owns there. */
|
|
425
|
+
/** New lock contents: Cursor's own event name -> the command strings this deploy now owns there (informational — list.ts). */
|
|
75
426
|
readonly ownedCommands: Readonly<Record<string, readonly string[]>>
|
|
427
|
+
/** New lock contents: Cursor's own event name -> this deploy's own written entries, content-addressed. */
|
|
428
|
+
readonly ownedEntries: Readonly<Record<string, readonly OwnedEntry[]>>
|
|
76
429
|
readonly changed: boolean
|
|
77
430
|
/** Events whose array actually differs from what was already on disk — doctor.ts's per-entry reasons. */
|
|
78
431
|
readonly changedEvents: readonly string[]
|
|
432
|
+
/** Event -> adl-owned commands with a genuine duplicate — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
433
|
+
readonly duplicateCommands: Readonly<Record<string, readonly string[]>>
|
|
434
|
+
/** Entries healed via the PAIR fallback this run — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
435
|
+
readonly restored: readonly (RestoredHook & { readonly event: string })[]
|
|
436
|
+
/** Events frozen this run because of an ambiguous pair — see `JsonHooksMergeResult`'s own field for the full rationale. */
|
|
437
|
+
readonly ambiguousConflicts: readonly AmbiguousPairConflict[]
|
|
79
438
|
}
|
|
80
439
|
|
|
81
|
-
/** For cursor-hooks-v1: `{version: 1, hooks: {event: [{command, statusMessage, timeout, matcher?}]}}`. */
|
|
440
|
+
/** For cursor-hooks-v1: `{version: 1, hooks: {event: [{command, statusMessage, timeout, matcher?}]}}`. No groups here — each entry is classified (and, unlike claude/codex, never split) on its own. */
|
|
82
441
|
export function mergeCursorHooksFile(
|
|
83
442
|
existing: Record<string, unknown> | undefined,
|
|
84
443
|
desired: ReadonlyMap<string, readonly RenderedCursorEntry[]>,
|
|
85
|
-
|
|
444
|
+
recordedEntries: Readonly<Record<string, readonly OwnedEntry[]>>,
|
|
445
|
+
legacyOwnedCommands: Readonly<Record<string, readonly string[]>> = {},
|
|
86
446
|
): CursorHooksMergeResult {
|
|
87
447
|
const base: Record<string, unknown> = existing ? { ...existing } : { version: 1 }
|
|
88
448
|
if (base.version === undefined) base.version = 1
|
|
@@ -93,16 +453,68 @@ export function mergeCursorHooksFile(
|
|
|
93
453
|
|
|
94
454
|
const newHooks: Record<string, unknown> = {}
|
|
95
455
|
const ownedCommands: Record<string, readonly string[]> = {}
|
|
456
|
+
const ownedEntries: Record<string, readonly OwnedEntry[]> = {}
|
|
96
457
|
const changedEvents: string[] = []
|
|
458
|
+
const duplicateCommands: Record<string, readonly string[]> = {}
|
|
459
|
+
const restored: (RestoredHook & { readonly event: string })[] = []
|
|
460
|
+
const ambiguousConflicts: AmbiguousPairConflict[] = []
|
|
97
461
|
let changed = existing === undefined
|
|
98
462
|
|
|
99
463
|
for (const event of orderedEvents) {
|
|
100
464
|
const existingEntries = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []
|
|
101
|
-
const
|
|
102
|
-
const
|
|
465
|
+
const recorded = recordedEntries[event] ?? []
|
|
466
|
+
const hashSet = new Set(recorded.map((r) => r.hash))
|
|
467
|
+
const recordedPairs = new Map(recorded.map((r) => [pairKey(r.command, r.matcher), { command: r.command, matcher: r.matcher }] as const))
|
|
468
|
+
const legacyCommandsAnywhere = new Set(legacyOwnedCommands[event] ?? [])
|
|
103
469
|
const desiredEntries = desired.get(event) ?? []
|
|
104
|
-
|
|
105
|
-
const
|
|
470
|
+
|
|
471
|
+
const existingHookLikes = existingEntries
|
|
472
|
+
.filter((entry): entry is Record<string, unknown> => isRecord(entry) && typeof entry.command === 'string')
|
|
473
|
+
.map((entry) => ({ command: entry.command as string, hash: hashCursorEntry(entry as never, event), matcher: entry.matcher as string | undefined }))
|
|
474
|
+
const { healablePairs, ambiguousPairs, ambiguousIdentities, duplicateCommands: dupes } = classifyPairs(existingHookLikes, hashSet, recordedPairs)
|
|
475
|
+
if (dupes.length > 0) duplicateCommands[event] = dupes
|
|
476
|
+
|
|
477
|
+
if (ambiguousPairs.size > 0) {
|
|
478
|
+
for (const identity of ambiguousIdentities) ambiguousConflicts.push(makeAmbiguousConflict(event, identity))
|
|
479
|
+
if (recorded.length > 0) {
|
|
480
|
+
ownedCommands[event] = recorded.map((r) => r.command)
|
|
481
|
+
ownedEntries[event] = recorded
|
|
482
|
+
}
|
|
483
|
+
if (existingEntries.length > 0) newHooks[event] = existingEntries
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const merged: unknown[] = []
|
|
488
|
+
let desiredCursor = 0
|
|
489
|
+
for (const entry of existingEntries) {
|
|
490
|
+
if (!isRecord(entry) || typeof entry.command !== 'string') {
|
|
491
|
+
merged.push(entry)
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
const matcher = entry.matcher as string | undefined
|
|
495
|
+
const hash = hashCursorEntry(entry as never, event)
|
|
496
|
+
const isOwned = isOwnedHook(entry as { command: string }, hash, matcher, { hashSet, healablePairs, legacyCommandsAnywhere })
|
|
497
|
+
if (!isOwned) {
|
|
498
|
+
merged.push(entry)
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
if (!hashSet.has(hash) && !legacyCommandsAnywhere.has(entry.command)) {
|
|
502
|
+
restored.push(matcher !== undefined ? { command: entry.command, matcher, event } : { command: entry.command, event })
|
|
503
|
+
}
|
|
504
|
+
if (desiredCursor < desiredEntries.length) {
|
|
505
|
+
merged.push(desiredEntries[desiredCursor])
|
|
506
|
+
desiredCursor += 1
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
while (desiredCursor < desiredEntries.length) {
|
|
510
|
+
merged.push(desiredEntries[desiredCursor])
|
|
511
|
+
desiredCursor += 1
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (desiredEntries.length > 0) {
|
|
515
|
+
ownedCommands[event] = desiredEntries.map((e) => e.command)
|
|
516
|
+
ownedEntries[event] = desiredEntries.map((e) => makeOwnedEntry(hashCursorEntry(e, event), e.command, e.matcher))
|
|
517
|
+
}
|
|
106
518
|
if (merged.length > 0) newHooks[event] = merged
|
|
107
519
|
if (JSON.stringify(existingHooks[event] ?? null) !== JSON.stringify(merged.length > 0 ? merged : null)) {
|
|
108
520
|
changed = true
|
|
@@ -111,5 +523,5 @@ export function mergeCursorHooksFile(
|
|
|
111
523
|
}
|
|
112
524
|
|
|
113
525
|
base.hooks = newHooks
|
|
114
|
-
return { fileContent: base, ownedCommands, changed, changedEvents }
|
|
526
|
+
return { fileContent: base, ownedCommands, ownedEntries, changed, changedEvents, duplicateCommands, restored, ambiguousConflicts }
|
|
115
527
|
}
|