javi-forge 1.29.0 → 1.30.1

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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Synchronous in-memory `PlatformSecureFs` fake for the transaction engine's
3
+ * fault matrix. Host-independent, no root, no real filesystem: directories,
4
+ * files, modes, and dev+ino identities live in Maps, and every failure branch
5
+ * (identity drift, ACL refusal, exclusive-create EEXIST, write/fsync fault,
6
+ * rename fault, ownership loss, capture refusal, post-commit hash drift) is a
7
+ * declarative toggle. Used only by tests.
8
+ */
9
+ import type { PlatformSecureFs } from "../secure-fs-transaction.js";
10
+ interface FakeFile {
11
+ bytes: Buffer;
12
+ mode: number;
13
+ }
14
+ /** Per-call fault predicates; every one defaults to "no fault". */
15
+ export interface FakeFaults {
16
+ /** Refuse revalidateIdentity for a path on its Nth (1-based) call. */
17
+ revalidateRefuse?: (target: string, callIndex: number) => boolean;
18
+ /** Refuse proveOwnershipAndMode for a path on its Nth call. */
19
+ ownershipRefuse?: (dirPath: string, callIndex: number) => boolean;
20
+ /** Refuse proveNoExtendedAcl for a path on its Nth call. */
21
+ aclRefuse?: (target: string, callIndex: number) => boolean;
22
+ /** Refuse captureFile for a path (simulate open/read failure). */
23
+ captureRefuse?: (target: string) => boolean;
24
+ /** Override the sha of a captured file (simulate post-commit drift). */
25
+ captureShaOverride?: (target: string) => string | undefined;
26
+ /** Refuse writeExclusive for a base name on its Nth call (EEXIST / fsync fault). */
27
+ writeRefuse?: (name: string, callIndex: number) => boolean;
28
+ /** Refuse renameInDir when the destination base name matches. */
29
+ renameRefuse?: (to: string) => boolean;
30
+ }
31
+ export interface FakeSecureFs extends PlatformSecureFs {
32
+ readonly dirs: Set<string>;
33
+ readonly files: Map<string, FakeFile>;
34
+ readonly dirModes: Map<string, number>;
35
+ faults: FakeFaults;
36
+ seedDir(dirPath: string): void;
37
+ seedFile(filePath: string, bytes: Buffer, mode?: number): void;
38
+ fileText(filePath: string): string | undefined;
39
+ hasBackup(dirPath: string): boolean;
40
+ }
41
+ export declare function makeFakeSecureFs(): FakeSecureFs;
42
+ export {};
43
+ //# sourceMappingURL=fake-secure-fs.d.ts.map
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Synchronous in-memory `PlatformSecureFs` fake for the transaction engine's
3
+ * fault matrix. Host-independent, no root, no real filesystem: directories,
4
+ * files, modes, and dev+ino identities live in Maps, and every failure branch
5
+ * (identity drift, ACL refusal, exclusive-create EEXIST, write/fsync fault,
6
+ * rename fault, ownership loss, capture refusal, post-commit hash drift) is a
7
+ * declarative toggle. Used only by tests.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import path from "node:path";
11
+ const ok = () => ({ ok: true });
12
+ const okValue = (value) => ({ ok: true, value });
13
+ const unsafe = (detail) => ({
14
+ ok: false,
15
+ refusal: "unsafe-parent-chain",
16
+ detail,
17
+ });
18
+ export function makeFakeSecureFs() {
19
+ const dirs = new Set();
20
+ const files = new Map();
21
+ const dirModes = new Map();
22
+ const inos = new Map();
23
+ let inoSeq = 100;
24
+ const revalidateCounts = new Map();
25
+ const ownershipCounts = new Map();
26
+ const aclCounts = new Map();
27
+ const writeCounts = new Map();
28
+ const inoFor = (p) => {
29
+ let ino = inos.get(p);
30
+ if (ino === undefined) {
31
+ ino = inoSeq++;
32
+ inos.set(p, ino);
33
+ }
34
+ return ino;
35
+ };
36
+ const bump = (m, key) => {
37
+ const next = (m.get(key) ?? 0) + 1;
38
+ m.set(key, next);
39
+ return next;
40
+ };
41
+ const handleFor = (p) => ({
42
+ path: p,
43
+ identity: { dev: 1, ino: inoFor(p) },
44
+ close: async () => { },
45
+ });
46
+ const isEmptyDir = (p) => {
47
+ const prefix = `${p}/`;
48
+ for (const f of files.keys())
49
+ if (f.startsWith(prefix))
50
+ return false;
51
+ for (const d of dirs)
52
+ if (d !== p && d.startsWith(prefix))
53
+ return false;
54
+ return true;
55
+ };
56
+ const fake = {
57
+ dirs,
58
+ files,
59
+ dirModes,
60
+ faults: {},
61
+ seedDir(dirPath) {
62
+ dirs.add(dirPath);
63
+ },
64
+ seedFile(filePath, bytes, mode = 0o644) {
65
+ files.set(filePath, { bytes, mode });
66
+ },
67
+ fileText(filePath) {
68
+ return files.get(filePath)?.bytes.toString("utf8");
69
+ },
70
+ hasBackup(dirPath) {
71
+ const prefix = `${dirPath}/`;
72
+ for (const f of files.keys()) {
73
+ if (f.startsWith(prefix) && f.includes(".javi-forge.bak."))
74
+ return true;
75
+ }
76
+ return false;
77
+ },
78
+ async openDirNoFollow(dirPath) {
79
+ if (!dirs.has(dirPath))
80
+ return unsafe(`openDir enoent ${dirPath}`);
81
+ return okValue(handleFor(dirPath));
82
+ },
83
+ async revalidateIdentity(target, held) {
84
+ const idx = bump(revalidateCounts, target);
85
+ if (fake.faults.revalidateRefuse?.(target, idx)) {
86
+ return unsafe(`identity drift ${target}`);
87
+ }
88
+ if (!dirs.has(target) && !files.has(target)) {
89
+ return unsafe(`identity missing ${target}`);
90
+ }
91
+ return inoFor(target) === held.ino
92
+ ? ok()
93
+ : unsafe(`identity drift ${target}`);
94
+ },
95
+ async proveOwnershipAndMode(dirPath) {
96
+ const idx = bump(ownershipCounts, dirPath);
97
+ if (fake.faults.ownershipRefuse?.(dirPath, idx)) {
98
+ return unsafe(`ownership ${dirPath}`);
99
+ }
100
+ return ok();
101
+ },
102
+ async proveNoExtendedAcl(target) {
103
+ const idx = bump(aclCounts, target);
104
+ if (fake.faults.aclRefuse?.(target, idx)) {
105
+ return { ok: false, refusal: "unsupported-posix-acl", detail: target };
106
+ }
107
+ return ok();
108
+ },
109
+ async createDirExclusive(parent, name, mode) {
110
+ const full = path.join(parent.path, name);
111
+ if (dirs.has(full) || files.has(full))
112
+ return unsafe(`EEXIST ${full}`);
113
+ dirs.add(full);
114
+ dirModes.set(full, mode);
115
+ return okValue(handleFor(full));
116
+ },
117
+ async captureFile(target) {
118
+ if (fake.faults.captureRefuse?.(target))
119
+ return unsafe(`capture ${target}`);
120
+ const file = files.get(target);
121
+ if (!file)
122
+ return unsafe(`capture enoent ${target}`);
123
+ const realSha = createHash("sha256").update(file.bytes).digest("hex");
124
+ const sha256 = fake.faults.captureShaOverride?.(target) ?? realSha;
125
+ return okValue({
126
+ bytes: file.bytes,
127
+ mode: file.mode,
128
+ identity: { dev: 1, ino: inoFor(target) },
129
+ sha256,
130
+ });
131
+ },
132
+ async writeExclusive(dir, name, bytes, mode) {
133
+ const idx = bump(writeCounts, name);
134
+ if (fake.faults.writeRefuse?.(name, idx))
135
+ return unsafe(`write ${name}`);
136
+ const full = path.join(dir.path, name);
137
+ if (files.has(full))
138
+ return unsafe(`EEXIST ${full}`);
139
+ files.set(full, { bytes, mode });
140
+ return ok();
141
+ },
142
+ async applyExactMode(target, mode) {
143
+ const file = files.get(target);
144
+ if (!file)
145
+ return unsafe(`applyMode enoent ${target}`);
146
+ file.mode = mode;
147
+ return ok();
148
+ },
149
+ async renameInDir(dir, from, to) {
150
+ if (fake.faults.renameRefuse?.(to))
151
+ return unsafe(`rename ${to}`);
152
+ const fromP = path.join(dir.path, from);
153
+ const toP = path.join(dir.path, to);
154
+ const file = files.get(fromP);
155
+ if (!file)
156
+ return unsafe(`rename enoent ${fromP}`);
157
+ files.set(toP, file);
158
+ files.delete(fromP);
159
+ inos.delete(toP); // fresh identity for the renamed-in target
160
+ return ok();
161
+ },
162
+ async unlinkIfIdentity(dir, name, _held) {
163
+ const full = path.join(dir.path, name);
164
+ if (!files.has(full))
165
+ return unsafe(`unlink enoent ${full}`);
166
+ files.delete(full);
167
+ return ok();
168
+ },
169
+ async rmdirIfIdentityEmpty(handle) {
170
+ if (!dirs.has(handle.path))
171
+ return unsafe(`rmdir enoent ${handle.path}`);
172
+ if (inoFor(handle.path) !== handle.identity.ino) {
173
+ return unsafe(`rmdir identity ${handle.path}`);
174
+ }
175
+ if (!isEmptyDir(handle.path))
176
+ return unsafe(`rmdir not-empty ${handle.path}`);
177
+ dirs.delete(handle.path);
178
+ return ok();
179
+ },
180
+ };
181
+ return fake;
182
+ }
183
+ //# sourceMappingURL=fake-secure-fs.js.map
@@ -8,6 +8,7 @@
8
8
  * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
9
  */
10
10
  import { type ClaudeHookComponentState, type SettingsClassification, type SettingsIdentityManifest } from "./claude-hook-settings.js";
11
+ import { type PlatformSecureFs } from "./secure-fs-transaction.js";
11
12
  declare const COVERAGE: readonly ["Bash", "PowerShell", "Read", "Write", "Edit"];
12
13
  export interface AssetManifestEntry {
13
14
  name: string;
@@ -78,10 +79,23 @@ export interface ClaudeHookMutationResult {
78
79
  ok: boolean;
79
80
  changed: string[];
80
81
  backups: string[];
82
+ report: ClaudeHookDoctorReport;
81
83
  errors: string[];
82
84
  }
83
- export declare function installClaudePreToolUse(_projectDir: string): Promise<ClaudeHookMutationResult>;
84
- export declare function repairClaudePreToolUse(_projectDir: string, _options?: {
85
+ /** Injectable deps so tests drive `_run` with a fake `PlatformSecureFs`. */
86
+ export interface ClaudeHookRunDeps {
87
+ secureFs?: PlatformSecureFs | null;
88
+ clock?: () => Date;
89
+ nonce?: () => string;
90
+ manifest?: Manifest;
91
+ platform?: NodeJS.Platform;
92
+ }
93
+ /** Internal deps-taking entry; tests drive it with a fake `PlatformSecureFs`. */
94
+ export declare function _run(projectDir: string, mode: "install" | "repair", options: {
95
+ force?: boolean;
96
+ }, deps: ClaudeHookRunDeps): Promise<ClaudeHookMutationResult>;
97
+ export declare function installClaudePreToolUse(projectDir: string): Promise<ClaudeHookMutationResult>;
98
+ export declare function repairClaudePreToolUse(projectDir: string, options?: {
85
99
  force?: boolean;
86
100
  }): Promise<ClaudeHookMutationResult>;
87
101
  export {};
@@ -7,13 +7,15 @@
7
7
  * and the component-level doctor. Install/repair are declared but unimplemented
8
8
  * Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
9
9
  */
10
- import { createHash } from "node:crypto";
11
- import { lstat } from "node:fs/promises";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { lstat, readFile } from "node:fs/promises";
12
12
  import path from "node:path";
13
13
  import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
14
14
  import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
15
- import { classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, } from "./claude-hook-settings.js";
15
+ import { buildManagedContainer, classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, planForceReplace, planLegacyCohortExcision, planManagedClaudeHookMerge, } from "./claude-hook-settings.js";
16
16
  import { safeReadFile } from "./safe-read.js";
17
+ import { selectSecureFs } from "./secure-fs-posix.js";
18
+ import { runTransaction, } from "./secure-fs-transaction.js";
17
19
  /** 1 MiB read budget, shared with the runtime's stdin envelope. */
18
20
  const ASSET_MAX_BYTES = 1024 * 1024;
19
21
  const NODE_MINIMUM_MAJOR = 22;
@@ -267,10 +269,222 @@ async function readManifest() {
267
269
  throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
268
270
  return JSON.parse(read.content);
269
271
  }
270
- export function installClaudePreToolUse(_projectDir) {
271
- throw new Error("unimplemented: Slice 3 transaction");
272
+ function refuseMessage(component, state) {
273
+ const remedy = remediationFor(state, component);
274
+ return `refuse ${component} in state ${state}${remedy ? ` — ${remedy}` : ""}`;
272
275
  }
273
- export function repairClaudePreToolUse(_projectDir, _options) {
274
- throw new Error("unimplemented: Slice 3 transaction");
276
+ function resolveAssetPlan(state, forced) {
277
+ switch (state) {
278
+ case "absent":
279
+ case "released-outdated":
280
+ return { kind: "write" };
281
+ case "managed-current":
282
+ return { kind: "noop" };
283
+ case "edited-managed":
284
+ return forced
285
+ ? { kind: "write" }
286
+ : { kind: "refuse", reason: refuseMessage("asset", state) };
287
+ default:
288
+ return { kind: "refuse", reason: refuseMessage("asset", state) };
289
+ }
290
+ }
291
+ function resolveSettingsPlan(settingsRead, currentAssetSha, forced, identities) {
292
+ if ("state" in settingsRead) {
293
+ const state = settingsRead.classification.state;
294
+ if (state === "absent" || state === "exact-legacy") {
295
+ return {
296
+ kind: "container",
297
+ container: buildManagedContainer(currentAssetSha),
298
+ };
299
+ }
300
+ return { kind: "refuse", reason: refuseMessage("settings", state) };
301
+ }
302
+ const value = settingsRead.value;
303
+ const merge = planManagedClaudeHookMerge(value, currentAssetSha, identities);
304
+ if (!merge.refused) {
305
+ if (merge.action === "install")
306
+ return { kind: "install" };
307
+ if (merge.action === "noop")
308
+ return { kind: "noop" };
309
+ if (merge.action === "replace") {
310
+ return {
311
+ kind: "replace",
312
+ groupIndex: merge.groupIndex,
313
+ handlerIndex: merge.handlerIndex,
314
+ };
315
+ }
316
+ }
317
+ const cls = classifySettingsEntry(value, currentAssetSha, identities);
318
+ if (cls.state === "exact-legacy") {
319
+ const plan = planLegacyCohortExcision(value);
320
+ return plan.refused
321
+ ? {
322
+ kind: "refuse",
323
+ reason: plan.reason ?? refuseMessage("settings", cls.state),
324
+ }
325
+ : { kind: "excise", plan };
326
+ }
327
+ if (cls.state === "edited-managed") {
328
+ if (!forced)
329
+ return { kind: "refuse", reason: refuseMessage("settings", cls.state) };
330
+ const force = planForceReplace(value, currentAssetSha);
331
+ if (force.refused) {
332
+ return {
333
+ kind: "refuse",
334
+ reason: force.reason ?? refuseMessage("settings", cls.state),
335
+ };
336
+ }
337
+ return {
338
+ kind: "replace",
339
+ groupIndex: force.groupIndex,
340
+ handlerIndex: force.handlerIndex,
341
+ };
342
+ }
343
+ return { kind: "refuse", reason: refuseMessage("settings", cls.state) };
344
+ }
345
+ function freshManagedGroup(currentAssetSha) {
346
+ return buildManagedContainer(currentAssetSha).hooks
347
+ .PreToolUse[0];
348
+ }
349
+ function ensureHooks(container) {
350
+ if (!isPlainObject(container.hooks))
351
+ container.hooks = {};
352
+ const hooks = container.hooks;
353
+ if (!Array.isArray(hooks.PreToolUse))
354
+ hooks.PreToolUse = [];
355
+ return hooks;
356
+ }
357
+ /** Build the desired settings container, preserving unrelated content. */
358
+ function applySettingsPlan(value, plan, currentAssetSha) {
359
+ if (plan.kind === "container")
360
+ return plan.container;
361
+ const container = structuredClone(value);
362
+ const group = freshManagedGroup(currentAssetSha);
363
+ const hooks = ensureHooks(container);
364
+ const pre = hooks.PreToolUse;
365
+ if (plan.kind === "install") {
366
+ pre.push(group);
367
+ return container;
368
+ }
369
+ if (plan.kind === "replace") {
370
+ const g = pre[plan.groupIndex];
371
+ g.matcher = MANAGED_MATCHER;
372
+ g.hooks[plan.handlerIndex] = group.hooks[0];
373
+ return container;
374
+ }
375
+ if (plan.kind !== "excise")
376
+ return container;
377
+ // excise: remove the proven cohort by descending index, then insert the group.
378
+ const { removePreIndices, removePostIndices, insertPreAt } = plan.plan;
379
+ for (const index of [...removePreIndices].sort((a, b) => b - a)) {
380
+ pre.splice(index, 1);
381
+ }
382
+ const post = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
383
+ for (const index of [...removePostIndices].sort((a, b) => b - a)) {
384
+ post.splice(index, 1);
385
+ }
386
+ pre.splice(insertPreAt, 0, group);
387
+ return container;
388
+ }
389
+ function serializeSettings(container) {
390
+ return Buffer.from(`${JSON.stringify(container, null, 2)}\n`, "utf8");
391
+ }
392
+ /** Internal deps-taking entry; tests drive it with a fake `PlatformSecureFs`. */
393
+ export async function _run(projectDir, mode, options, deps) {
394
+ const manifest = deps.manifest ?? (await readManifest());
395
+ const platform = deps.platform ?? process.platform;
396
+ const secureFs = deps.secureFs !== undefined ? deps.secureFs : selectSecureFs(platform);
397
+ const clock = deps.clock ?? (() => new Date());
398
+ const nonce = deps.nonce ?? (() => randomBytes(4).toString("hex"));
399
+ const currentAssetSha = manifest.asset.sha256;
400
+ const assetDestPath = path.join(projectDir, ".claude", "hooks", ASSET_NAME);
401
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
402
+ const assetSrcPath = path.join(CLAUDE_HOOK_ASSETS_DIR, ASSET_NAME);
403
+ const doctor = () => doctorClaudePreToolUse(projectDir, { manifest });
404
+ // Windows (or any platform without an adapter) refuses with zero mutation.
405
+ if (!secureFs) {
406
+ return {
407
+ ok: false,
408
+ changed: [],
409
+ backups: [],
410
+ errors: ["windows-secure-object-unavailable"],
411
+ report: await doctor(),
412
+ };
413
+ }
414
+ // Classify both components with the Slice-2 read layer.
415
+ const assetCls = await classifyAssetState(assetDestPath, manifest);
416
+ const settingsRead = await readSettings(settingsPath);
417
+ const settingsState = ("state" in settingsRead
418
+ ? settingsRead.classification
419
+ : classifySettingsEntry(settingsRead.value, currentAssetSha, manifest.settingsEntries)).state;
420
+ const forced = mode === "repair" && options.force === true;
421
+ const assetPlan = resolveAssetPlan(assetCls.state, forced);
422
+ const settingsPlan = resolveSettingsPlan(settingsRead, currentAssetSha, forced, manifest.settingsEntries);
423
+ // A refusal on either component refuses the whole operation, zero mutation.
424
+ if (assetPlan.kind === "refuse" || settingsPlan.kind === "refuse") {
425
+ const reason = assetPlan.kind === "refuse"
426
+ ? assetPlan.reason
427
+ : settingsPlan.reason;
428
+ return {
429
+ ok: false,
430
+ changed: [],
431
+ backups: [],
432
+ errors: [reason],
433
+ report: await doctor(),
434
+ };
435
+ }
436
+ // Zero-write idempotent no-op: both components already current.
437
+ if (assetPlan.kind === "noop" && settingsPlan.kind === "noop") {
438
+ return {
439
+ ok: true,
440
+ changed: [],
441
+ backups: [],
442
+ errors: [],
443
+ report: await doctor(),
444
+ };
445
+ }
446
+ const assetWasAbsent = assetCls.state === "absent";
447
+ const settingsWasAbsent = "state" in settingsRead && settingsRead.classification.state === "absent";
448
+ const assetForced = forced && assetCls.state === "edited-managed";
449
+ const settingsForced = forced && settingsState === "edited-managed";
450
+ const desiredAsset = assetPlan.kind === "write" ? await readFile(assetSrcPath) : null;
451
+ const desiredSettings = settingsPlan.kind === "noop"
452
+ ? null
453
+ : serializeSettings(applySettingsPlan("value" in settingsRead ? settingsRead.value : undefined, settingsPlan, currentAssetSha));
454
+ const asset = {
455
+ path: assetDestPath,
456
+ desired: desiredAsset,
457
+ capturePrior: assetPlan.kind === "write" && !assetWasAbsent,
458
+ forceBackup: assetForced,
459
+ wasAbsent: assetWasAbsent,
460
+ };
461
+ const settings = {
462
+ path: settingsPath,
463
+ desired: desiredSettings,
464
+ capturePrior: settingsPlan.kind !== "noop" && !settingsWasAbsent,
465
+ forceBackup: settingsForced,
466
+ wasAbsent: settingsWasAbsent,
467
+ };
468
+ const tx = await runTransaction({
469
+ secureFs,
470
+ clock,
471
+ nonce,
472
+ projectDir,
473
+ asset,
474
+ settings,
475
+ });
476
+ return {
477
+ ok: tx.ok,
478
+ changed: tx.committed,
479
+ backups: tx.backups,
480
+ errors: tx.errors,
481
+ report: await doctor(),
482
+ };
483
+ }
484
+ export function installClaudePreToolUse(projectDir) {
485
+ return _run(projectDir, "install", {}, {});
486
+ }
487
+ export function repairClaudePreToolUse(projectDir, options) {
488
+ return _run(projectDir, "repair", options ?? {}, {});
275
489
  }
276
490
  //# sourceMappingURL=claude-hook-manager.js.map
@@ -113,4 +113,76 @@ export interface ManagedMergePlan {
113
113
  * preserved), and every other state refuses (edited/force is a Slice-3 concern).
114
114
  */
115
115
  export declare function planManagedClaudeHookMerge(parsed: unknown, currentAssetSha: string, identities: SettingsIdentityManifest): ManagedMergePlan;
116
+ /** The exact managed handler shape the writer installs. */
117
+ export interface ManagedHandler {
118
+ type: "command";
119
+ command: "node";
120
+ args: [string];
121
+ timeout: number;
122
+ statusMessage: string;
123
+ }
124
+ /** The exact managed matcher group the writer installs. */
125
+ export interface ManagedMatcherGroup {
126
+ matcher: string;
127
+ hooks: [ManagedHandler];
128
+ }
129
+ /** Cohort-excision plan for embedded exact-legacy. */
130
+ export interface LegacyCohortExcisionPlan {
131
+ refused: boolean;
132
+ reason?: string;
133
+ /** Indices to remove from hooks.PreToolUse (L1..L3 matches), ascending. */
134
+ removePreIndices: number[];
135
+ /** Indices to remove from hooks.PostToolUse (L4 match), ascending. */
136
+ removePostIndices: number[];
137
+ /** Append position for the freshly built managed PreToolUse group. */
138
+ insertPreAt: number;
139
+ }
140
+ /**
141
+ * Plan excision of the proven four-object legacy cohort from an embedded
142
+ * container. Reuses `LEGACY_COHORT` + `deepStructuralEqual` (the same primitives
143
+ * `classifyLegacy` uses) and never re-derives the classifier. Returns the exact
144
+ * ascending object indices to remove per event plus the append position for the
145
+ * freshly built managed group; every non-cohort sibling is preserved by index.
146
+ * Only an exact-legacy cohort is eligible — a partial/edited cohort refuses
147
+ * (the classifier already routes those to `foreign`).
148
+ */
149
+ export declare function planLegacyCohortExcision(parsed: unknown): LegacyCohortExcisionPlan;
150
+ /**
151
+ * Force-replace plan for edited-managed. Eligibility keys on MATCHER EXACTNESS
152
+ * (§324), not unconditionally on sibling count. Exposes matcherExact +
153
+ * siblingHandlers so the manager can enforce the exact §324 rule.
154
+ */
155
+ export interface ForceReplacePlan {
156
+ refused: boolean;
157
+ /** Set only when matcher edited AND siblingHandlers > 0. */
158
+ reason?: string;
159
+ state: ClaudeHookComponentState;
160
+ groupIndex?: number;
161
+ handlerIndex?: number;
162
+ /** True => group matcher === MANAGED_MATCHER. */
163
+ matcherExact?: boolean;
164
+ /** Count of unrelated handlers in the group. */
165
+ siblingHandlers?: number;
166
+ }
167
+ /**
168
+ * Plan an in-place force replacement of the single marker-proven managed
169
+ * handler for an `edited-managed` component. Eligibility (§324 / JD-A-001):
170
+ * - matcherExact === true → eligible regardless of siblingHandlers.
171
+ * - matcherExact === false && siblingHandlers === 0 → eligible.
172
+ * - matcherExact === false && siblingHandlers > 0 → refused even under force.
173
+ * A container without exactly one marker-proven handler in a valid matcher
174
+ * group also refuses.
175
+ */
176
+ export declare function planForceReplace(parsed: unknown, currentAssetSha: string): ForceReplacePlan;
177
+ /**
178
+ * Synthesize a fresh managed-only container for the two terminal states with no
179
+ * parsed value: fresh install (`absent`) and whole-file `exact-legacy`. Takes
180
+ * only the current asset SHA — not the whole Manifest — so this module never
181
+ * imports the manager's manifest reader at runtime (JD-A-002).
182
+ */
183
+ export declare function buildManagedContainer(currentAssetSha: string): {
184
+ hooks: {
185
+ PreToolUse: [ManagedMatcherGroup];
186
+ };
187
+ };
116
188
  //# sourceMappingURL=claude-hook-settings.d.ts.map