@indigoai-us/hq-cli 5.345.82 → 5.345.83
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.83] — 2026-10-11
|
|
6
|
+
- HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
|
|
7
|
+
- Lanes hook installation now replaces stale hq-cli drain registrations across
|
|
8
|
+
Claude settings and Codex config, including entries whose old install path no
|
|
9
|
+
longer exists. Other hooks are preserved, duplicate drains are removed, and a
|
|
10
|
+
repeat install leaves the registration file byte-identical.
|
|
11
|
+
|
|
5
12
|
## [5.345.82] — 2026-10-11
|
|
6
13
|
- Session-start health no longer treats an ordinary HQ Core upgrade warning
|
|
7
14
|
as an unrepaired doctor finding. `hq doctor` still shows the upgrade;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared runtime for generated hq CLI forwarders.
|
|
3
|
+
|
|
4
|
+
forwarder_exec() {
|
|
5
|
+
local script_name="$1" min_cli="$2" root_mode="$3" command="$4" stdin_closed="$5"
|
|
6
|
+
shift 5
|
|
7
|
+
local forwarder_library_dir="${BASH_SOURCE[0]%/*}"
|
|
8
|
+
local path_rule index value path_option path_value
|
|
9
|
+
local -a forwarded_args path_rules path_options path_values converted_values command_parts
|
|
10
|
+
forwarded_args=("$@")
|
|
11
|
+
local forwarded_arg_count=$#
|
|
12
|
+
|
|
13
|
+
if [ "$root_mode" = live-project ]; then
|
|
14
|
+
HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
|
|
15
|
+
elif [ "$root_mode" = live ]; then
|
|
16
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if ! command -v hq >/dev/null 2>&1; then
|
|
20
|
+
printf "%s: requires the hq CLI — this script's implementation now ships with it.\n" "$script_name" >&2
|
|
21
|
+
printf 'Install it with: npm install -g @indigoai-us/hq-cli\n' >&2
|
|
22
|
+
exit 127
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
[ "$forwarder_library_dir" != "${BASH_SOURCE[0]}" ] || forwarder_library_dir=.
|
|
26
|
+
if [ -f "$forwarder_library_dir/hq-cli-floor.sh" ]; then
|
|
27
|
+
# shellcheck source=lib/hq-cli-floor.sh
|
|
28
|
+
. "$forwarder_library_dir/hq-cli-floor.sh"
|
|
29
|
+
hq_cli_floor_check "$script_name" "$min_cli"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
if [ "$stdin_closed" = true ]; then
|
|
33
|
+
if ! { true 3<&0; } 2>/dev/null; then
|
|
34
|
+
export HQ_CORE_STDIN_CLOSED=1
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
|
|
39
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
40
|
+
HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
45
|
+
IFS=';' read -r -a path_rules <<< "$PATH_OPERANDS"
|
|
46
|
+
for path_rule in "${path_rules[@]}"; do
|
|
47
|
+
case "$path_rule" in
|
|
48
|
+
none) ;;
|
|
49
|
+
position:*)
|
|
50
|
+
index="${path_rule#position:}"
|
|
51
|
+
if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
|
|
52
|
+
;;
|
|
53
|
+
first-nonoption)
|
|
54
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
55
|
+
case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index"; break ;; esac
|
|
56
|
+
done
|
|
57
|
+
;;
|
|
58
|
+
all-nonoptions)
|
|
59
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
60
|
+
case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index" ;; esac
|
|
61
|
+
done
|
|
62
|
+
;;
|
|
63
|
+
options:*)
|
|
64
|
+
IFS=, read -r -a path_options <<< "${path_rule#options:}"
|
|
65
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
66
|
+
for path_option in "${path_options[@]}"; do
|
|
67
|
+
case "${forwarded_args[index]}" in
|
|
68
|
+
"$path_option")
|
|
69
|
+
index=$((index + 1))
|
|
70
|
+
if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
|
|
71
|
+
break
|
|
72
|
+
;;
|
|
73
|
+
"$path_option"=*)
|
|
74
|
+
value="${forwarded_args[index]#*=}"
|
|
75
|
+
case "$value" in /*) forwarded_args[index]="${path_option}=$(cygpath -m "$value")" ;; esac
|
|
76
|
+
break
|
|
77
|
+
;;
|
|
78
|
+
esac
|
|
79
|
+
done
|
|
80
|
+
done
|
|
81
|
+
;;
|
|
82
|
+
comma:*)
|
|
83
|
+
path_option="${path_rule#comma:}"
|
|
84
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
85
|
+
case "${forwarded_args[index]}" in
|
|
86
|
+
"$path_option")
|
|
87
|
+
index=$((index + 1))
|
|
88
|
+
if (( index < forwarded_arg_count )); then
|
|
89
|
+
IFS=, read -r -a path_values <<< "${forwarded_args[index]}"
|
|
90
|
+
converted_values=()
|
|
91
|
+
for path_value in "${path_values[@]}"; do
|
|
92
|
+
case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
|
|
93
|
+
done
|
|
94
|
+
value="$(IFS=,; printf '%s' "${converted_values[*]}")"
|
|
95
|
+
forwarded_args[index]="$value"
|
|
96
|
+
fi
|
|
97
|
+
break
|
|
98
|
+
;;
|
|
99
|
+
"$path_option"=*)
|
|
100
|
+
value="${forwarded_args[index]#*=}"
|
|
101
|
+
case "$value" in
|
|
102
|
+
/*,*|/*)
|
|
103
|
+
IFS=, read -r -a path_values <<< "$value"
|
|
104
|
+
converted_values=()
|
|
105
|
+
for path_value in "${path_values[@]}"; do
|
|
106
|
+
case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
|
|
107
|
+
done
|
|
108
|
+
value="$(IFS=,; printf '%s' "${converted_values[*]}")"
|
|
109
|
+
forwarded_args[index]="${path_option}=$value"
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
112
|
+
break
|
|
113
|
+
;;
|
|
114
|
+
esac
|
|
115
|
+
done
|
|
116
|
+
;;
|
|
117
|
+
*) printf 'unknown path_operands rule: %s\n' "$path_rule" >&2; exit 70 ;;
|
|
118
|
+
esac
|
|
119
|
+
done
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
IFS=' ' read -r -a command_parts <<< "$command"
|
|
123
|
+
if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
|
|
124
|
+
if [ "$forwarded_arg_count" -gt 0 ]; then
|
|
125
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}" "${forwarded_args[@]}"
|
|
126
|
+
else
|
|
127
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}"
|
|
128
|
+
fi
|
|
129
|
+
elif [ "$forwarded_arg_count" -gt 0 ]; then
|
|
130
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}" "${forwarded_args[@]}"
|
|
131
|
+
else
|
|
132
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}"
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
convert_forwarded_path() {
|
|
137
|
+
local arg_index="$1"
|
|
138
|
+
local value="${forwarded_args[$arg_index]}"
|
|
139
|
+
case "$value" in
|
|
140
|
+
/*) forwarded_args[$arg_index]="$(cygpath -m "$value")" ;;
|
|
141
|
+
esac
|
|
142
|
+
}
|
package/dist/commands/core.js
CHANGED
|
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
|
|
|
268
268
|
{ asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
|
|
269
269
|
{ asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
|
|
270
270
|
{ asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
|
|
271
|
+
{ asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
|
|
271
272
|
{ asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
|
|
272
273
|
{ asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
|
|
273
274
|
{ asset: "core/scripts/lib/work-mesh-live-rebind.sh", root: "live", summary: "Work Mesh Live session rebind library sourced by hooks and the native rollback command" },
|
|
@@ -149,9 +149,18 @@ function isInstalledDrainEntry(entryPath) {
|
|
|
149
149
|
* Both paths target the same hq-cli drain entry, so recognize an installed
|
|
150
150
|
* package entry alongside the exact entry belonging to this CLI invocation.
|
|
151
151
|
*/
|
|
152
|
-
function drainCommandsMatch(candidate, expected) {
|
|
153
|
-
if (candidate === expected)
|
|
154
|
-
|
|
152
|
+
function drainCommandsMatch(candidate, expected, requireInstalledPaths = false) {
|
|
153
|
+
if (candidate === expected) {
|
|
154
|
+
if (!requireInstalledPaths)
|
|
155
|
+
return true;
|
|
156
|
+
const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
|
|
157
|
+
? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
|
|
158
|
+
: candidate;
|
|
159
|
+
const currentArgv = parseShellQuotedArguments(withoutProviderEnv);
|
|
160
|
+
return currentArgv !== null && currentArgv.length === 2 &&
|
|
161
|
+
path.parse(currentArgv[0]).name.toLowerCase() === "node" &&
|
|
162
|
+
fs.existsSync(currentArgv[0]) && fs.existsSync(currentArgv[1]);
|
|
163
|
+
}
|
|
155
164
|
const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
|
|
156
165
|
? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
|
|
157
166
|
: candidate;
|
|
@@ -162,9 +171,9 @@ function drainCommandsMatch(candidate, expected) {
|
|
|
162
171
|
return false;
|
|
163
172
|
if (!isInstalledDrainEntry(argv[1]))
|
|
164
173
|
return false;
|
|
165
|
-
return fs.existsSync(argv[0]) && fs.existsSync(argv[1]);
|
|
174
|
+
return !requireInstalledPaths || (fs.existsSync(argv[0]) && fs.existsSync(argv[1]));
|
|
166
175
|
}
|
|
167
|
-
function groupsContainDrainCommand(groups, expected) {
|
|
176
|
+
function groupsContainDrainCommand(groups, expected, requireInstalledPaths = false) {
|
|
168
177
|
if (!Array.isArray(groups))
|
|
169
178
|
return false;
|
|
170
179
|
return groups.some((group) => {
|
|
@@ -173,29 +182,51 @@ function groupsContainDrainCommand(groups, expected) {
|
|
|
173
182
|
return group.hooks.some((hook) => isPlainObject(hook) &&
|
|
174
183
|
hook.type === "command" &&
|
|
175
184
|
typeof hook.command === "string" &&
|
|
176
|
-
drainCommandsMatch(hook.command, expected));
|
|
185
|
+
drainCommandsMatch(hook.command, expected, requireInstalledPaths));
|
|
177
186
|
});
|
|
178
187
|
}
|
|
179
|
-
function tomlHasDrainCommand(parsed, expected) {
|
|
188
|
+
function tomlHasDrainCommand(parsed, expected, requireInstalledPaths = false) {
|
|
180
189
|
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : null;
|
|
181
190
|
return hooks !== null &&
|
|
182
|
-
["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected));
|
|
191
|
+
["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected, requireInstalledPaths));
|
|
183
192
|
}
|
|
184
|
-
function
|
|
193
|
+
function removeDrainCommandsFromGroups(groups, expected, legacyCommand) {
|
|
185
194
|
let changed = false;
|
|
195
|
+
let keptCurrent = false;
|
|
186
196
|
const next = groups.flatMap((group) => {
|
|
187
197
|
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
188
198
|
return [group];
|
|
189
|
-
const hooks = group.hooks.filter((hook) =>
|
|
190
|
-
hook.type
|
|
191
|
-
|
|
199
|
+
const hooks = group.hooks.filter((hook) => {
|
|
200
|
+
if (!isPlainObject(hook) || hook.type !== "command" || typeof hook.command !== "string")
|
|
201
|
+
return true;
|
|
202
|
+
const isCurrent = hook.command === expected;
|
|
203
|
+
const isDrain = isCurrent || drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand;
|
|
204
|
+
if (isCurrent && !keptCurrent) {
|
|
205
|
+
keptCurrent = true;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (isDrain)
|
|
209
|
+
changed = true;
|
|
210
|
+
return !isDrain;
|
|
211
|
+
});
|
|
192
212
|
if (hooks.length === group.hooks.length)
|
|
193
213
|
return [group];
|
|
194
|
-
changed = true;
|
|
195
214
|
return hooks.length === 0 ? [] : [{ ...group, hooks }];
|
|
196
215
|
});
|
|
197
216
|
return { groups: next, changed };
|
|
198
217
|
}
|
|
218
|
+
function groupHasExactlyOneCommand(groups, command) {
|
|
219
|
+
let count = 0;
|
|
220
|
+
for (const group of groups) {
|
|
221
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
222
|
+
continue;
|
|
223
|
+
for (const hook of group.hooks) {
|
|
224
|
+
if (isPlainObject(hook) && hook.type === "command" && hook.command === command)
|
|
225
|
+
count += 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return count === 1;
|
|
229
|
+
}
|
|
199
230
|
export function renderDrainHookJson(shPath, provider = "grok") {
|
|
200
231
|
const command = drainHookCommand(shPath, provider);
|
|
201
232
|
const registration = {
|
|
@@ -237,21 +268,18 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath, leg
|
|
|
237
268
|
}
|
|
238
269
|
eventGroups.set(event, groups);
|
|
239
270
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
: { groups: pre, changed: false };
|
|
247
|
-
const allRegistered = events.every((event) => groupsContainCommand(eventGroups.get(event), command));
|
|
248
|
-
if (allRegistered && !migration.changed) {
|
|
271
|
+
const cleanups = new Map(events.map((event) => [
|
|
272
|
+
event,
|
|
273
|
+
removeDrainCommandsFromGroups(eventGroups.get(event), command, legacyCommand),
|
|
274
|
+
]));
|
|
275
|
+
if (events.every((event) => groupHasExactlyOneCommand(cleanups.get(event).groups, command) &&
|
|
276
|
+
!cleanups.get(event).changed)) {
|
|
249
277
|
return { text: existingText ?? "", changed: false };
|
|
250
278
|
}
|
|
251
279
|
const nextHooks = { ...hooks };
|
|
252
280
|
for (const event of events) {
|
|
253
|
-
const groups =
|
|
254
|
-
nextHooks[event] =
|
|
281
|
+
const groups = cleanups.get(event).groups;
|
|
282
|
+
nextHooks[event] = groupHasExactlyOneCommand(groups, command) ? groups : [
|
|
255
283
|
...groups,
|
|
256
284
|
{ hooks: [{ type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS }] },
|
|
257
285
|
];
|
|
@@ -405,6 +433,145 @@ function parseTomlOrThrow(text, configPath, stage) {
|
|
|
405
433
|
}
|
|
406
434
|
return parsed;
|
|
407
435
|
}
|
|
436
|
+
function renderTomlInline(value) {
|
|
437
|
+
if (typeof value === "string")
|
|
438
|
+
return tomlBasicString(value);
|
|
439
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
440
|
+
return String(value);
|
|
441
|
+
if (Array.isArray(value))
|
|
442
|
+
return `[${value.map(renderTomlInline).join(", ")}]`;
|
|
443
|
+
if (isPlainObject(value)) {
|
|
444
|
+
const fields = Object.entries(value).map(([key, entry]) => `${JSON.stringify(key)} = ${renderTomlInline(entry)}`);
|
|
445
|
+
return `{ ${fields.join(", ")} }`;
|
|
446
|
+
}
|
|
447
|
+
throw new LanesError("config_toml_migration_failed", "Could not safely render an inline Codex hook while removing stale lane drains");
|
|
448
|
+
}
|
|
449
|
+
function isDrainHookValue(hook, expected, legacyCommand) {
|
|
450
|
+
return isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string" &&
|
|
451
|
+
(drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand);
|
|
452
|
+
}
|
|
453
|
+
function removeStaleCodexDrainHooks(text, parsed, expected, legacyCommand, configPath) {
|
|
454
|
+
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {};
|
|
455
|
+
const lines = text.split("\n");
|
|
456
|
+
const spans = [];
|
|
457
|
+
let changed = false;
|
|
458
|
+
for (const event of ["PreToolUse", "PostToolUse", "Stop"]) {
|
|
459
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
460
|
+
const parentIndexes = lines.flatMap((line, index) => {
|
|
461
|
+
const header = tomlHeader(line);
|
|
462
|
+
return header?.array && header.path === `hooks.${event}` ? [index] : [];
|
|
463
|
+
});
|
|
464
|
+
if (parentIndexes.length !== groups.length) {
|
|
465
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} drain hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groups: groups.length, headers: parentIndexes.length });
|
|
466
|
+
}
|
|
467
|
+
let keptCurrent = false;
|
|
468
|
+
for (let groupIndex = 0; groupIndex < parentIndexes.length; groupIndex += 1) {
|
|
469
|
+
const start = parentIndexes[groupIndex];
|
|
470
|
+
let end = start + 1;
|
|
471
|
+
for (; end < lines.length; end += 1) {
|
|
472
|
+
const header = tomlHeader(lines[end]);
|
|
473
|
+
if (!header)
|
|
474
|
+
continue;
|
|
475
|
+
if (header.array && header.path === `hooks.${event}`)
|
|
476
|
+
break;
|
|
477
|
+
if (header.path.startsWith(`hooks.${event}.`))
|
|
478
|
+
continue;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
const hookHeaders = lines.flatMap((line, index) => {
|
|
482
|
+
if (index <= start || index >= end)
|
|
483
|
+
return [];
|
|
484
|
+
const header = tomlHeader(line);
|
|
485
|
+
return header?.array && header.path === `hooks.${event}.hooks` ? [index] : [];
|
|
486
|
+
});
|
|
487
|
+
const group = groups[groupIndex];
|
|
488
|
+
const groupHooks = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
489
|
+
if (hookHeaders.length !== groupHooks.length) {
|
|
490
|
+
const keptInlineHooks = [];
|
|
491
|
+
let inlineNeedsEdit = false;
|
|
492
|
+
for (const hook of groupHooks) {
|
|
493
|
+
const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
|
|
494
|
+
? hook.command
|
|
495
|
+
: undefined;
|
|
496
|
+
if (!command) {
|
|
497
|
+
keptInlineHooks.push(hook);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const current = command === expected;
|
|
501
|
+
const drain = isDrainHookValue(hook, expected, legacyCommand);
|
|
502
|
+
if (!drain || (current && !keptCurrent)) {
|
|
503
|
+
if (current)
|
|
504
|
+
keptCurrent = true;
|
|
505
|
+
keptInlineHooks.push(hook);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
inlineNeedsEdit = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!inlineNeedsEdit)
|
|
512
|
+
continue;
|
|
513
|
+
if (hookHeaders.length === 0) {
|
|
514
|
+
const hooksLine = lines.findIndex((line, index) => index > start && index < end && /^\s*hooks\s*=/.test(line));
|
|
515
|
+
if (hooksLine === -1) {
|
|
516
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely find inline ${event} hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex });
|
|
517
|
+
}
|
|
518
|
+
changed = true;
|
|
519
|
+
if (keptInlineHooks.length === 0) {
|
|
520
|
+
spans.push({ start, end });
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
const indent = /^\s*/.exec(lines[hooksLine])?.[0] ?? "";
|
|
524
|
+
lines[hooksLine] = `${indent}hooks = ${renderTomlInline(keptInlineHooks)}`;
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} hook entries in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex, hooks: groupHooks.length, headers: hookHeaders.length });
|
|
529
|
+
}
|
|
530
|
+
const removeHooks = [];
|
|
531
|
+
for (let hookIndex = 0; hookIndex < hookHeaders.length; hookIndex += 1) {
|
|
532
|
+
const hookStart = hookHeaders[hookIndex];
|
|
533
|
+
let hookEnd = hookStart + 1;
|
|
534
|
+
for (; hookEnd < end; hookEnd += 1) {
|
|
535
|
+
const header = tomlHeader(lines[hookEnd]);
|
|
536
|
+
if (header?.array && header.path === `hooks.${event}.hooks`)
|
|
537
|
+
break;
|
|
538
|
+
if (header && !header.path.startsWith(`hooks.${event}.hooks.`))
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
const hook = groupHooks[hookIndex];
|
|
542
|
+
const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
|
|
543
|
+
? hook.command
|
|
544
|
+
: undefined;
|
|
545
|
+
if (!command)
|
|
546
|
+
continue;
|
|
547
|
+
const current = command === expected;
|
|
548
|
+
const drain = drainCommandsMatch(command, expected) || command === legacyCommand;
|
|
549
|
+
if (!drain || (current && !keptCurrent)) {
|
|
550
|
+
if (current)
|
|
551
|
+
keptCurrent = true;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
removeHooks.push({ start: hookStart, end: hookEnd });
|
|
555
|
+
}
|
|
556
|
+
if (removeHooks.length === 0)
|
|
557
|
+
continue;
|
|
558
|
+
changed = true;
|
|
559
|
+
if (removeHooks.length === hookHeaders.length) {
|
|
560
|
+
spans.push({ start, end });
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
spans.push(...removeHooks);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!changed)
|
|
568
|
+
return { text, changed: false };
|
|
569
|
+
for (const span of spans.sort((a, b) => b.start - a.start)) {
|
|
570
|
+
lines.splice(span.start, span.end - span.start);
|
|
571
|
+
}
|
|
572
|
+
const cleaned = lines.join("\n");
|
|
573
|
+
return { text: cleaned, changed: true };
|
|
574
|
+
}
|
|
408
575
|
/**
|
|
409
576
|
* Set `hooks = true` inside `[features]` by editing lines, so the operator's
|
|
410
577
|
* comments and layout survive. A `[features]` header that exists gets the
|
|
@@ -442,12 +609,21 @@ export function mergeCodexConfigHook(existingText, command, configPath, legacyCo
|
|
|
442
609
|
const original = existingText ?? "";
|
|
443
610
|
const parsed = parseTomlOrThrow(original, configPath, "before merge");
|
|
444
611
|
const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
|
|
445
|
-
const
|
|
612
|
+
const parsedAfterMigration = parseTomlOrThrow(migration.text, configPath, "after legacy hook removal");
|
|
613
|
+
const drainCleanup = removeStaleCodexDrainHooks(migration.text, parsedAfterMigration, command, legacyCommand, configPath);
|
|
614
|
+
const changedByCleanup = migration.changed || drainCleanup.changed;
|
|
615
|
+
const textAfterMigration = drainCleanup.text;
|
|
446
616
|
const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
|
|
447
617
|
const hasPreToolCommand = tomlHasCommand(current, command);
|
|
448
618
|
const hasCommand = tomlHasDrainCommand(current, command);
|
|
449
619
|
const featureOn = tomlHooksFeatureEnabled(current);
|
|
450
|
-
if (hasCommand && featureOn && !
|
|
620
|
+
if (hasCommand && featureOn && !changedByCleanup &&
|
|
621
|
+
["PreToolUse", "PostToolUse", "Stop"].every((event) => {
|
|
622
|
+
const groups = isPlainObject(current.hooks) && Array.isArray(current.hooks[event])
|
|
623
|
+
? current.hooks[event]
|
|
624
|
+
: [];
|
|
625
|
+
return groupHasExactlyOneCommand(groups, command);
|
|
626
|
+
})) {
|
|
451
627
|
return { text: original, changed: false };
|
|
452
628
|
}
|
|
453
629
|
let text = textAfterMigration;
|
|
@@ -509,7 +685,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
|
|
|
509
685
|
const toml = parseToml(text);
|
|
510
686
|
return {
|
|
511
687
|
...base,
|
|
512
|
-
registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command) && tomlHooksFeatureEnabled(toml),
|
|
688
|
+
registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command, true) && tomlHooksFeatureEnabled(toml),
|
|
513
689
|
};
|
|
514
690
|
}
|
|
515
691
|
const json = JSON.parse(text);
|
|
@@ -517,7 +693,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
|
|
|
517
693
|
return {
|
|
518
694
|
...base,
|
|
519
695
|
registered: hooks !== null &&
|
|
520
|
-
(parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command)),
|
|
696
|
+
(parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command, true)),
|
|
521
697
|
};
|
|
522
698
|
}
|
|
523
699
|
catch (err) {
|