@lannguyensi/harness 0.59.0 → 0.59.2

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.
@@ -25,6 +25,9 @@ export class CodexInstallRefusalError extends HarnessExitError {
25
25
  }
26
26
  }
27
27
  const GENERATED_HEADER = "# Generated by harness apply --runtime codex.";
28
+ /** How the split-block refusal names a legacy generated header as the line
29
+ * its block starts at (`assertNoSplitBlock`, `findManagedRange`). */
30
+ const GENERATED_HEADER_OPENING = `the '${GENERATED_HEADER}' line`;
28
31
  const LEGACY_HOOK_TABLE_RE = /^\[\[hooks\.(pre_tool_use|post_tool_use|user_prompt_submit|session_start|stop)\]\]$/;
29
32
  const CURRENT_HOOK_TABLE_RE = /^\[\[hooks\.[A-Za-z][A-Za-z0-9_]*\]\]$/;
30
33
  // A single, unquoted-or-quoted TOML bare/quoted key: `foo-bar_1`, a basic
@@ -45,7 +48,11 @@ const TOML_KEY = String.raw `(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
45
48
  // single key with nothing after it, never as an array element) so a
46
49
  // multi-line array/value sitting inside a still-owned harness hook table
47
50
  // is never misread as a foreign table header ending the owned region
48
- // early (task 6a037359).
51
+ // early (task 6a037359). A lone element on its own line (`["x"]`, `['x']`,
52
+ // `[1]`, `[1979-05-27]`) does have the shape of a header, so this regex is
53
+ // only a candidate filter: every caller also requires `createTopLevelProbe`
54
+ // before treating a match as a real header (task 6b56d735), the scans
55
+ // through `createLineClassifier` (task 01053b27).
49
56
  const NON_HOOK_TABLE_RE = new RegExp(String.raw `^\[(?!\[hooks\.)\[?\s*${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*\s*\]\]?\s*(?:#.*)?$`);
50
57
  // Every emitted hook table is preceded by exactly this comment shape
51
58
  // (generate-codex-config.ts's `emitHook`); used both to name removed hook
@@ -53,6 +60,16 @@ const NON_HOOK_TABLE_RE = new RegExp(String.raw `^\[(?!\[hooks\.)\[?\s*${TOML_KE
53
60
  // between two harness hook tables (see `assertNoSplitBlock`).
54
61
  const HARNESS_HOOK_COMMENT_PREFIX = "# harness hook: ";
55
62
  const HOOK_ID_LINE_RE = /^# harness hook: (.+) \(budget_ms=\d+\)$/;
63
+ // A `[[hooks.<Event>]]` array-of-tables header, harness-written or not.
64
+ // `NON_HOOK_TABLE_RE` deliberately excludes it, so the ownership scan tells
65
+ // the two apart by the `# harness hook:` comment above the header instead
66
+ // (see `scanOwnedContentEnd`).
67
+ const HOOK_ARRAY_HEADER_RE = /^\[\[hooks\./;
68
+ /** True when a run of comment/blank lines holds a line the generator writes
69
+ * above every hook table (`# harness hook: <id> (budget_ms=N)`). */
70
+ function runHoldsHarnessHookComment(run) {
71
+ return run.split("\n").some((line) => line.trim().startsWith(HARNESS_HOOK_COMMENT_PREFIX));
72
+ }
56
73
  export function defaultCodexConfigPath(homeDir) {
57
74
  return path.join(homeDir ?? os.homedir(), ".codex", "config.toml");
58
75
  }
@@ -202,9 +219,72 @@ function findExactLine(text, target, from) {
202
219
  }
203
220
  return -1;
204
221
  }
222
+ /** Answers, for a line start in `text`, whether that line sits at the top
223
+ * level of the document, that is, not inside a multi-line array, a multi-line
224
+ * inline table or a multi-line string opened by an earlier line. The document
225
+ * parser is the oracle: the text before the line parses on its own exactly
226
+ * when nothing is still open there, whereas a prefix that ends inside an
227
+ * array is an unterminated array and fails. A line that merely LOOKS like a
228
+ * table header (an array element on its own line such as `["x"]`, `['x']`,
229
+ * `[1]` or `[1979-05-27]`) is therefore told apart from a real header even
230
+ * when a real table of the same name exists elsewhere in the document, which
231
+ * a lookup of the header's key path in the parsed document could not do.
232
+ * A line inside an open construct never has a prefix that parses, so such a
233
+ * line is never taken for a header.
234
+ *
235
+ * The verdict is the full-prefix verdict: a line is top level when
236
+ * `text.slice(0, lineStart)` parses. The probe first tries a fast path: it
237
+ * remembers the last line start it confirmed as top level and parses only
238
+ * the text from there; when that segment parses, the line is confirmed
239
+ * without parsing the full prefix. When the segment fails, the full prefix
240
+ * decides, and a line it accepts is confirmed too. The segment is not
241
+ * claimed to agree with the full prefix: a segment can fail where the full
242
+ * prefix parses (a sub-table `[hooks.Stop.meta]` confirmed, then the next
243
+ * `[[hooks.Stop]]` element, which is never a candidate and so stays in the
244
+ * segment, redefines `hooks.Stop` as seen from the segment alone), and then
245
+ * the full prefix is what answers. So a line is reported as not top level
246
+ * only when the full prefix fails, and a line whose full prefix parses is
247
+ * always reported as top level. The fast path can only answer "top level"
248
+ * where the full prefix fails if the prefix holds a conflict of its own (a
249
+ * table defined twice); such a document does not parse as a whole, and the
250
+ * install refuses it through the parse check on the config either way. Even
251
+ * then the segment starts at a confirmed line with nothing open, so a line
252
+ * inside an open array, inline table or multi-line string still fails it.
253
+ *
254
+ * Cost: a real table confirmed on the fast path costs a segment parse. Any
255
+ * candidate whose segment fails costs a full-prefix parse too: an array
256
+ * element on its own line, or a sub-table after a later element of the same
257
+ * array of tables. Many of those are quadratic in their number (seconds at
258
+ * thousands; the CHANGELOG entry for this change has the measurements).
259
+ * Both scans query non-decreasing line starts. A query
260
+ * before the last confirmed line start throws instead of answering; a line
261
+ * reported as not top level is never confirmed, so it never moves that
262
+ * bound (task 6b56d735). Exported for direct tests only. */
263
+ export function createTopLevelProbe(text) {
264
+ let confirmed = 0;
265
+ const parses = (content) => {
266
+ try {
267
+ parseToml(content);
268
+ return true;
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ };
274
+ return (lineStart) => {
275
+ if (lineStart < confirmed) {
276
+ throw new Error(`top-level probe queried at offset ${lineStart}, before the last confirmed line start ${confirmed}`);
277
+ }
278
+ const topLevel = parses(text.slice(confirmed, lineStart)) ||
279
+ (confirmed > 0 && parses(text.slice(0, lineStart)));
280
+ if (topLevel)
281
+ confirmed = lineStart;
282
+ return topLevel;
283
+ };
284
+ }
205
285
  /** The first non-blank, non-comment line at or after `from` (bounded by
206
- * `limit`), trimmed. Used to name the offending foreign header in a
207
- * refusal message. */
286
+ * `limit`), trimmed, with the offset its line starts at. Used to name the
287
+ * offending foreign header in a refusal message. */
208
288
  function firstContentLine(text, from, limit) {
209
289
  let pos = Math.max(from, 0);
210
290
  const end = Math.max(limit, from);
@@ -212,11 +292,59 @@ function firstContentLine(text, from, limit) {
212
292
  const lineEnd = Math.min(lineEndAfter(text, pos), end);
213
293
  const trimmed = text.slice(pos, lineEnd).trim();
214
294
  if (trimmed !== "" && !trimmed.startsWith("#"))
215
- return trimmed;
295
+ return { line: trimmed, start: pos };
216
296
  pos = lineEnd;
217
297
  }
218
298
  return null;
219
299
  }
300
+ /**
301
+ * The one line classifier every header scan of the install uses (the
302
+ * managed-range scan, the foreign-section listing, the kept hook table scan
303
+ * and the hook header map), so a line is read the same way wherever it is
304
+ * met (task 01053b27). Called once per line, in order, from a line start at
305
+ * which nothing is open. The rule, applied in this order:
306
+ *
307
+ * 1. A line that starts inside a multi-line string opened by an earlier line
308
+ * (`nextTripleDelim`) is `string`: string content, whatever it looks like.
309
+ * 2. A blank line or a `#` comment line is `comment` (the END marker and the
310
+ * `# harness hook:` lines are comment lines the callers check by text).
311
+ * 3. A line starting `[[hooks.` is `hookHeader`. No parse is needed: a dotted
312
+ * bare key is not a TOML value, so in a config that parses such a line is
313
+ * never inside an array or inline table, and a config that does not parse
314
+ * is refused by the parse checks before anything is written.
315
+ * 4. Any other line with the shape of a table header (`NON_HOOK_TABLE_RE`)
316
+ * is `foreignHeader` only when it starts at the top level of the document
317
+ * (`createTopLevelProbe`); otherwise it is an array element on its own
318
+ * line inside a multi-line value (`["x"]`, `['x']`, `[1]`,
319
+ * `[1979-05-27]`) and is `content`, never a header.
320
+ * 5. Every other line is `content` (a key/value line or a continuation line
321
+ * of a multi-line value).
322
+ *
323
+ * `wanted` narrows rule 4 for a scan that only uses some headers: a
324
+ * header-shaped line it rejects is `content` for that scan without the
325
+ * top-level probe, whose full-prefix parse is the cost of rule 4 (see
326
+ * `createTopLevelProbe`). Only the hook header map passes one, since it
327
+ * resolves nothing but headers whose root key is `hooks`.
328
+ */
329
+ function createLineClassifier(text, wanted = () => true) {
330
+ let delim = null;
331
+ const startsAtTopLevel = createTopLevelProbe(text);
332
+ return (lineStart, rawLine) => {
333
+ const startedInsideString = delim !== null;
334
+ delim = nextTripleDelim(rawLine, delim);
335
+ if (startedInsideString)
336
+ return "string";
337
+ const trimmed = rawLine.trim();
338
+ if (trimmed === "" || trimmed.startsWith("#"))
339
+ return "comment";
340
+ if (HOOK_ARRAY_HEADER_RE.test(trimmed))
341
+ return "hookHeader";
342
+ if (NON_HOOK_TABLE_RE.test(trimmed) && wanted(trimmed) && startsAtTopLevel(lineStart)) {
343
+ return "foreignHeader";
344
+ }
345
+ return "content";
346
+ };
347
+ }
220
348
  /**
221
349
  * Scans forward from `start` classifying each line as either still-owned
222
350
  * harness content or the start of foreign (non-harness) content, WITHOUT
@@ -235,7 +363,24 @@ function firstContentLine(text, from, limit) {
235
363
  * into the replaced block.
236
364
  *
237
365
  * A line is "foreign" exactly when it is a table header that is not one
238
- * of ours (`NON_HOOK_TABLE_RE`, mirroring the pre-existing legacy scan);
366
+ * of ours (`NON_HOOK_TABLE_RE`, mirroring the pre-existing legacy scan) and
367
+ * sits at the top level of the document (`createTopLevelProbe`: an array
368
+ * element on its own line inside a multi-line value has the same shape but
369
+ * is not a header), or
370
+ * a `[[hooks.<Event>]]` header without a `# harness hook:` comment above it
371
+ * that comes after at least one header that has one (task 01053b27).
372
+ * Failure modes of that second rule: a harness table whose comment an
373
+ * operator deleted ends the range there (a later harness table still
374
+ * carrying its comment, before the END marker when one follows, lands in
375
+ * the zone `assertNoSplitBlock` refuses on; a
376
+ * last table stays as an operator table, which the install names in
377
+ * `keptOperatorHookTables` and refuses when its command is one harness
378
+ * writes, see `assertKeptHookTablesAreOperatorOwned`); a
379
+ * block that never carried the comments (older generator output) has no
380
+ * marked table before its first hook table, so it is consumed whole as
381
+ * before. Bare `key =` lines of an operator table follow that table out of
382
+ * the range; bare keys placed directly under a harness table cannot be told
383
+ * apart from the harness table's own fields and are still consumed with it;
239
384
  * every other non-blank, non-comment line (a hook table's `matcher =` /
240
385
  * `hooks =` field, or an older schema's bare `command =` / `match =` /
241
386
  * `timeout_ms =` / `blocking =` field) is treated as still-owned content,
@@ -251,33 +396,66 @@ function firstContentLine(text, from, limit) {
251
396
  function scanOwnedContentEnd(text, start) {
252
397
  let pos = start;
253
398
  let commentRunStart = null;
254
- let delim = null;
399
+ let sawOwnedContent = false;
400
+ let sawMarkedHookTable = false;
401
+ const classify = createLineClassifier(text);
255
402
  while (pos < text.length) {
256
403
  const lineEnd = lineEndAfter(text, pos);
257
404
  const rawLine = text.slice(pos, lineEnd);
258
- const startedInsideString = delim !== null;
259
- delim = nextTripleDelim(rawLine, delim);
260
- if (startedInsideString) {
405
+ const kind = classify(pos, rawLine);
406
+ if (kind === "string") {
261
407
  commentRunStart = null;
408
+ sawOwnedContent = true;
262
409
  pos = lineEnd;
263
410
  continue;
264
411
  }
265
- const trimmed = rawLine.trim();
266
- if (trimmed === CODEX_MANAGED_END) {
412
+ if (rawLine.trim() === CODEX_MANAGED_END) {
267
413
  return { end: lineEnd, sawEndMarker: true };
268
414
  }
269
- if (trimmed === "" || trimmed.startsWith("#")) {
415
+ if (kind === "comment") {
270
416
  if (commentRunStart === null)
271
417
  commentRunStart = pos;
272
418
  pos = lineEnd;
273
419
  continue;
274
420
  }
275
- if (NON_HOOK_TABLE_RE.test(trimmed)) {
421
+ if (kind === "foreignHeader") {
276
422
  return { end: commentRunStart ?? pos, sawEndMarker: false };
277
423
  }
424
+ if (kind === "hookHeader") {
425
+ const marked = commentRunStart !== null &&
426
+ runHoldsHarnessHookComment(text.slice(commentRunStart, pos));
427
+ if (marked) {
428
+ sawMarkedHookTable = true;
429
+ }
430
+ else if (sawMarkedHookTable) {
431
+ // An operator-authored hook table (no `# harness hook:` comment
432
+ // above it) after the harness tables ends the owned region, like a
433
+ // foreign table, and keeps a comment attached to it (task 01053b27).
434
+ return { end: commentRunStart ?? pos, sawEndMarker: false, endedAtOperatorHookTable: true };
435
+ }
436
+ }
278
437
  commentRunStart = null;
438
+ sawOwnedContent = true;
279
439
  pos = lineEnd;
280
440
  }
441
+ // No END marker and no foreign table: a run of blank/comment lines that
442
+ // reaches EOF and holds at least one comment is an operator's trailing
443
+ // comment, not harness content, so back off to the start of that run
444
+ // (task b34ed105). Not when the run holds a harness-authored line (the
445
+ // generated header, a `# harness hook:` line, the source prefix: the
446
+ // generator itself writes comment-only content, so such a run is stale
447
+ // harness output that must be replaced), and not before the scan has seen
448
+ // an owned content line. A run of only blank lines is still consumed.
449
+ if (commentRunStart !== null && sawOwnedContent) {
450
+ const run = text.slice(commentRunStart, pos);
451
+ const hasComment = /(^|\n)[ \t]*#/.test(run);
452
+ const harnessAuthored = run.includes(HARNESS_HOOK_COMMENT_PREFIX) ||
453
+ run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
454
+ run.includes(GENERATED_HEADER);
455
+ if (hasComment && !harnessAuthored) {
456
+ return { end: commentRunStart, sawEndMarker: false };
457
+ }
458
+ }
281
459
  return { end: pos, sawEndMarker: false };
282
460
  }
283
461
  /**
@@ -307,9 +485,21 @@ function scanOwnedContentEnd(text, start) {
307
485
  * document (`to` is `text.length`), telling the operator to move something
308
486
  * below a marker that does not exist would be nonsensical, so a different
309
487
  * fix (add one, or move the table below the last harness hook table) is
310
- * offered instead (task 6a037359).
488
+ * offered instead (task 6a037359). `blockOpening` names the line the block
489
+ * starts at in the delete-the-lines fix: the BEGIN marker by default; a
490
+ * legacy config (no BEGIN marker) whose END marker survived passes its
491
+ * source-prefix line or generated header instead, since a BEGIN marker it
492
+ * does not have is no line to delete from (task 01053b27). For the generated
493
+ * header the fix deletes that line and the END marker line too: a generated
494
+ * header left alone above END no longer marks a harness block
495
+ * (`findManagedRange` needs a `[[hooks.` line after it), so the install
496
+ * would add a fresh block and leave that pair behind as an orphan. Exported for
497
+ * direct tests only: the scan
498
+ * that feeds it never hands it a line that is not a real header, so its own
499
+ * naming rule is reachable in isolation only by calling it directly (task
500
+ * 6b56d735).
311
501
  */
312
- function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
502
+ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blockOpening = `'${CODEX_MANAGED_BEGIN}'`) {
313
503
  const zoneStart = Math.max(from, 0);
314
504
  const zoneEnd = Math.max(to, from);
315
505
  const zone = text.slice(zoneStart, zoneEnd);
@@ -338,13 +528,31 @@ function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
338
528
  const markerLineEnd = lineEndAfter(text, zoneStart + markerOffset);
339
529
  const foreignHeaderLine = firstContentLine(text, zoneStart, zoneStart + markerOffset) ??
340
530
  firstContentLine(text, markerLineEnd, zoneEnd);
341
- const firstForeignHeader = foreignHeaderLine === null ? "(unknown)" : describeTableHeader(foreignHeaderLine);
342
- const guidance = hasEndMarker
343
- ? `Move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker, or delete the lines ` +
344
- `between '${CODEX_MANAGED_BEGIN}' and '${CODEX_MANAGED_END}' so only harness-owned tables ` +
345
- "remain between them"
346
- : `Add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table (before ` +
347
- `${firstForeignHeader}), or move ${firstForeignHeader} below the last harness hook table`;
531
+ const firstForeignHeader = describeForeignHeaderLine(text, foreignHeaderLine);
532
+ const fixes = hasEndMarker
533
+ ? [
534
+ `move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker`,
535
+ blockOpening === GENERATED_HEADER_OPENING
536
+ ? `delete every line from ${blockOpening} through the '${CODEX_MANAGED_END}' marker ` +
537
+ "line, both included, so the install writes a fresh harness block"
538
+ : `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
539
+ "harness-owned tables remain between them",
540
+ ]
541
+ : [
542
+ `add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
543
+ `(before ${firstForeignHeader})`,
544
+ `move ${firstForeignHeader} below the last harness hook table`,
545
+ ];
546
+ // An uncommented hook table in the middle may be a harness table whose
547
+ // comment line was deleted, so moving it (the operator-table fix) is only
548
+ // right for an operator-owned one; name the restore option first (task
549
+ // 01053b27).
550
+ const isHookTable = HOOK_ARRAY_HEADER_RE.test(foreignHeaderLine?.line ?? "");
551
+ const guidance = isHookTable
552
+ ? `If ${firstForeignHeader} is a harness hook table whose '${HARNESS_HOOK_COMMENT_PREFIX}<id> ` +
553
+ "(budget_ms=<n>)' comment line was deleted, restore that comment line directly above it. " +
554
+ `If it is an operator-owned table, ${fixes.join(", or ")}`
555
+ : fixes.join(", or ").replace(/^./, (c) => c.toUpperCase());
348
556
  throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) sitting before ` +
349
557
  `more harness-owned content: line ${offendingLineNumber} contains '${marker}' and still ` +
350
558
  "looks like part of the harness-managed hook block; refusing to guess the block " +
@@ -377,6 +585,23 @@ function parseTableHeader(line) {
377
585
  function describeTableHeader(line) {
378
586
  return parseTableHeader(line)?.header ?? "(unknown)";
379
587
  }
588
+ /** How a refusal names the line it found where the foreign table's header
589
+ * should be. A verified header prints as its bracketed key path. A line that
590
+ * has the shape of a header but is not one (an array element on its own line
591
+ * inside a multi-line value) is never printed, since it can hold a config
592
+ * value such as a token; only its line number is. Any other line is
593
+ * `(unknown)`, as before. */
594
+ function describeForeignHeaderLine(text, found) {
595
+ if (found === null)
596
+ return "(unknown)";
597
+ const parsed = parseTableHeader(found.line);
598
+ if (parsed === null)
599
+ return "(unknown)";
600
+ if (!createTopLevelProbe(text)(found.start)) {
601
+ return `line ${lineNumberAt(text, found.start)}`;
602
+ }
603
+ return parsed.header;
604
+ }
380
605
  /** A key segment's bare name: a literal (`'...'`) key's inner text, a basic
381
606
  * (`"..."`) key's inner text when it holds no escape sequence, otherwise
382
607
  * the segment as written. Used only to recognize the `hooks` root however
@@ -420,18 +645,12 @@ function collectForeignSectionHeaders(text, from, to) {
420
645
  const counts = new Map();
421
646
  let pos = Math.max(from, 0);
422
647
  const limit = Math.max(to, from);
423
- let delim = null;
648
+ const classify = createLineClassifier(text);
424
649
  while (pos < limit) {
425
650
  const lineEnd = Math.min(lineEndAfter(text, pos), limit);
426
651
  const rawLine = text.slice(pos, lineEnd);
427
- const startedInsideString = delim !== null;
428
- delim = nextTripleDelim(rawLine, delim);
429
- if (startedInsideString) {
430
- pos = lineEnd;
431
- continue;
432
- }
433
- const trimmed = rawLine.trim();
434
- if (NON_HOOK_TABLE_RE.test(trimmed)) {
652
+ if (classify(pos, rawLine) === "foreignHeader") {
653
+ const trimmed = rawLine.trim();
435
654
  const root = foreignSectionRootKey(trimmed);
436
655
  counts.set(root, (counts.get(root) ?? 0) + 1);
437
656
  if (!seenRoots.has(root)) {
@@ -456,6 +675,107 @@ function extractHookIds(content) {
456
675
  }
457
676
  return ids;
458
677
  }
678
+ /**
679
+ * The operator-authored hook tables directly after a managed range that
680
+ * ended at one (`scanOwnedContentEnd`'s `endedAtOperatorHookTable`): every
681
+ * `[[hooks.*]]` header from `from` up to the first non-hook table header or
682
+ * `to`, each by header, line number and offset. Lines are read by
683
+ * `createLineClassifier`, so an array element on its own line inside a kept
684
+ * table's multi-line value neither ends the run nor is named, and a hook
685
+ * table after it is still collected (task 01053b27). Empty for any other ending
686
+ * (the first header at `from` is then a non-hook table, or there is none).
687
+ * The caller has already refused a zone that holds a harness marker
688
+ * (`assertNoSplitBlock`), so no header found here carries a `# harness
689
+ * hook:` comment.
690
+ */
691
+ function collectKeptHookTables(text, from, to) {
692
+ const tables = [];
693
+ let pos = from;
694
+ const classify = createLineClassifier(text);
695
+ while (pos < to) {
696
+ const lineEnd = Math.min(lineEndAfter(text, pos), to);
697
+ const rawLine = text.slice(pos, lineEnd);
698
+ const kind = classify(pos, rawLine);
699
+ if (kind === "foreignHeader")
700
+ break;
701
+ if (kind === "hookHeader") {
702
+ tables.push({ header: describeTableHeader(rawLine), line: lineNumberAt(text, pos), start: pos });
703
+ }
704
+ pos = lineEnd;
705
+ }
706
+ return tables;
707
+ }
708
+ /** A kept table as the install names it: its header, its 1-based line
709
+ * number in the config before the install and in the config the install
710
+ * writes (`nextLines`, same order as `kept`), never any value text. */
711
+ function describeKeptHookTables(kept, nextLines) {
712
+ return kept.map((t, i) => `${t.header} (line ${t.line} before the install, line ${nextLines[i]} after it)`);
713
+ }
714
+ const UTF8_BOM = "\uFEFF";
715
+ /** A leading UTF-8 BOM is not part of any managed range: a range that would
716
+ * start at offset 0 starts just past the BOM instead, so the splice keeps
717
+ * the BOM exactly once (task b34ed105). */
718
+ function startPastBom(text, start) {
719
+ return start === 0 && text.startsWith(UTF8_BOM) ? UTF8_BOM.length : start;
720
+ }
721
+ /**
722
+ * The range from `start` to `end`, where the ownership scan stopped short of
723
+ * an END marker, plus what it keeps after `end`: when a stray END marker line
724
+ * follows (a drifted END), the zone up to it is checked for a split block
725
+ * (`assertNoSplitBlock`), its foreign sections and kept operator hook tables
726
+ * are collected, and that END line is excised by the splice (`strayEnd`);
727
+ * without one, the same happens up to the end of the document. Content below
728
+ * that END line is never read here and the splice leaves it untouched.
729
+ * `blockOpening` is the line the split-block refusal names as the start of
730
+ * the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
731
+ */
732
+ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
733
+ const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
734
+ if (strayIdx === -1) {
735
+ assertNoSplitBlock(text, end, text.length, configPath, false);
736
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
737
+ const keptHookTables = collectKeptHookTables(text, end, text.length);
738
+ return { start, end, foreignSectionsPreserved, keptHookTables };
739
+ }
740
+ const strayLineStart = lineStartAt(text, strayIdx);
741
+ const strayLineEnd = lineEndAfter(text, strayIdx);
742
+ assertNoSplitBlock(text, end, strayLineStart, configPath, true, blockOpening);
743
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
744
+ const keptHookTables = collectKeptHookTables(text, end, strayLineStart);
745
+ return {
746
+ start,
747
+ end,
748
+ strayEnd: { start: strayLineStart, end: strayLineEnd },
749
+ foreignSectionsPreserved,
750
+ keptHookTables,
751
+ };
752
+ }
753
+ /**
754
+ * The range of a legacy (no BEGIN marker) config: the source-prefix or
755
+ * generated-header paths. When the scan ended at an operator hook table,
756
+ * the zone after it is handled like the BEGIN path's
757
+ * (`rangeBeforeStrayEnd`): an END marker that survived its deleted BEGIN
758
+ * line, sitting after the kept tables, is excised like a drifted END, so
759
+ * the install leaves no orphan END line below the kept tables and the
760
+ * kept-table refusal offers moving an operator-owned table below that END;
761
+ * a harness-commented table after the kept tables refuses through the
762
+ * split-block check instead of being kept as if it were operator content
763
+ * (the fresh block would repeat it) when it sits before that END, or
764
+ * anywhere later when no END follows. Content below that END is untouched,
765
+ * as on the BEGIN path, so a harness-commented table there is kept and not
766
+ * named. The split-block refusal names `blockOpening` (the source-prefix
767
+ * line or the generated header) as the start of the block, since there is
768
+ * no BEGIN marker (task 01053b27). Other endings keep the legacy behaviour.
769
+ */
770
+ function legacyManagedRange(text, start, scan, configPath, blockOpening) {
771
+ if (scan.endedAtOperatorHookTable) {
772
+ return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening);
773
+ }
774
+ const foreignSectionsPreserved = scan.sawEndMarker
775
+ ? []
776
+ : collectForeignSectionHeaders(text, scan.end, text.length);
777
+ return { start, end: scan.end, foreignSectionsPreserved, keptHookTables: [] };
778
+ }
459
779
  function findManagedRange(text, configPath) {
460
780
  const begin = text.indexOf(CODEX_MANAGED_BEGIN);
461
781
  if (begin !== -1) {
@@ -478,54 +798,36 @@ function findManagedRange(text, configPath) {
478
798
  }
479
799
  let start = lineStartAt(text, begin);
480
800
  const prev = previousLine(text, start);
481
- if (prev && prev.text.startsWith(CODEX_MANAGED_SOURCE_PREFIX)) {
482
- start = prev.start;
801
+ if (prev) {
802
+ // A BOM sitting before the source-prefix comment on line 1 must not
803
+ // hide the prefix from this check (task b34ed105).
804
+ const prevText = prev.start === 0 ? prev.text.replace(/^\uFEFF/, "") : prev.text;
805
+ if (prevText.startsWith(CODEX_MANAGED_SOURCE_PREFIX))
806
+ start = prev.start;
483
807
  }
808
+ start = startPastBom(text, start);
484
809
  const contentStart = lineEndAfter(text, begin);
485
810
  const scan = scanOwnedContentEnd(text, contentStart);
486
811
  const end = scan.end;
487
812
  if (scan.sawEndMarker) {
488
- return { start, end, foreignSectionsPreserved: [] };
489
- }
490
- const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
491
- if (strayIdx === -1) {
492
- assertNoSplitBlock(text, end, text.length, configPath, false);
493
- const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
494
- return { start, end, foreignSectionsPreserved };
495
- }
496
- const strayLineStart = lineStartAt(text, strayIdx);
497
- const strayLineEnd = lineEndAfter(text, strayIdx);
498
- assertNoSplitBlock(text, end, strayLineStart, configPath, true);
499
- const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
500
- return {
501
- start,
502
- end,
503
- strayEnd: { start: strayLineStart, end: strayLineEnd },
504
- foreignSectionsPreserved,
505
- };
813
+ return { start, end, foreignSectionsPreserved: [], keptHookTables: [] };
814
+ }
815
+ return rangeBeforeStrayEnd(text, start, end, configPath);
506
816
  }
507
817
  const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
508
818
  if (source !== -1) {
509
- const start = lineStartAt(text, source);
510
- const scan = scanOwnedContentEnd(text, start);
511
- const end = scan.end;
512
- const foreignSectionsPreserved = scan.sawEndMarker
513
- ? []
514
- : collectForeignSectionHeaders(text, end, text.length);
515
- return { start, end, foreignSectionsPreserved };
819
+ const start = startPastBom(text, lineStartAt(text, source));
820
+ return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
516
821
  }
517
822
  const generated = text.indexOf(GENERATED_HEADER);
518
823
  if (generated !== -1) {
519
- const start = lineStartAt(text, generated);
824
+ const start = startPastBom(text, lineStartAt(text, generated));
520
825
  const scan = scanOwnedContentEnd(text, start);
521
826
  const end = scan.end;
522
827
  const candidate = text.slice(start, end);
523
828
  if (candidate.includes("[[hooks.") ||
524
829
  candidate.includes("[[hooks.pre_tool_use]]")) {
525
- const foreignSectionsPreserved = scan.sawEndMarker
526
- ? []
527
- : collectForeignSectionHeaders(text, end, text.length);
528
- return { start, end, foreignSectionsPreserved };
830
+ return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
529
831
  }
530
832
  }
531
833
  return null;
@@ -714,8 +1016,12 @@ function firstDifferingKeyPath(a, b, path) {
714
1016
  * from a harness-written one would need the same line-level reading of the
715
1017
  * file this net is meant to be independent of. An operator-authored
716
1018
  * `[[hooks.<Event>]]` entry that the line scan wrongly counts as part of
717
- * the harness-managed block is therefore replaced without a refusal
718
- * (follow-up task 01053b27).
1019
+ * the harness-managed block is therefore replaced without a refusal (task
1020
+ * 01053b27 covers the operator table after the harness tables, which the scan
1021
+ * now keeps and names; still replaced silently are an operator table before
1022
+ * the first harness table inside the block, one under a comment run that
1023
+ * holds a `# harness hook:` line, bare keys directly under a harness table,
1024
+ * and one after an old block that never carried the comments).
719
1025
  *
720
1026
  * Parsing goes through `parseToml` (a leading byte order mark ignored,
721
1027
  * 64-bit integers kept as `bigint`). A document that does not parse refuses
@@ -863,6 +1169,222 @@ function withBackupLocation(linkPath, check) {
863
1169
  `sit beside the link, as ${linkPath}.harness-backup-* files.`, err.configPath);
864
1170
  }
865
1171
  }
1172
+ /** A header key segment's name as TOML reads it: a bare key as written, a
1173
+ * literal (`'...'`) key's inner text, a basic (`"..."`) key's inner text
1174
+ * when it holds no escape sequence. `null` for a basic key with an escape,
1175
+ * which this scan does not decode; a header holding one is not matched to a
1176
+ * parsed entry (see `mapHookArrayHeaders`). */
1177
+ function resolvedKeyName(segment) {
1178
+ const quote = segment[0];
1179
+ if (quote === "'")
1180
+ return segment.slice(1, -1);
1181
+ if (quote === '"') {
1182
+ const inner = segment.slice(1, -1);
1183
+ return inner.includes("\\") ? null : inner;
1184
+ }
1185
+ return segment;
1186
+ }
1187
+ /** Whether a header-shaped line's first key resolves to `hooks`, the only
1188
+ * root `mapHookArrayHeaders` resolves. */
1189
+ function hooksRooted(trimmed) {
1190
+ return resolvedKeyName(parseTableHeader(trimmed)?.keys[0] ?? "") === "hooks";
1191
+ }
1192
+ function subKey(event, index) {
1193
+ return `${event}\u0000${index}`;
1194
+ }
1195
+ /**
1196
+ * Every table header of the whole document that appends to a hook
1197
+ * event array (`[[hooks.<Event>]]`, however its keys are spelled) or to such
1198
+ * an entry's own `hooks` array (`[[hooks.<Event>.hooks]]`), with the entry it
1199
+ * appends: TOML appends each header's table to its array in document order,
1200
+ * and a sub-array header to the array's LAST entry so far. Only the lines
1201
+ * `createLineClassifier` reads as a header are resolved: a line inside a
1202
+ * multi-line string, and an array element on its own line, are skipped. A line resolved
1203
+ * to such a header cannot be a value line of a document that parses (a
1204
+ * dotted bare path is not a TOML value), and a header this scan cannot
1205
+ * resolve (an escape in a quoted key) is not counted, so the caller compares
1206
+ * `eventCounts` and `subCounts` with the parsed arrays' lengths and treats a
1207
+ * mismatch as "cannot match exactly". The classifier reads a header-shaped
1208
+ * line as a header here only when its root key resolves to `hooks`
1209
+ * (`hooksRooted`, checked before the top-level probe), so every header this
1210
+ * scan resolves has the root `hooks` (a `[[hooks.` line has it by its
1211
+ * prefix), a table outside `hooks` never shifts the order match, and an
1212
+ * array element on its own line (`["x"]`, `[1]`) costs no parse in this
1213
+ * scan, unless the element is the string `hooks` (`["hooks"]`,
1214
+ * `['hooks']`): that line reads as a `hooks` root, so it is probed like the
1215
+ * `hooks`-rooted spellings that `[[hooks.` does not catch
1216
+ * (`[[ hooks.Stop ]]`, `[["hooks".Stop]]`, `[hooks.Stop]`), and the probe
1217
+ * finds it inside the array.
1218
+ */
1219
+ function mapHookArrayHeaders(text) {
1220
+ const map = { slots: new Map(), eventCounts: new Map(), subCounts: new Map() };
1221
+ let pos = 0;
1222
+ const classify = createLineClassifier(text, hooksRooted);
1223
+ while (pos < text.length) {
1224
+ const lineEnd = lineEndAfter(text, pos);
1225
+ const rawLine = text.slice(pos, lineEnd);
1226
+ const kind = classify(pos, rawLine);
1227
+ const parsed = kind === "hookHeader" || kind === "foreignHeader" ? parseTableHeader(rawLine) : null;
1228
+ // The bracket kind is not checked: a single-bracket `[hooks.<Event>]`
1229
+ // cannot coexist with a `[[hooks.<Event>]]` array in a document that
1230
+ // parses, and a single-bracket `[hooks.<Event>.hooks]` makes that entry's
1231
+ // `hooks` a table, whose `command` is not a hook command either way.
1232
+ if (parsed !== null) {
1233
+ const [, event, sub] = parsed.keys.map(resolvedKeyName);
1234
+ if (typeof event === "string") {
1235
+ const count = map.eventCounts.get(event) ?? 0;
1236
+ if (parsed.keys.length === 2) {
1237
+ map.slots.set(pos, { event, index: count });
1238
+ map.eventCounts.set(event, count + 1);
1239
+ }
1240
+ else if (parsed.keys.length === 3 && sub === "hooks") {
1241
+ // Appends to the event's last entry so far; with no entry before
1242
+ // it (`index` -1) TOML makes the event a table, not an array, and
1243
+ // the caller finds no array to match.
1244
+ const key = subKey(event, count - 1);
1245
+ const subIndex = map.subCounts.get(key) ?? 0;
1246
+ map.slots.set(pos, { event, index: count - 1, subIndex });
1247
+ map.subCounts.set(key, subIndex + 1);
1248
+ }
1249
+ }
1250
+ }
1251
+ pos = lineEnd;
1252
+ }
1253
+ return map;
1254
+ }
1255
+ /** The hook command of one parsed hook entry, `{ command = "..." }`, as a
1256
+ * one-element list; empty for anything else. */
1257
+ function hookCommandOf(entry) {
1258
+ return isTomlTable(entry) && typeof entry.command === "string" ? [entry.command] : [];
1259
+ }
1260
+ /** The hook commands of one parsed `hooks.<Event>` entry: its own `command`
1261
+ * plus each `command` of its `hooks` array (inline `hooks = [{ ... }]` or the
1262
+ * expanded `[[hooks.<Event>.hooks]]` form). No other `command` key counts. */
1263
+ function eventEntryCommands(entry) {
1264
+ if (!isTomlTable(entry))
1265
+ return [];
1266
+ const nested = Array.isArray(entry.hooks) ? entry.hooks.flatMap(hookCommandOf) : [];
1267
+ return [...hookCommandOf(entry), ...nested];
1268
+ }
1269
+ /** Every hook command of a parsed document's `hooks.<Event>` arrays. */
1270
+ function documentHookCommands(doc) {
1271
+ if (!isTomlTable(doc) || !isTomlTable(doc.hooks))
1272
+ return [];
1273
+ return Object.values(doc.hooks).flatMap((value) => Array.isArray(value) ? value.flatMap(eventEntryCommands) : []);
1274
+ }
1275
+ /**
1276
+ * The hook commands a kept table defines, read from the parsed whole
1277
+ * document (`doc`) at the entry its header appends (`map`): for
1278
+ * `[[hooks.<Event>]]` the entry's own `command`, plus its `hooks` array's
1279
+ * commands unless `[[hooks.<Event>.hooks]]` headers build that array (each
1280
+ * of those is its own kept table); for `[[hooks.<Event>.hooks]]` that nested
1281
+ * entry's `command`. A header appending nowhere else (`[[hooks.<Event>.x]]`)
1282
+ * defines none. `null` when the header cannot be matched exactly: a header
1283
+ * the scan did not resolve, a count of headers that differs from the parsed
1284
+ * array's length, or a nested `hooks` header with no entry before it.
1285
+ */
1286
+ function keptTableCommands(table, doc, map) {
1287
+ const slot = map.slots.get(table.start);
1288
+ if (slot === undefined) {
1289
+ // Not an append the map resolved: a `[[hooks.<Event>.x]]` table holds no
1290
+ // hook command; a header with a key the map does not resolve cannot be
1291
+ // matched.
1292
+ const resolved = parseTableHeader(table.header)?.keys.every((k) => resolvedKeyName(k) !== null);
1293
+ return resolved === true ? [] : null;
1294
+ }
1295
+ const hooks = isTomlTable(doc) && isTomlTable(doc.hooks) ? doc.hooks : undefined;
1296
+ const entries = hooks?.[slot.event];
1297
+ if (!Array.isArray(entries) || entries.length !== map.eventCounts.get(slot.event))
1298
+ return null;
1299
+ const entry = entries[slot.index];
1300
+ const subCount = map.subCounts.get(subKey(slot.event, slot.index)) ?? 0;
1301
+ if (slot.subIndex === undefined) {
1302
+ return subCount > 0 ? hookCommandOf(entry) : eventEntryCommands(entry);
1303
+ }
1304
+ const nested = isTomlTable(entry) && Array.isArray(entry.hooks) ? entry.hooks : [];
1305
+ if (nested.length !== subCount)
1306
+ return null;
1307
+ return hookCommandOf(nested[slot.subIndex]);
1308
+ }
1309
+ /** The move-below-END option of a kept-table refusal: only when the config
1310
+ * has an END marker after the kept tables, since below it the install never
1311
+ * touches a table (an operator-owned table whose command starts with
1312
+ * `harness `, the operator's own CLI, is kept that way). Every range with
1313
+ * kept tables and an END marker after them records that marker as
1314
+ * `strayEnd`, on the BEGIN path and on the legacy paths alike
1315
+ * (`rangeBeforeStrayEnd`), so the caller keys the option on it. */
1316
+ function moveBelowEndOption(hasEndMarker) {
1317
+ return hasEndMarker
1318
+ ? `, or, if it is an operator-owned table, move it below the '${CODEX_MANAGED_END}' marker, ` +
1319
+ "where the install never touches it"
1320
+ : "";
1321
+ }
1322
+ /** Parses the whole document for the kept-table check, refusing with the
1323
+ * parser's position and one-line reason only (never its code frame). */
1324
+ function parseForKeptTableCheck(content, what, configPath) {
1325
+ try {
1326
+ return parseToml(content);
1327
+ }
1328
+ catch (err) {
1329
+ throw new CodexInstallRefusalError(`Codex config ${configPath}: the TOML parser used by harness could not read ${what} ` +
1330
+ `(${describeTomlParseError(err)}), so harness cannot check the operator hook tables it ` +
1331
+ "would keep after the harness hook tables; refusing to install. The file is untouched.", configPath);
1332
+ }
1333
+ }
1334
+ /**
1335
+ * Refuses (file untouched) when an operator hook table the install keeps
1336
+ * looks like one harness wrote: one of its hook commands equals a command of
1337
+ * the fresh block, or starts with `harness `. The line scan tells a harness
1338
+ * table from an operator one only by the `# harness hook:` comment above it,
1339
+ * so a harness table whose comment an operator deleted, sitting last, would
1340
+ * otherwise be kept next to the fresh block: the hook would run twice, or a
1341
+ * hook the manifest retired would keep running after every later install
1342
+ * (task 01053b27). A retired hook whose command does not start with
1343
+ * `harness ` (an environment-prefixed report-dir wrap, the memory-router
1344
+ * hook, a custom manifest hook) is not recognised: it is kept and named only.
1345
+ *
1346
+ * The commands come from the parsed WHOLE document (the same `parseToml` the
1347
+ * semantic net uses), each kept table matched to its parsed entry by the
1348
+ * order of the `[[hooks.<Event>]]` headers (`mapHookArrayHeaders`); no part
1349
+ * of the file is parsed on its own. Only hook commands count
1350
+ * (`hooks.<Event>[].command` and `hooks.<Event>[].hooks[].command`), not any
1351
+ * other key named `command`. A kept table that cannot be matched exactly
1352
+ * refuses too. Every message names the table's header and line number only,
1353
+ * never a command or any other value text, and never a parser code frame,
1354
+ * since a refusal is printed to stderr and into `--json` output.
1355
+ */
1356
+ function assertKeptHookTablesAreOperatorOwned(kept, currentContent, generatedContent, configPath, hasEndMarker) {
1357
+ if (kept.length === 0)
1358
+ return;
1359
+ const freshCommands = new Set(documentHookCommands(parseForKeptTableCheck(generatedContent, "the generated hook block", configPath)));
1360
+ const doc = parseForKeptTableCheck(currentContent, "this file", configPath);
1361
+ const map = mapHookArrayHeaders(currentContent);
1362
+ for (const table of kept) {
1363
+ const commands = keptTableCommands(table, doc, map);
1364
+ if (commands === null) {
1365
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
1366
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
1367
+ "harness hook tables, that harness could not match to its entry in the parsed config " +
1368
+ "(a header it does not resolve: an escape sequence in a quoted key, on this table or on " +
1369
+ "another table of the same hook event, or a nested hooks table it cannot place), so it " +
1370
+ "cannot check whether harness wrote that table. Spell those header keys without escape " +
1371
+ `sequences, restore the '${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line ` +
1372
+ `directly above ${table.header} if harness wrote it${moveBelowEndOption(hasEndMarker)}, or ` +
1373
+ "delete the table, then re-run `harness apply --runtime codex --install`. The file is untouched.", configPath);
1374
+ }
1375
+ const harnessShaped = commands.some((command) => freshCommands.has(command) || command.startsWith("harness "));
1376
+ if (!harnessShaped)
1377
+ continue;
1378
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
1379
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
1380
+ "harness hook tables, whose command is one harness itself writes. harness cannot tell it " +
1381
+ "from a harness hook table whose comment line was deleted, and keeping it next to the " +
1382
+ "fresh harness block would run that hook twice (or keep a retired harness hook running). " +
1383
+ `Restore the comment line directly above ${table.header}, so the install replaces the table ` +
1384
+ `with the fresh block${moveBelowEndOption(hasEndMarker)}, or delete the table, then re-run ` +
1385
+ `\`harness apply --runtime codex --install\`. The file is untouched.`, configPath);
1386
+ }
1387
+ }
866
1388
  export function planCodexConfigInstall(opts) {
867
1389
  validateCodexManagedConfig(opts.generatedContent);
868
1390
  const requestedPath = opts.configPath ?? defaultCodexConfigPath(opts.homeDir);
@@ -886,16 +1408,29 @@ export function planCodexConfigInstall(opts) {
886
1408
  let summary;
887
1409
  let removedHookIds = [];
888
1410
  let foreignSectionsPreserved = [];
1411
+ let keptHookTables = [];
1412
+ let keptTableDescriptions = [];
1413
+ let hasEndMarkerAfterKept = false;
889
1414
  if (range) {
890
1415
  const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
891
1416
  const newHookIds = extractHookIds(managedBlock);
892
1417
  removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
893
- foreignSectionsPreserved = range.foreignSectionsPreserved;
1418
+ keptHookTables = range.keptHookTables;
1419
+ hasEndMarkerAfterKept = range.strayEnd !== undefined;
894
1420
  const middleForeign = range.strayEnd
895
1421
  ? currentContent.slice(range.end, range.strayEnd.start)
896
1422
  : "";
897
1423
  const tailStart = range.strayEnd ? range.strayEnd.end : range.end;
898
1424
  nextContent = `${currentContent.slice(0, range.start)}${managedBlock}${middleForeign}${currentContent.slice(tailStart)}`;
1425
+ // A kept table sits right after the fresh block in the written config
1426
+ // (it is in `middleForeign`, or at the start of the tail when there is
1427
+ // no stray END), so its offset there follows from the splice.
1428
+ const shift = range.start + managedBlock.length - range.end;
1429
+ keptTableDescriptions = describeKeptHookTables(keptHookTables, keptHookTables.map((t) => lineNumberAt(nextContent, t.start + shift)));
1430
+ // The kept operator hook tables ride the same list, so the dry-run and
1431
+ // the install summary name them exactly like the foreign tables kept
1432
+ // beside them (task 01053b27).
1433
+ foreignSectionsPreserved = [...range.foreignSectionsPreserved, ...keptTableDescriptions];
899
1434
  summary = `updated harness-managed Codex hook block in ${configPath}${viaLink}`;
900
1435
  }
901
1436
  else {
@@ -912,6 +1447,7 @@ export function planCodexConfigInstall(opts) {
912
1447
  if (currentContent !== nextContent) {
913
1448
  withBackupLocation(linkPath, () => assertConfigSemanticInvariant(currentContent, nextContent, configPath));
914
1449
  }
1450
+ assertKeptHookTablesAreOperatorOwned(keptHookTables, currentContent, opts.generatedContent, configPath, hasEndMarkerAfterKept);
915
1451
  return {
916
1452
  configPath,
917
1453
  ...(linkPath !== undefined ? { linkPath } : {}),
@@ -925,6 +1461,7 @@ export function planCodexConfigInstall(opts) {
925
1461
  : summary,
926
1462
  removedHookIds,
927
1463
  foreignSectionsPreserved,
1464
+ keptOperatorHookTables: keptTableDescriptions,
928
1465
  };
929
1466
  }
930
1467
  function timestampForBackup(now) {