@lannguyensi/harness 0.59.2 → 0.61.0

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/dist/cli/apply/apply.d.ts +20 -0
  4. package/dist/cli/apply/apply.js +13 -7
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/apply/install-codex-config.d.ts +26 -4
  7. package/dist/cli/apply/install-codex-config.js +299 -120
  8. package/dist/cli/apply/install-codex-config.js.map +1 -1
  9. package/dist/cli/approve/understanding.d.ts +6 -6
  10. package/dist/cli/approve/understanding.js +62 -58
  11. package/dist/cli/approve/understanding.js.map +1 -1
  12. package/dist/cli/bounded-stdin.d.ts +27 -0
  13. package/dist/cli/bounded-stdin.js +82 -0
  14. package/dist/cli/bounded-stdin.js.map +1 -0
  15. package/dist/cli/doctor/format.js +22 -0
  16. package/dist/cli/doctor/format.js.map +1 -1
  17. package/dist/cli/doctor/index.js +60 -31
  18. package/dist/cli/doctor/index.js.map +1 -1
  19. package/dist/cli/doctor/settings-drift.js +12 -2
  20. package/dist/cli/doctor/settings-drift.js.map +1 -1
  21. package/dist/cli/doctor/types.d.ts +17 -0
  22. package/dist/cli/dry-run.js +7 -0
  23. package/dist/cli/dry-run.js.map +1 -1
  24. package/dist/cli/index.js +46 -144
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/cli/pack/auto-approve-path.js +47 -13
  27. package/dist/cli/pack/auto-approve-path.js.map +1 -1
  28. package/dist/cli/pack/hook-bootstrap.d.ts +71 -5
  29. package/dist/cli/pack/hook-bootstrap.js +92 -18
  30. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  31. package/dist/cli/pack/hook-branch-protection.d.ts +6 -0
  32. package/dist/cli/pack/hook-branch-protection.js +11 -2
  33. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  34. package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
  35. package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
  36. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +6 -0
  37. package/dist/cli/pack/hook-codex-pre-tool-use.js +52 -7
  38. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  39. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +7 -0
  40. package/dist/cli/pack/hook-post-merge-gate-record.js +39 -2
  41. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -1
  42. package/dist/cli/pack/hook-post-merge-gate.d.ts +6 -0
  43. package/dist/cli/pack/hook-post-merge-gate.js +17 -2
  44. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  45. package/dist/cli/pack/hook-post-tool-use.js +2 -2
  46. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  47. package/dist/cli/pack/hook-pre-tool-use.d.ts +7 -0
  48. package/dist/cli/pack/hook-pre-tool-use.js +84 -18
  49. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  50. package/dist/cli/pack/hook-runtime-reality.d.ts +2 -0
  51. package/dist/cli/pack/hook-runtime-reality.js +49 -9
  52. package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
  53. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +6 -0
  54. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +11 -2
  55. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  56. package/dist/cli/pack/hook-solution-acceptance.d.ts +6 -0
  57. package/dist/cli/pack/hook-solution-acceptance.js +11 -2
  58. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  59. package/dist/cli/pack/hook-subagent-start.d.ts +2 -0
  60. package/dist/cli/pack/hook-subagent-start.js +21 -4
  61. package/dist/cli/pack/hook-subagent-start.js.map +1 -1
  62. package/dist/cli/policy/intercept.d.ts +2 -0
  63. package/dist/cli/policy/intercept.js +14 -13
  64. package/dist/cli/policy/intercept.js.map +1 -1
  65. package/dist/cli/record/index.d.ts +11 -2
  66. package/dist/cli/record/index.js +114 -25
  67. package/dist/cli/record/index.js.map +1 -1
  68. package/dist/cli/session-start/branch-check.d.ts +3 -29
  69. package/dist/cli/session-start/branch-check.js +10 -37
  70. package/dist/cli/session-start/branch-check.js.map +1 -1
  71. package/dist/cli/session-start/index.d.ts +3 -32
  72. package/dist/cli/session-start/index.js +19 -31
  73. package/dist/cli/session-start/index.js.map +1 -1
  74. package/dist/cli/session-start/shared-options.d.ts +103 -0
  75. package/dist/cli/session-start/shared-options.js +107 -0
  76. package/dist/cli/session-start/shared-options.js.map +1 -0
  77. package/dist/cli/session-start/stale-base-check.d.ts +3 -28
  78. package/dist/cli/session-start/stale-base-check.js +10 -37
  79. package/dist/cli/session-start/stale-base-check.js.map +1 -1
  80. package/dist/cli/session-start/toolchain-parity.d.ts +5 -21
  81. package/dist/cli/session-start/toolchain-parity.js +9 -38
  82. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  83. package/dist/cli/smoke/index.d.ts +1 -1
  84. package/dist/cli/smoke/index.js +8 -11
  85. package/dist/cli/smoke/index.js.map +1 -1
  86. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +11 -0
  87. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +11 -0
  88. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  89. package/dist/policy-packs/builtin/post-merge-gate.js +5 -2
  90. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  91. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.d.ts +36 -0
  92. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js +73 -0
  93. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js.map +1 -0
  94. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +3 -2
  95. package/dist/policy-packs/builtin/understanding-before-execution/index.js +5 -2
  96. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  97. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +1 -0
  98. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  99. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +253 -1
  100. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +429 -13
  101. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  102. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +1 -1
  103. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +2 -2
  104. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  105. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +36 -0
  106. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +50 -0
  107. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
  108. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +3 -2
  109. package/dist/policy-packs/builtin/understanding-before-execution.js +4 -1
  110. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  111. package/dist/runtime/bash-prefix-parse.js +319 -126
  112. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  113. package/dist/runtime/deletion-target-resolve.js +24 -1
  114. package/dist/runtime/deletion-target-resolve.js.map +1 -1
  115. package/dist/runtime/destructive-shell-floor.js +15 -1
  116. package/dist/runtime/destructive-shell-floor.js.map +1 -1
  117. package/dist/runtime/intercept.d.ts +38 -0
  118. package/dist/runtime/intercept.js +224 -5
  119. package/dist/runtime/intercept.js.map +1 -1
  120. package/dist/runtime/read-only-bash.js +11 -1
  121. package/dist/runtime/read-only-bash.js.map +1 -1
  122. package/dist/runtime/shell-word.d.ts +42 -1
  123. package/dist/runtime/shell-word.js +174 -6
  124. package/dist/runtime/shell-word.js.map +1 -1
  125. package/package.json +1 -1
@@ -65,11 +65,6 @@ const HOOK_ID_LINE_RE = /^# harness hook: (.+) \(budget_ms=\d+\)$/;
65
65
  // the two apart by the `# harness hook:` comment above the header instead
66
66
  // (see `scanOwnedContentEnd`).
67
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
- }
73
68
  export function defaultCodexConfigPath(homeDir) {
74
69
  return path.join(homeDir ?? os.homedir(), ".codex", "config.toml");
75
70
  }
@@ -189,35 +184,65 @@ function nextTripleDelim(rawLine, delimIn) {
189
184
  return delim;
190
185
  }
191
186
  /**
192
- * The first line, at or after `from`, whose TRIMMED content is byte-equal
193
- * to `target` (not a raw substring search). A raw `text.indexOf(target,
194
- * from)` would also match `target` sitting mid-line inside unrelated
195
- * content; matching only whole trimmed lines rules that out. A line that
196
- * BEGAN inside an open TOML multi-line string (tracked via
197
- * `nextTripleDelim`, correctly distinguishing `"""` from `'''` and
198
- * ignoring delimiters inside a comment or a single-line string) is never
199
- * treated as a possible match, regardless of its trimmed text: a foreign
200
- * table's own multi-line string value may legitimately contain a line
201
- * that reads exactly like the harness END marker (task 6a037359) without
202
- * that being a real stray END. The triple-quote state is tracked only
203
- * from `from` onward (a string open before `from` is not detected), which
204
- * is correct for the caller's one use: `from` is always the start of the
205
- * first foreign table in the zone being searched, so any multi-line
206
- * string that matters here opens inside that zone, not before it.
187
+ * The one rule every managed-block marker is located by (task 36d962d2): a
188
+ * line is a marker line only when its trimmed text is the marker (`whole`)
189
+ * or starts with it (`prefix`) AND it sits at the top level of the document
190
+ * (`createTopLevelProbe`). Marker text anywhere else is operator content:
191
+ * inside a single-line or multi-line string (basic or literal), after a
192
+ * content line as a trailing comment, inside an inline table, or on a line
193
+ * inside a multi-line array (an element, or a comment line between
194
+ * elements). Treating such text as a marker made the install replace,
195
+ * delete, or splice a fresh block into the operator's value. Every lookup
196
+ * of BEGIN, END, the source prefix, the generated header and the
197
+ * `# harness hook:` comment goes through this probe or `findMarkerLine`, and
198
+ * so does the `[[hooks.` header line that lets a generated header open a
199
+ * legacy block (`findManagedRange`).
200
+ * Two marker searches stay outside it on purpose: the split-block refusal's
201
+ * candidate list (`assertNoSplitBlock`), which can only refuse, never
202
+ * install, and the trailing comment run of `scanOwnedContentEnd`, whose
203
+ * lines are whole top-level comment lines by construction. That run (blank
204
+ * and comment lines ending the file after a block with no END marker) is
205
+ * consumed as stale harness output when the source prefix, the generated
206
+ * header or the `# harness hook:` text appears anywhere in it, so an
207
+ * operator comment there that mentions such text mid-line is removed with
208
+ * the block.
209
+ *
210
+ * The returned probe answers for line starts queried in non-decreasing
211
+ * order (the top-level probe's contract, which a line that fails the text
212
+ * test never touches).
207
213
  */
208
- function findExactLine(text, target, from) {
214
+ function createMarkerLineProbe(text) {
215
+ const topLevel = createTopLevelProbe(text);
216
+ return (lineStart, marker, shape) => {
217
+ const trimmed = text.slice(lineStart, lineEndAfter(text, lineStart)).trim();
218
+ const textMatches = shape === "whole" ? trimmed === marker : trimmed.startsWith(marker);
219
+ return textMatches && topLevel(lineStart);
220
+ };
221
+ }
222
+ /** The start of the first marker line (`createMarkerLineProbe`) at or after
223
+ * the line start `from`, or -1. Candidates are visited in order, one line
224
+ * each, so the probe's non-decreasing-offset contract holds. */
225
+ function findMarkerLine(text, marker, shape, from = 0) {
226
+ const isMarkerLine = createMarkerLineProbe(text);
209
227
  let pos = from;
210
- let delim = null;
211
- while (pos < text.length) {
212
- const lineEnd = lineEndAfter(text, pos);
213
- const rawLine = text.slice(pos, lineEnd);
214
- const startedInsideString = delim !== null;
215
- delim = nextTripleDelim(rawLine, delim);
216
- if (!startedInsideString && rawLine.trim() === target)
217
- return pos;
218
- pos = lineEnd;
228
+ for (;;) {
229
+ const idx = text.indexOf(marker, pos);
230
+ if (idx === -1)
231
+ return -1;
232
+ const lineStart = lineStartAt(text, idx);
233
+ if (isMarkerLine(lineStart, marker, shape))
234
+ return lineStart;
235
+ pos = lineEndAfter(text, lineStart);
219
236
  }
220
- return -1;
237
+ }
238
+ /** True when a line in `[from, to)` is a marker line for the prefix
239
+ * `marker`. */
240
+ function rangeHoldsMarkerLine(text, from, to, marker, isMarkerLine) {
241
+ for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
242
+ if (isMarkerLine(pos, marker, "prefix"))
243
+ return true;
244
+ }
245
+ return false;
221
246
  }
222
247
  /** Answers, for a line start in `text`, whether that line sits at the top
223
248
  * level of the document, that is, not inside a multi-line array, a multi-line
@@ -399,6 +424,7 @@ function scanOwnedContentEnd(text, start) {
399
424
  let sawOwnedContent = false;
400
425
  let sawMarkedHookTable = false;
401
426
  const classify = createLineClassifier(text);
427
+ const isMarkerLine = createMarkerLineProbe(text);
402
428
  while (pos < text.length) {
403
429
  const lineEnd = lineEndAfter(text, pos);
404
430
  const rawLine = text.slice(pos, lineEnd);
@@ -409,7 +435,7 @@ function scanOwnedContentEnd(text, start) {
409
435
  pos = lineEnd;
410
436
  continue;
411
437
  }
412
- if (rawLine.trim() === CODEX_MANAGED_END) {
438
+ if (isMarkerLine(pos, CODEX_MANAGED_END, "whole")) {
413
439
  return { end: lineEnd, sawEndMarker: true };
414
440
  }
415
441
  if (kind === "comment") {
@@ -423,7 +449,7 @@ function scanOwnedContentEnd(text, start) {
423
449
  }
424
450
  if (kind === "hookHeader") {
425
451
  const marked = commentRunStart !== null &&
426
- runHoldsHarnessHookComment(text.slice(commentRunStart, pos));
452
+ rangeHoldsMarkerLine(text, commentRunStart, pos, HARNESS_HOOK_COMMENT_PREFIX, isMarkerLine);
427
453
  if (marked) {
428
454
  sawMarkedHookTable = true;
429
455
  }
@@ -449,6 +475,11 @@ function scanOwnedContentEnd(text, start) {
449
475
  if (commentRunStart !== null && sawOwnedContent) {
450
476
  const run = text.slice(commentRunStart, pos);
451
477
  const hasComment = /(^|\n)[ \t]*#/.test(run);
478
+ // Matched as substrings on purpose, outside `createMarkerLineProbe`:
479
+ // every line of this run is a whole comment or blank line at the top
480
+ // level (a string line resets the run, and at the end of a document that
481
+ // parses nothing is still open), so marker text here is never inside a
482
+ // value, and a comment that mentions a marker is stale harness output.
452
483
  const harnessAuthored = run.includes(HARNESS_HOOK_COMMENT_PREFIX) ||
453
484
  run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
454
485
  run.includes(GENERATED_HEADER);
@@ -458,6 +489,33 @@ function scanOwnedContentEnd(text, start) {
458
489
  }
459
490
  return { end: pos, sawEndMarker: false };
460
491
  }
492
+ /**
493
+ * True when a harness-commented hook table (a `# harness hook:` comment line
494
+ * followed, later in the zone, by a `[[hooks.` header or a hook event array
495
+ * header spelled without that prefix, `[[ hooks.SessionStart ]]`) lies after the named
496
+ * table's header line, before `to`. The comment is found as a marker line
497
+ * (`createMarkerLineProbe`), so a value that merely contains the marker text
498
+ * does not count. Used only to pick the fix guidance of a refusal (task
499
+ * 36d962d2).
500
+ */
501
+ function harnessHookTableFollows(text, header, to) {
502
+ if (header === null)
503
+ return false;
504
+ let pos = lineEndAfter(text, header.start);
505
+ let sawComment = false;
506
+ const isMarkerLine = createMarkerLineProbe(text);
507
+ while (pos < to) {
508
+ const lineEnd = Math.min(lineEndAfter(text, pos), to);
509
+ const trimmed = text.slice(pos, lineEnd).trim();
510
+ if (isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
511
+ sawComment = true;
512
+ else if (sawComment && (HOOK_ARRAY_HEADER_RE.test(trimmed) || isHookEventArrayHeader(trimmed))) {
513
+ return true;
514
+ }
515
+ pos = lineEnd;
516
+ }
517
+ return false;
518
+ }
461
519
  /**
462
520
  * Refuses loudly (mirror case of the stray-END bug) when the zone between
463
521
  * `from` and `to` still contains a recognizable fragment of a harness
@@ -471,7 +529,8 @@ function scanOwnedContentEnd(text, start) {
471
529
  * `scanOwnedContentEnd`; when only blank or comment lines precede the found
472
530
  * marker in the zone -- so the pre-marker lookup finds no content line to
473
531
  * report -- the header is looked up again just past that marker's own
474
- * line, since the wedged table then sits after it, not before; printed
532
+ * line, since the wedged table then sits after it, not before, and the
533
+ * message says the marker line sits above the table (task e8f4fc03); printed
475
534
  * through `describeTableHeader`, so only the header's own bracketed key
476
535
  * path appears, never a trailing comment), and
477
536
  * the line number and marker of the first line beyond it
@@ -485,14 +544,33 @@ function scanOwnedContentEnd(text, start) {
485
544
  * document (`to` is `text.length`), telling the operator to move something
486
545
  * below a marker that does not exist would be nonsensical, so a different
487
546
  * fix (add one, or move the table below the last harness hook table) is
488
- * offered instead (task 6a037359). `blockOpening` names the line the block
547
+ * offered instead (task 6a037359). Adding one is offered only when no
548
+ * harness-commented hook table follows the named table
549
+ * (`harnessHookTableFollows`): an END placed before the named table would
550
+ * leave that later table running next to the fresh block (task 36d962d2).
551
+ * When the named table is a hook event table spelled without the `[[hooks.`
552
+ * prefix, the guidance offers deleting it if harness wrote it, since the scan
553
+ * reads such a table as foreign with or without a comment line above it
554
+ * (task e8f4fc03). The legacy paths call
555
+ * this too, so the substring matching above applies there as well: a value
556
+ * holding marker text refuses. This candidate list and the trailing comment
557
+ * run of `scanOwnedContentEnd` are the two marker searches that do not go
558
+ * through `createMarkerLineProbe`, on purpose. This one can only refuse,
559
+ * never install, so matching marker text in a value costs a refusal, never
560
+ * operator content. The comment run's lines are whole top-level comment
561
+ * lines, so marker text there is never inside a value, but that run (blank
562
+ * and comment lines ending the file after a block with no END marker) is
563
+ * consumed when the source prefix, the generated header or the
564
+ * `# harness hook:` text appears anywhere in it, so an operator comment
565
+ * there that mentions such text mid-line is removed with the block.
566
+ * `blockOpening` names the line the block
489
567
  * starts at in the delete-the-lines fix: the BEGIN marker by default; a
490
568
  * legacy config (no BEGIN marker) whose END marker survived passes its
491
569
  * source-prefix line or generated header instead, since a BEGIN marker it
492
570
  * does not have is no line to delete from (task 01053b27). For the generated
493
571
  * header the fix deletes that line and the END marker line too: a generated
494
572
  * header left alone above END no longer marks a harness block
495
- * (`findManagedRange` needs a `[[hooks.` line after it), so the install
573
+ * (`findManagedRange` needs a top-level `[[hooks.` header line after it), so the install
496
574
  * would add a fresh block and leave that pair behind as an orphan. Exported for
497
575
  * direct tests only: the scan
498
576
  * that feeds it never hands it a line that is not a real header, so its own
@@ -526,9 +604,15 @@ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blo
526
604
  // the foreign table that split the block is wedged after the marker's own
527
605
  // line instead, so look there.
528
606
  const markerLineEnd = lineEndAfter(text, zoneStart + markerOffset);
529
- const foreignHeaderLine = firstContentLine(text, zoneStart, zoneStart + markerOffset) ??
530
- firstContentLine(text, markerLineEnd, zoneEnd);
607
+ const headerBeforeMarker = firstContentLine(text, zoneStart, zoneStart + markerOffset);
608
+ const foreignHeaderLine = headerBeforeMarker ?? firstContentLine(text, markerLineEnd, zoneEnd);
531
609
  const firstForeignHeader = describeForeignHeaderLine(text, foreignHeaderLine);
610
+ // Where the marker line sits relative to the named table, so the message
611
+ // stays true when the table was found past the marker (a respelled hook
612
+ // table with its own '# harness hook:' comment line above it, say).
613
+ const placement = headerBeforeMarker === null
614
+ ? `below harness-owned content: line ${offendingLineNumber}, above it, contains '${marker}'`
615
+ : `sitting before more harness-owned content: line ${offendingLineNumber} contains '${marker}'`;
532
616
  const fixes = hasEndMarker
533
617
  ? [
534
618
  `move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker`,
@@ -538,23 +622,37 @@ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blo
538
622
  : `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
539
623
  "harness-owned tables remain between them",
540
624
  ]
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
- ];
625
+ : harnessHookTableFollows(text, foreignHeaderLine, zoneEnd)
626
+ ? // An END marker placed before the named table would leave the
627
+ // harness-commented hook table after it running next to the fresh
628
+ // block, so the only fix offered is moving the named table past it
629
+ // (task 36d962d2).
630
+ [`move ${firstForeignHeader} below the last harness hook table`]
631
+ : [
632
+ `add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
633
+ `(before ${firstForeignHeader})`,
634
+ `move ${firstForeignHeader} below the last harness hook table`,
635
+ ];
546
636
  // An uncommented hook table in the middle may be a harness table whose
547
637
  // comment line was deleted, so moving it (the operator-table fix) is only
548
638
  // right for an operator-owned one; name the restore option first (task
549
639
  // 01053b27).
550
640
  const isHookTable = HOOK_ARRAY_HEADER_RE.test(foreignHeaderLine?.line ?? "");
641
+ // A hook event table spelled another way (`[[ hooks.Stop ]]`) is read as
642
+ // foreign with or without a comment line above it, so restoring one does
643
+ // not help; deleting it does, if harness wrote it (task e8f4fc03).
644
+ const isRespelledHookTable = isHookEventArrayHeader(foreignHeaderLine?.line ?? "");
551
645
  const guidance = isHookTable
552
646
  ? `If ${firstForeignHeader} is a harness hook table whose '${HARNESS_HOOK_COMMENT_PREFIX}<id> ` +
553
647
  "(budget_ms=<n>)' comment line was deleted, restore that comment line directly above it. " +
554
648
  `If it is an operator-owned table, ${fixes.join(", or ")}`
555
- : fixes.join(", or ").replace(/^./, (c) => c.toUpperCase());
556
- throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) sitting before ` +
557
- `more harness-owned content: line ${offendingLineNumber} contains '${marker}' and still ` +
649
+ : isRespelledHookTable
650
+ ? `If ${firstForeignHeader} is a harness hook table, delete it together with any ` +
651
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it (the install ` +
652
+ "replaces only hook tables spelled '[[hooks.<Event>]]' with that comment line above " +
653
+ `them, and writes the fresh block). If it is an operator-owned table, ${fixes.join(", or ")}`
654
+ : fixes.join(", or ").replace(/^./, (c) => c.toUpperCase());
655
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) ${placement} and still ` +
558
656
  "looks like part of the harness-managed hook block; refusing to guess the block " +
559
657
  `boundaries. ${guidance}, then re-run \`harness apply --runtime codex --install\`. As a ` +
560
658
  `last resort, restore ${configPath} from the most recent .harness-backup-* file (if one exists), but ` +
@@ -638,18 +736,22 @@ function foreignSectionRootKey(headerLine) {
638
736
  * so the operator reads "a whole namespace, N tables" rather than
639
737
  * mistaking the printed header for the only table kept; exactly one table
640
738
  * under a root prints its own header. Headers print through
641
- * `describeTableHeader` (no trailing comment). */
642
- function collectForeignSectionHeaders(text, from, to) {
739
+ * `describeTableHeader` (no trailing comment). A header that is one of the
740
+ * `kept` operator hook tables (a hook event table spelled without the
741
+ * `[[hooks.` prefix, see `collectKeptHookTables`) is named there instead and
742
+ * not listed twice. */
743
+ function collectForeignSectionHeaders(text, from, to, kept = []) {
643
744
  const seenRoots = new Set();
644
745
  const headers = [];
645
746
  const counts = new Map();
747
+ const keptStarts = new Set(kept.map((t) => t.start));
646
748
  let pos = Math.max(from, 0);
647
749
  const limit = Math.max(to, from);
648
750
  const classify = createLineClassifier(text);
649
751
  while (pos < limit) {
650
752
  const lineEnd = Math.min(lineEndAfter(text, pos), limit);
651
753
  const rawLine = text.slice(pos, lineEnd);
652
- if (classify(pos, rawLine) === "foreignHeader") {
754
+ if (classify(pos, rawLine) === "foreignHeader" && !keptStarts.has(pos)) {
653
755
  const trimmed = rawLine.trim();
654
756
  const root = foreignSectionRootKey(trimmed);
655
757
  counts.set(root, (counts.get(root) ?? 0) + 1);
@@ -666,27 +768,44 @@ function collectForeignSectionHeaders(text, from, to) {
666
768
  return count > 1 ? `[${root}.*] (${count} tables)` : header;
667
769
  });
668
770
  }
669
- function extractHookIds(content) {
771
+ /** The hook ids named by the `# harness hook:` marker lines
772
+ * (`createMarkerLineProbe`) in `[from, to)` of `text`, so a value holding
773
+ * that text is never reported as a removed hook. */
774
+ function extractHookIds(text, from = 0, to = text.length) {
670
775
  const ids = new Set();
671
- for (const rawLine of content.split(/\r?\n/)) {
672
- const m = HOOK_ID_LINE_RE.exec(rawLine.trim());
776
+ const isMarkerLine = createMarkerLineProbe(text);
777
+ for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
778
+ if (!isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
779
+ continue;
780
+ const m = HOOK_ID_LINE_RE.exec(text.slice(pos, lineEndAfter(text, pos)).trim());
673
781
  if (m?.[1] !== undefined)
674
782
  ids.add(m[1]);
675
783
  }
676
784
  return ids;
677
785
  }
678
786
  /**
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
787
+ * The operator-authored hook tables directly after a managed range: every
788
+ * hook event table header from `from` up to the first non-hook table header
789
+ * or `to`, each by header, line number and offset. A hook event table is a
790
+ * `[[hooks.*]]` header, or an array-of-tables header spelled another way
791
+ * whose first key resolves to `hooks` and that names an event
792
+ * (`isHookEventArrayHeader`: `[[ hooks.Stop ]]`, `[["hooks".Stop]]`,
793
+ * `[[hooks . Stop]]`). The ownership scan reads such a respelled header as a
794
+ * foreign table, so it ends the managed range there; collected here, it is
795
+ * named and its commands are checked like any kept hook table, instead of
796
+ * being kept as a foreign section whose harness command would then run next
797
+ * to the fresh block's (task e8f4fc03). Lines are read by
683
798
  * `createLineClassifier`, so an array element on its own line inside a kept
684
799
  * 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.
800
+ * table after it is still collected (task 01053b27). Empty when the first
801
+ * header at `from` is a non-hook table, or there is none. A hook table after
802
+ * the first non-hook table is not collected: it is kept, not named and not
803
+ * checked, a deliberate boundary (only the run of hook tables directly after
804
+ * the harness tables is examined). The caller refuses a zone that holds a
805
+ * harness marker (`assertNoSplitBlock`) before it uses the tables: over the
806
+ * whole zone on the BEGIN path and when an END marker follows, over the run
807
+ * of hook tables (up to the returned `end`) on the legacy path without one,
808
+ * so no table the install keeps carries a `# harness hook:` comment.
690
809
  */
691
810
  function collectKeptHookTables(text, from, to) {
692
811
  const tables = [];
@@ -696,14 +815,15 @@ function collectKeptHookTables(text, from, to) {
696
815
  const lineEnd = Math.min(lineEndAfter(text, pos), to);
697
816
  const rawLine = text.slice(pos, lineEnd);
698
817
  const kind = classify(pos, rawLine);
699
- if (kind === "foreignHeader")
818
+ const hookTable = kind === "hookHeader" || (kind === "foreignHeader" && isHookEventArrayHeader(rawLine.trim()));
819
+ if (kind === "foreignHeader" && !hookTable)
700
820
  break;
701
- if (kind === "hookHeader") {
821
+ if (hookTable) {
702
822
  tables.push({ header: describeTableHeader(rawLine), line: lineNumberAt(text, pos), start: pos });
703
823
  }
704
824
  pos = lineEnd;
705
825
  }
706
- return tables;
826
+ return { tables, end: pos };
707
827
  }
708
828
  /** A kept table as the install names it: its header, its 1-based line
709
829
  * number in the config before the install and in the config the install
@@ -729,19 +849,19 @@ function startPastBom(text, start) {
729
849
  * `blockOpening` is the line the split-block refusal names as the start of
730
850
  * the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
731
851
  */
732
- function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
733
- const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
852
+ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFrom = end) {
853
+ const strayIdx = findMarkerLine(text, CODEX_MANAGED_END, "whole", end);
734
854
  if (strayIdx === -1) {
735
855
  assertNoSplitBlock(text, end, text.length, configPath, false);
736
- const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
737
- const keptHookTables = collectKeptHookTables(text, end, text.length);
856
+ const keptHookTables = collectKeptHookTables(text, end, text.length).tables;
857
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length, keptHookTables);
738
858
  return { start, end, foreignSectionsPreserved, keptHookTables };
739
859
  }
740
860
  const strayLineStart = lineStartAt(text, strayIdx);
741
861
  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);
862
+ assertNoSplitBlock(text, zoneFrom, strayLineStart, configPath, true, blockOpening);
863
+ const keptHookTables = collectKeptHookTables(text, end, strayLineStart).tables;
864
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart, keptHookTables);
745
865
  return {
746
866
  start,
747
867
  end,
@@ -753,39 +873,73 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
753
873
  /**
754
874
  * The range of a legacy (no BEGIN marker) config: the source-prefix or
755
875
  * 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.
876
+ * or at a foreign table with an END marker after it, the zone after it is
877
+ * handled like the BEGIN path's (`rangeBeforeStrayEnd`): an END marker that
878
+ * survived its deleted BEGIN line, sitting after the kept tables or the
879
+ * foreign table, is excised like a drifted END, so the install leaves no
880
+ * orphan END line and the kept-table refusal offers moving an operator-owned
881
+ * table below that END; a harness-commented table after the kept tables or
882
+ * the foreign table refuses through the split-block check instead of being
883
+ * kept as if it were operator content (the fresh block would repeat it)
884
+ * when it sits before that END, or, after kept tables, anywhere later when
885
+ * no END follows (task e8f4fc03 extends the END case to a foreign table).
886
+ * Content below that END is untouched, as on the BEGIN path, so a
887
+ * harness-commented table there is kept and not named. The split-block
888
+ * refusal names `blockOpening` (the source-prefix line or the generated
889
+ * header) as the start of the block, since there is no BEGIN marker (task
890
+ * 01053b27); the split-block zone starts after that opening line, so a block
891
+ * with no hook table in it (the source prefix directly above an operator
892
+ * table) is not refused for its own opening line (task 36d962d2). The opening
893
+ * line is a marker line (`createMarkerLineProbe`), so marker text inside a
894
+ * value or after a content line opens no block.
895
+ * A scan that ended at a foreign table with no END marker anywhere after it
896
+ * keeps the legacy behaviour (no split-block check), except that hook event tables directly after the block are collected, named and
897
+ * checked (`collectKeptHookTables`), and a harness marker in their run
898
+ * refuses through the split-block check. The END marker is looked up as a
899
+ * marker line too, so END text in a foreign table's value does not route a
900
+ * config here. Like the BEGIN path,
901
+ * the split-block check matches its markers as substrings, so a value in the
902
+ * zone that holds marker text refuses too (see `assertNoSplitBlock`).
769
903
  */
770
904
  function legacyManagedRange(text, start, scan, configPath, blockOpening) {
771
- if (scan.endedAtOperatorHookTable) {
772
- return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening);
905
+ if (scan.sawEndMarker) {
906
+ return { start, end: scan.end, foreignSectionsPreserved: [], keptHookTables: [] };
773
907
  }
774
- const foreignSectionsPreserved = scan.sawEndMarker
775
- ? []
776
- : collectForeignSectionHeaders(text, scan.end, text.length);
777
- return { start, end: scan.end, foreignSectionsPreserved, keptHookTables: [] };
908
+ // The split-block zone starts after the block's own opening line: when the
909
+ // scan owned nothing (the generated header or a foreign table directly
910
+ // follows the source prefix), `scan.end` is that opening line itself, which
911
+ // is a split-block marker and would refuse a block with nothing in it
912
+ // (task 36d962d2).
913
+ const zoneFrom = Math.max(scan.end, lineEndAfter(text, start));
914
+ if (scan.endedAtOperatorHookTable || findMarkerLine(text, CODEX_MANAGED_END, "whole", scan.end) !== -1) {
915
+ return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening, zoneFrom);
916
+ }
917
+ const run = collectKeptHookTables(text, scan.end, text.length);
918
+ // The scan ended at a hook table spelled without the `[[hooks.` prefix: a
919
+ // harness marker in the run of hook tables after it (a harness-commented
920
+ // table, or the respelled table's own comment line) is harness content the
921
+ // scan could not read, so refuse through the split-block check, as the
922
+ // BEGIN path does, instead of naming a table with that comment line above it
923
+ // as one without (task e8f4fc03). The run stops at the first non-hook table:
924
+ // past it the legacy reading below stays unchanged.
925
+ if (run.tables.length > 0)
926
+ assertNoSplitBlock(text, zoneFrom, run.end, configPath, false);
927
+ const keptHookTables = run.tables;
928
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, scan.end, text.length, keptHookTables);
929
+ return { start, end: scan.end, foreignSectionsPreserved, keptHookTables };
778
930
  }
779
931
  function findManagedRange(text, configPath) {
780
- const begin = text.indexOf(CODEX_MANAGED_BEGIN);
932
+ // BEGIN is found as a marker line (`createMarkerLineProbe`): BEGIN text in
933
+ // an operator value (a string, a trailing comment) is neither the block nor
934
+ // a second marker, so the install neither deletes operator content after
935
+ // it nor splices the fresh block into the value (task 36d962d2).
936
+ const begin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix");
781
937
  if (begin !== -1) {
782
- const secondBegin = text.indexOf(CODEX_MANAGED_BEGIN, begin + CODEX_MANAGED_BEGIN.length);
938
+ const secondBegin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix", lineEndAfter(text, begin));
783
939
  if (secondBegin !== -1) {
784
940
  // Names the second marker's line number only, never that line's own
785
- // text: the marker is found by a substring search, so the line may be
786
- // foreign content (a value holding a token, say) that merely contains
787
- // the marker text, and this refusal is printed to stderr and `--json`.
788
- const secondBeginLineNumber = lineNumberAt(text, lineStartAt(text, secondBegin));
941
+ // text, since this refusal is printed to stderr and `--json`.
942
+ const secondBeginLineNumber = lineNumberAt(text, secondBegin);
789
943
  throw new CodexInstallRefusalError(`Codex config ${configPath} has more than one '${CODEX_MANAGED_BEGIN}' marker ` +
790
944
  `(a second one at line ${secondBeginLineNumber}); refusing to ` +
791
945
  "guess which one is current. Delete the entire unwanted BEGIN/END pair -- including " +
@@ -796,14 +950,14 @@ function findManagedRange(text, configPath) {
796
950
  "but that discards any Codex state (hook trust entries, marketplaces, plugins) " +
797
951
  "written to the file since that backup.", configPath);
798
952
  }
799
- let start = lineStartAt(text, begin);
953
+ let start = begin;
800
954
  const prev = previousLine(text, 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;
955
+ // Only a marker line is the block's source prefix: the closing line of a
956
+ // multi-line string that ends with the prefix text is the operator's
957
+ // value, not part of the block (task 36d962d2). Trimming also drops a BOM
958
+ // sitting before the source-prefix comment on line 1 (task b34ed105).
959
+ if (prev && createMarkerLineProbe(text)(prev.start, CODEX_MANAGED_SOURCE_PREFIX, "prefix")) {
960
+ start = prev.start;
807
961
  }
808
962
  start = startPastBom(text, start);
809
963
  const contentStart = lineEndAfter(text, begin);
@@ -814,19 +968,24 @@ function findManagedRange(text, configPath) {
814
968
  }
815
969
  return rangeBeforeStrayEnd(text, start, end, configPath);
816
970
  }
817
- const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
971
+ const source = findMarkerLine(text, CODEX_MANAGED_SOURCE_PREFIX, "prefix");
818
972
  if (source !== -1) {
819
- const start = startPastBom(text, lineStartAt(text, source));
973
+ const start = startPastBom(text, source);
820
974
  return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
821
975
  }
822
- const generated = text.indexOf(GENERATED_HEADER);
976
+ const generated = findMarkerLine(text, GENERATED_HEADER, "prefix");
823
977
  if (generated !== -1) {
824
- const start = startPastBom(text, lineStartAt(text, generated));
978
+ const start = startPastBom(text, generated);
825
979
  const scan = scanOwnedContentEnd(text, start);
826
- const end = scan.end;
827
- const candidate = text.slice(start, end);
828
- if (candidate.includes("[[hooks.") ||
829
- candidate.includes("[[hooks.pre_tool_use]]")) {
980
+ // The generated header opens a legacy block only when a hook table header
981
+ // line (`[[hooks.`, the older `[[hooks.pre_tool_use]]` included) follows
982
+ // it inside the scanned range, found by the same top-level line rule as
983
+ // the markers: `[[hooks.` text in a value, a multi-line string line or a
984
+ // comment under an operator table is operator content, and taking it for
985
+ // a header made the install replace the operator's keys after the header
986
+ // line with the fresh block (task 36d962d2).
987
+ const hookHeader = findMarkerLine(text, "[[hooks.", "prefix", start);
988
+ if (hookHeader !== -1 && hookHeader < scan.end) {
830
989
  return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
831
990
  }
832
991
  }
@@ -1189,6 +1348,17 @@ function resolvedKeyName(segment) {
1189
1348
  function hooksRooted(trimmed) {
1190
1349
  return resolvedKeyName(parseTableHeader(trimmed)?.keys[0] ?? "") === "hooks";
1191
1350
  }
1351
+ /** Whether a header line is an array-of-tables header that appends to a
1352
+ * hook event array or below one (`[[hooks.Stop]]`, `[[ hooks.Stop ]]`,
1353
+ * `[["hooks".Stop]]`, `[[hooks . Stop.hooks]]`): double brackets, a first key
1354
+ * that resolves to `hooks` (`hooksRooted`) and at least an event key after
1355
+ * it. A one-key line (`[["hooks"]]`, which may also be an array element on
1356
+ * its own line) is not one. Used by `collectKeptHookTables` for the
1357
+ * spellings the `[[hooks.` prefix does not catch (task e8f4fc03). */
1358
+ function isHookEventArrayHeader(trimmed) {
1359
+ const parsed = parseTableHeader(trimmed);
1360
+ return (parsed !== null && parsed.header.startsWith("[[") && parsed.keys.length >= 2 && hooksRooted(trimmed));
1361
+ }
1192
1362
  function subKey(event, index) {
1193
1363
  return `${event}\u0000${index}`;
1194
1364
  }
@@ -1210,11 +1380,12 @@ function subKey(event, index) {
1210
1380
  * scan resolves has the root `hooks` (a `[[hooks.` line has it by its
1211
1381
  * prefix), a table outside `hooks` never shifts the order match, and an
1212
1382
  * 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.
1383
+ * scan, unless the element line reads as a header whose first key is
1384
+ * `hooks` (`["hooks"]`, `['hooks']`, `[["hooks"]]`, `[[ 'hooks' ]]`): that
1385
+ * line reads as a `hooks` root, so it is probed like the `hooks`-rooted
1386
+ * spellings that `[[hooks.` does not catch (`[[ hooks.Stop ]]`,
1387
+ * `[["hooks".Stop]]`, `[hooks.Stop]`), and the probe finds it inside the
1388
+ * array.
1218
1389
  */
1219
1390
  function mapHookArrayHeaders(text) {
1220
1391
  const map = { slots: new Map(), eventCounts: new Map(), subCounts: new Map() };
@@ -1375,14 +1546,22 @@ function assertKeptHookTablesAreOperatorOwned(kept, currentContent, generatedCon
1375
1546
  const harnessShaped = commands.some((command) => freshCommands.has(command) || command.startsWith("harness "));
1376
1547
  if (!harnessShaped)
1377
1548
  continue;
1549
+ // harness writes every hook table as `[[hooks.<Event>]]`; a table spelled
1550
+ // another way (`[[ hooks.Stop ]]`) never was one, and with the comment
1551
+ // line restored above it the scan would still read it as a foreign
1552
+ // table, so the restore fix is offered only for the harness spelling
1553
+ // (task e8f4fc03).
1554
+ const fix = HOOK_ARRAY_HEADER_RE.test(table.header)
1555
+ ? `Restore the comment line directly above ${table.header}, so the install replaces the table ` +
1556
+ `with the fresh block${moveBelowEndOption(hasEndMarker)}, or delete the table`
1557
+ : "Delete the table (the install replaces only hook tables spelled '[[hooks.<Event>]]' with " +
1558
+ `that comment line above them)${moveBelowEndOption(hasEndMarker)}`;
1378
1559
  throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
1379
1560
  `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
1380
1561
  "harness hook tables, whose command is one harness itself writes. harness cannot tell it " +
1381
1562
  "from a harness hook table whose comment line was deleted, and keeping it next to the " +
1382
1563
  "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);
1564
+ `${fix}, then re-run \`harness apply --runtime codex --install\`. The file is untouched.`, configPath);
1386
1565
  }
1387
1566
  }
1388
1567
  export function planCodexConfigInstall(opts) {
@@ -1412,7 +1591,7 @@ export function planCodexConfigInstall(opts) {
1412
1591
  let keptTableDescriptions = [];
1413
1592
  let hasEndMarkerAfterKept = false;
1414
1593
  if (range) {
1415
- const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
1594
+ const oldHookIds = extractHookIds(currentContent, range.start, range.end);
1416
1595
  const newHookIds = extractHookIds(managedBlock);
1417
1596
  removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
1418
1597
  keptHookTables = range.keptHookTables;