@lannguyensi/harness 0.60.0 → 0.62.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 (89) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/dist/cli/apply/install-codex-config.d.ts +24 -7
  4. package/dist/cli/apply/install-codex-config.js +353 -81
  5. package/dist/cli/apply/install-codex-config.js.map +1 -1
  6. package/dist/cli/approve/understanding.d.ts +31 -6
  7. package/dist/cli/approve/understanding.js +197 -56
  8. package/dist/cli/approve/understanding.js.map +1 -1
  9. package/dist/cli/bounded-stdin.d.ts +40 -0
  10. package/dist/cli/bounded-stdin.js +111 -0
  11. package/dist/cli/bounded-stdin.js.map +1 -0
  12. package/dist/cli/doctor/settings-drift.js +12 -2
  13. package/dist/cli/doctor/settings-drift.js.map +1 -1
  14. package/dist/cli/gc/index.d.ts +6 -2
  15. package/dist/cli/gc/index.js +22 -7
  16. package/dist/cli/gc/index.js.map +1 -1
  17. package/dist/cli/index.js +15 -14
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/pack/auto-approve-path.js +39 -4
  20. package/dist/cli/pack/auto-approve-path.js.map +1 -1
  21. package/dist/cli/pack/hook-bootstrap.d.ts +59 -5
  22. package/dist/cli/pack/hook-bootstrap.js +67 -19
  23. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  24. package/dist/cli/pack/hook-branch-protection.d.ts +6 -0
  25. package/dist/cli/pack/hook-branch-protection.js +11 -2
  26. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  27. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +6 -0
  28. package/dist/cli/pack/hook-codex-pre-tool-use.js +20 -2
  29. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  30. package/dist/cli/pack/hook-post-merge-gate.d.ts +6 -0
  31. package/dist/cli/pack/hook-post-merge-gate.js +17 -2
  32. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  33. package/dist/cli/pack/hook-pre-tool-use.d.ts +7 -0
  34. package/dist/cli/pack/hook-pre-tool-use.js +26 -4
  35. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  36. package/dist/cli/pack/hook-runtime-reality.d.ts +2 -0
  37. package/dist/cli/pack/hook-runtime-reality.js +48 -8
  38. package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
  39. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +6 -0
  40. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +11 -2
  41. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  42. package/dist/cli/pack/hook-solution-acceptance.d.ts +6 -0
  43. package/dist/cli/pack/hook-solution-acceptance.js +11 -2
  44. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  45. package/dist/cli/pack/hook-subagent-start.d.ts +2 -0
  46. package/dist/cli/pack/hook-subagent-start.js +21 -4
  47. package/dist/cli/pack/hook-subagent-start.js.map +1 -1
  48. package/dist/cli/policy/intercept.d.ts +2 -0
  49. package/dist/cli/policy/intercept.js +57 -21
  50. package/dist/cli/policy/intercept.js.map +1 -1
  51. package/dist/cli/record/index.js +97 -21
  52. package/dist/cli/record/index.js.map +1 -1
  53. package/dist/cli/session-start/branch-check.js +4 -1
  54. package/dist/cli/session-start/branch-check.js.map +1 -1
  55. package/dist/cli/session-start/index.d.ts +0 -15
  56. package/dist/cli/session-start/index.js +4 -62
  57. package/dist/cli/session-start/index.js.map +1 -1
  58. package/dist/cli/session-start/shared-options.d.ts +15 -4
  59. package/dist/cli/session-start/shared-options.js +13 -15
  60. package/dist/cli/session-start/shared-options.js.map +1 -1
  61. package/dist/cli/session-start/stale-base-check.js +4 -1
  62. package/dist/cli/session-start/stale-base-check.js.map +1 -1
  63. package/dist/cli/session-start/toolchain-parity.js +4 -1
  64. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  65. package/dist/io/atomic-write.d.ts +37 -1
  66. package/dist/io/atomic-write.js +61 -8
  67. package/dist/io/atomic-write.js.map +1 -1
  68. package/dist/io/display-path.d.ts +11 -0
  69. package/dist/io/display-path.js +29 -0
  70. package/dist/io/display-path.js.map +1 -0
  71. package/dist/policy-packs/builtin/post-merge-gate.js +5 -2
  72. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  73. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  74. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  75. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  76. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +127 -16
  77. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +192 -45
  78. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  79. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +3 -2
  80. package/dist/policy-packs/builtin/understanding-before-execution.js +4 -1
  81. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  82. package/dist/runtime/deletion-target-resolve.js +24 -1
  83. package/dist/runtime/deletion-target-resolve.js.map +1 -1
  84. package/dist/runtime/destructive-shell-floor.js +15 -1
  85. package/dist/runtime/destructive-shell-floor.js.map +1 -1
  86. package/dist/runtime/shell-word.d.ts +26 -0
  87. package/dist/runtime/shell-word.js +94 -7
  88. package/dist/runtime/shell-word.js.map +1 -1
  89. 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,120 @@ 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` (or
198
+ * `findMarkerAboveHookTable`, which calls the probe for its one candidate),
199
+ * and so does the `[[hooks.` header line that lets a generated header open a
200
+ * legacy block (`findManagedRange`), searched only up to the end of the range
201
+ * the scan owns.
202
+ * Two marker searches stay outside it on purpose: the split-block refusal's
203
+ * candidate list (`assertNoSplitBlock`), which can only refuse, never
204
+ * install, and the trailing comment run of `scanOwnedContentEnd`, whose
205
+ * lines are whole top-level comment lines by construction. That run (blank
206
+ * and comment lines ending the file after a block with no END marker) is
207
+ * consumed as stale harness output when the source prefix, the generated
208
+ * header or the `# harness hook:` text appears anywhere in it, so an
209
+ * operator comment there that mentions such text mid-line is removed with
210
+ * the block.
211
+ *
212
+ * The returned probe answers for line starts queried in non-decreasing
213
+ * order (the top-level probe's contract, which a line that fails the text
214
+ * test never touches).
207
215
  */
208
- function findExactLine(text, target, from) {
216
+ function createMarkerLineProbe(text) {
217
+ const topLevel = createTopLevelProbe(text);
218
+ return (lineStart, marker, shape) => {
219
+ const trimmed = text.slice(lineStart, lineEndAfter(text, lineStart)).trim();
220
+ const textMatches = shape === "whole" ? trimmed === marker : trimmed.startsWith(marker);
221
+ return textMatches && topLevel(lineStart);
222
+ };
223
+ }
224
+ /** The start of the first marker line (`createMarkerLineProbe`) at or after
225
+ * the line start `from` and before `to`, or -1. Candidates are visited in
226
+ * order, one line each, so the probe's non-decreasing-offset contract holds.
227
+ * Every candidate that fails the text test is free, but one that passes it
228
+ * costs a parse of the document before it (`createTopLevelProbe`), so a caller
229
+ * that only needs a marker inside a known range passes that range's end as
230
+ * `to`: the search stops there instead of probing every candidate past it. */
231
+ function findMarkerLine(text, marker, shape, from = 0, to = text.length) {
232
+ const isMarkerLine = createMarkerLineProbe(text);
209
233
  let pos = from;
234
+ for (;;) {
235
+ const idx = text.indexOf(marker, pos);
236
+ if (idx === -1)
237
+ return -1;
238
+ const lineStart = lineStartAt(text, idx);
239
+ if (lineStart >= to)
240
+ return -1;
241
+ if (isMarkerLine(lineStart, marker, shape))
242
+ return lineStart;
243
+ pos = lineEndAfter(text, lineStart);
244
+ }
245
+ }
246
+ /**
247
+ * The marker line (`createMarkerLineProbe`, prefix shape) that sits in the run
248
+ * of blank and comment lines directly above a hook table header, searched from
249
+ * the line start `from` (a line at which nothing is open) to the end of the
250
+ * document, with the header's offset; null when there is none. `isHookTable`
251
+ * says which header lines count as a hook table. This is how the two orphan
252
+ * shapes are found: a generated-header line above a hook table past a foreign
253
+ * table, and a `# harness hook:` comment line above a hook table with no block
254
+ * marker around it. The scan is linear and parses nothing for a line that is
255
+ * not such a pair: a line inside a multi-line string is skipped
256
+ * (`nextTripleDelim`), a content line ends the run, and only a marker-text
257
+ * line directly above a header reaches the probe, once, which confirms the
258
+ * line is at the top level. A comment run directly above a hook table header
259
+ * is at the top level in any config that parses (a header line is never inside
260
+ * an array), so the probe only turns away a config that does not parse, which
261
+ * the parse checks refuse anyway.
262
+ */
263
+ function findMarkerAboveHookTable(text, marker, from, isHookTable) {
264
+ const isMarkerLine = createMarkerLineProbe(text);
210
265
  let delim = null;
266
+ let runMarker = null;
267
+ let pos = from;
211
268
  while (pos < text.length) {
212
269
  const lineEnd = lineEndAfter(text, pos);
213
270
  const rawLine = text.slice(pos, lineEnd);
214
271
  const startedInsideString = delim !== null;
215
272
  delim = nextTripleDelim(rawLine, delim);
216
- if (!startedInsideString && rawLine.trim() === target)
217
- return pos;
273
+ if (startedInsideString) {
274
+ runMarker = null;
275
+ pos = lineEnd;
276
+ continue;
277
+ }
278
+ const trimmed = rawLine.trim();
279
+ if (trimmed === "" || trimmed.startsWith("#")) {
280
+ if (runMarker === null && trimmed.startsWith(marker))
281
+ runMarker = pos;
282
+ }
283
+ else {
284
+ if (runMarker !== null && isHookTable(trimmed) && isMarkerLine(runMarker, marker, "prefix")) {
285
+ return { marker: runMarker, header: pos };
286
+ }
287
+ runMarker = null;
288
+ }
218
289
  pos = lineEnd;
219
290
  }
220
- return -1;
291
+ return null;
292
+ }
293
+ /** True when a line in `[from, to)` is a marker line for the prefix
294
+ * `marker`. */
295
+ function rangeHoldsMarkerLine(text, from, to, marker, isMarkerLine) {
296
+ for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
297
+ if (isMarkerLine(pos, marker, "prefix"))
298
+ return true;
299
+ }
300
+ return false;
221
301
  }
222
302
  /** Answers, for a line start in `text`, whether that line sits at the top
223
303
  * level of the document, that is, not inside a multi-line array, a multi-line
@@ -398,7 +478,15 @@ function scanOwnedContentEnd(text, start) {
398
478
  let commentRunStart = null;
399
479
  let sawOwnedContent = false;
400
480
  let sawMarkedHookTable = false;
481
+ let firstContentLine = null;
482
+ let firstHookHeader = null;
483
+ // A key line before the first hook table header of the range, with that
484
+ // header, when both exist: see `OwnedContentScan.operatorLineBeforeHookTable`.
485
+ const keyBeforeHookTable = () => firstContentLine !== null && firstHookHeader !== null
486
+ ? { operatorLineBeforeHookTable: { line: firstContentLine, header: firstHookHeader } }
487
+ : {};
401
488
  const classify = createLineClassifier(text);
489
+ const isMarkerLine = createMarkerLineProbe(text);
402
490
  while (pos < text.length) {
403
491
  const lineEnd = lineEndAfter(text, pos);
404
492
  const rawLine = text.slice(pos, lineEnd);
@@ -409,8 +497,8 @@ function scanOwnedContentEnd(text, start) {
409
497
  pos = lineEnd;
410
498
  continue;
411
499
  }
412
- if (rawLine.trim() === CODEX_MANAGED_END) {
413
- return { end: lineEnd, sawEndMarker: true };
500
+ if (isMarkerLine(pos, CODEX_MANAGED_END, "whole")) {
501
+ return { end: lineEnd, sawEndMarker: true, ...keyBeforeHookTable() };
414
502
  }
415
503
  if (kind === "comment") {
416
504
  if (commentRunStart === null)
@@ -419,11 +507,12 @@ function scanOwnedContentEnd(text, start) {
419
507
  continue;
420
508
  }
421
509
  if (kind === "foreignHeader") {
422
- return { end: commentRunStart ?? pos, sawEndMarker: false };
510
+ return { end: commentRunStart ?? pos, sawEndMarker: false, ...keyBeforeHookTable() };
423
511
  }
424
512
  if (kind === "hookHeader") {
513
+ firstHookHeader ??= pos;
425
514
  const marked = commentRunStart !== null &&
426
- runHoldsHarnessHookComment(text.slice(commentRunStart, pos));
515
+ rangeHoldsMarkerLine(text, commentRunStart, pos, HARNESS_HOOK_COMMENT_PREFIX, isMarkerLine);
427
516
  if (marked) {
428
517
  sawMarkedHookTable = true;
429
518
  }
@@ -431,9 +520,17 @@ function scanOwnedContentEnd(text, start) {
431
520
  // An operator-authored hook table (no `# harness hook:` comment
432
521
  // above it) after the harness tables ends the owned region, like a
433
522
  // foreign table, and keeps a comment attached to it (task 01053b27).
434
- return { end: commentRunStart ?? pos, sawEndMarker: false, endedAtOperatorHookTable: true };
523
+ return {
524
+ end: commentRunStart ?? pos,
525
+ sawEndMarker: false,
526
+ endedAtOperatorHookTable: true,
527
+ ...keyBeforeHookTable(),
528
+ };
435
529
  }
436
530
  }
531
+ else if (firstHookHeader === null) {
532
+ firstContentLine ??= pos;
533
+ }
437
534
  commentRunStart = null;
438
535
  sawOwnedContent = true;
439
536
  pos = lineEnd;
@@ -449,14 +546,46 @@ function scanOwnedContentEnd(text, start) {
449
546
  if (commentRunStart !== null && sawOwnedContent) {
450
547
  const run = text.slice(commentRunStart, pos);
451
548
  const hasComment = /(^|\n)[ \t]*#/.test(run);
549
+ // Matched as substrings on purpose, outside `createMarkerLineProbe`:
550
+ // every line of this run is a whole comment or blank line at the top
551
+ // level (a string line resets the run, and at the end of a document that
552
+ // parses nothing is still open), so marker text here is never inside a
553
+ // value, and a comment that mentions a marker is stale harness output.
452
554
  const harnessAuthored = run.includes(HARNESS_HOOK_COMMENT_PREFIX) ||
453
555
  run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
454
556
  run.includes(GENERATED_HEADER);
455
557
  if (hasComment && !harnessAuthored) {
456
- return { end: commentRunStart, sawEndMarker: false };
558
+ return { end: commentRunStart, sawEndMarker: false, ...keyBeforeHookTable() };
559
+ }
560
+ }
561
+ return { end: pos, sawEndMarker: false, ...keyBeforeHookTable() };
562
+ }
563
+ /**
564
+ * True when a harness-commented hook table (a `# harness hook:` comment line
565
+ * followed, later in the zone, by a `[[hooks.` header or a hook event array
566
+ * header spelled without that prefix, `[[ hooks.SessionStart ]]`) lies after the named
567
+ * table's header line, before `to`. The comment is found as a marker line
568
+ * (`createMarkerLineProbe`), so a value that merely contains the marker text
569
+ * does not count. Used only to pick the fix guidance of a refusal (task
570
+ * 36d962d2).
571
+ */
572
+ function harnessHookTableFollows(text, header, to) {
573
+ if (header === null)
574
+ return false;
575
+ let pos = lineEndAfter(text, header.start);
576
+ let sawComment = false;
577
+ const isMarkerLine = createMarkerLineProbe(text);
578
+ while (pos < to) {
579
+ const lineEnd = Math.min(lineEndAfter(text, pos), to);
580
+ const trimmed = text.slice(pos, lineEnd).trim();
581
+ if (isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
582
+ sawComment = true;
583
+ else if (sawComment && (HOOK_ARRAY_HEADER_RE.test(trimmed) || isHookEventArrayHeader(trimmed))) {
584
+ return true;
457
585
  }
586
+ pos = lineEnd;
458
587
  }
459
- return { end: pos, sawEndMarker: false };
588
+ return false;
460
589
  }
461
590
  /**
462
591
  * Refuses loudly (mirror case of the stray-END bug) when the zone between
@@ -485,20 +614,37 @@ function scanOwnedContentEnd(text, start) {
485
614
  * point at that marker; when there is no END marker anywhere in the
486
615
  * document (`to` is `text.length`), telling the operator to move something
487
616
  * below a marker that does not exist would be nonsensical, so a different
488
- * fix (add one, or move the table below the last harness hook table) is
489
- * offered instead (task 6a037359). When the named table is a hook event
490
- * table spelled without the `[[hooks.` prefix, the guidance offers deleting
491
- * it if harness wrote it, since the scan reads such a table as foreign with
492
- * or without a comment line above it (task e8f4fc03). The legacy paths call
617
+ * fix is offered instead (task 6a037359): adding one right after the last
618
+ * harness hook table, unless a harness-commented hook table follows the named
619
+ * table (`harnessHookTableFollows`), since an END placed before the named table
620
+ * would leave that later table running next to the fresh block, and then only
621
+ * moving the named table below the last harness hook table is offered (task
622
+ * 36d962d2). With no such table after it the named table already sits below
623
+ * the last harness hook table, so that move is never offered next to the END
624
+ * fix: it would change nothing and bring the same refusal back.
625
+ * When the named table is a hook event table spelled without the `[[hooks.`
626
+ * prefix, the guidance offers deleting it if harness wrote it, since the scan
627
+ * reads such a table as foreign with or without a comment line above it
628
+ * (task e8f4fc03). The legacy paths call
493
629
  * this too, so the substring matching above applies there as well: a value
494
- * holding marker text refuses. `blockOpening` names the line the block
630
+ * holding marker text refuses. This candidate list and the trailing comment
631
+ * run of `scanOwnedContentEnd` are the two marker searches that do not go
632
+ * through `createMarkerLineProbe`, on purpose. This one can only refuse,
633
+ * never install, so matching marker text in a value costs a refusal, never
634
+ * operator content. The comment run's lines are whole top-level comment
635
+ * lines, so marker text there is never inside a value, but that run (blank
636
+ * and comment lines ending the file after a block with no END marker) is
637
+ * consumed when the source prefix, the generated header or the
638
+ * `# harness hook:` text appears anywhere in it, so an operator comment
639
+ * there that mentions such text mid-line is removed with the block.
640
+ * `blockOpening` names the line the block
495
641
  * starts at in the delete-the-lines fix: the BEGIN marker by default; a
496
642
  * legacy config (no BEGIN marker) whose END marker survived passes its
497
643
  * source-prefix line or generated header instead, since a BEGIN marker it
498
644
  * does not have is no line to delete from (task 01053b27). For the generated
499
645
  * header the fix deletes that line and the END marker line too: a generated
500
646
  * header left alone above END no longer marks a harness block
501
- * (`findManagedRange` needs a `[[hooks.` line after it), so the install
647
+ * (`findManagedRange` needs a top-level `[[hooks.` header line after it), so the install
502
648
  * would add a fresh block and leave that pair behind as an orphan. Exported for
503
649
  * direct tests only: the scan
504
650
  * that feeds it never hands it a line that is not a real header, so its own
@@ -550,11 +696,20 @@ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blo
550
696
  : `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
551
697
  "harness-owned tables remain between them",
552
698
  ]
553
- : [
554
- `add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
555
- `(before ${firstForeignHeader})`,
556
- `move ${firstForeignHeader} below the last harness hook table`,
557
- ];
699
+ : harnessHookTableFollows(text, foreignHeaderLine, zoneEnd)
700
+ ? // An END marker placed before the named table would leave the
701
+ // harness-commented hook table after it running next to the fresh
702
+ // block, so the only fix offered is moving the named table past it
703
+ // (task 36d962d2).
704
+ [`move ${firstForeignHeader} below the last harness hook table`]
705
+ : // No harness-commented hook table follows the named table, so it
706
+ // already sits below the last harness hook table and moving it there
707
+ // changes nothing (the same refusal would come back): only the END
708
+ // marker is offered.
709
+ [
710
+ `add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
711
+ `(before ${firstForeignHeader})`,
712
+ ];
558
713
  // An uncommented hook table in the middle may be a harness table whose
559
714
  // comment line was deleted, so moving it (the operator-table fix) is only
560
715
  // right for an operator-owned one; name the restore option first (task
@@ -690,10 +845,16 @@ function collectForeignSectionHeaders(text, from, to, kept = []) {
690
845
  return count > 1 ? `[${root}.*] (${count} tables)` : header;
691
846
  });
692
847
  }
693
- function extractHookIds(content) {
848
+ /** The hook ids named by the `# harness hook:` marker lines
849
+ * (`createMarkerLineProbe`) in `[from, to)` of `text`, so a value holding
850
+ * that text is never reported as a removed hook. */
851
+ function extractHookIds(text, from = 0, to = text.length) {
694
852
  const ids = new Set();
695
- for (const rawLine of content.split(/\r?\n/)) {
696
- const m = HOOK_ID_LINE_RE.exec(rawLine.trim());
853
+ const isMarkerLine = createMarkerLineProbe(text);
854
+ for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
855
+ if (!isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
856
+ continue;
857
+ const m = HOOK_ID_LINE_RE.exec(text.slice(pos, lineEndAfter(text, pos)).trim());
697
858
  if (m?.[1] !== undefined)
698
859
  ids.add(m[1]);
699
860
  }
@@ -765,8 +926,8 @@ function startPastBom(text, start) {
765
926
  * `blockOpening` is the line the split-block refusal names as the start of
766
927
  * the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
767
928
  */
768
- function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
769
- const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
929
+ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFrom = end) {
930
+ const strayIdx = findMarkerLine(text, CODEX_MANAGED_END, "whole", end);
770
931
  if (strayIdx === -1) {
771
932
  assertNoSplitBlock(text, end, text.length, configPath, false);
772
933
  const keptHookTables = collectKeptHookTables(text, end, text.length).tables;
@@ -775,7 +936,7 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
775
936
  }
776
937
  const strayLineStart = lineStartAt(text, strayIdx);
777
938
  const strayLineEnd = lineEndAfter(text, strayIdx);
778
- assertNoSplitBlock(text, end, strayLineStart, configPath, true, blockOpening);
939
+ assertNoSplitBlock(text, zoneFrom, strayLineStart, configPath, true, blockOpening);
779
940
  const keptHookTables = collectKeptHookTables(text, end, strayLineStart).tables;
780
941
  const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart, keptHookTables);
781
942
  return {
@@ -786,6 +947,25 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
786
947
  keptHookTables,
787
948
  };
788
949
  }
950
+ /** Refuses operator key/value content before the first hook table on every
951
+ * locator path. The ownership scan supplies offsets; diagnostics print no values. */
952
+ function assertNoOperatorLineBeforeHookTable(text, start, scan, configPath, blockOpening) {
953
+ if (scan.operatorLineBeforeHookTable !== undefined) {
954
+ const { line, header } = scan.operatorLineBeforeHookTable;
955
+ // Names line numbers only, never the line's own text: it is a key or a
956
+ // value, which can hold a token, and the refusal is printed to stderr and
957
+ // `--json`.
958
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a key or value line (line ${lineNumberAt(text, line)}) between ` +
959
+ `${blockOpening} (line ${lineNumberAt(text, start)}) and the first hook table below it (line ` +
960
+ `${lineNumberAt(text, header)}). A block harness wrote has only comment lines there, so that ` +
961
+ "line is yours, a key of whatever table is open where the opening line sits (an operator " +
962
+ "hook table, say), and the install, which replaces everything from the opening line " +
963
+ "through the harness hook tables, would delete it; refusing to guess. Move that line (with " +
964
+ "any value lines it continues) above the opening line, so it stays in the table it " +
965
+ "belongs to, then re-run " +
966
+ "`harness apply --runtime codex --install`. The file is untouched.", configPath);
967
+ }
968
+ }
789
969
  /**
790
970
  * The range of a legacy (no BEGIN marker) config: the source-prefix or
791
971
  * generated-header paths. When the scan ended at an operator hook table,
@@ -799,26 +979,37 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
799
979
  * kept as if it were operator content (the fresh block would repeat it)
800
980
  * when it sits before that END, or, after kept tables, anywhere later when
801
981
  * no END follows (task e8f4fc03 extends the END case to a foreign table).
802
- * Content below that END is untouched, as on the BEGIN path, so a
803
- * harness-commented table there is kept and not named. The split-block
982
+ * Content below that END is kept, as on the BEGIN path; the global orphan
983
+ * check refuses a harness-commented hook table outside the owned range. The split-block
804
984
  * refusal names `blockOpening` (the source-prefix line or the generated
805
985
  * header) as the start of the block, since there is no BEGIN marker (task
806
- * 01053b27). A scan that ended at a foreign table with no END marker
807
- * anywhere after it keeps the legacy behaviour (no split-block check), except
808
- * that hook event tables directly after the block are collected, named and
986
+ * 01053b27); the split-block zone starts after that opening line, so a block
987
+ * with no hook table in it (the source prefix directly above an operator
988
+ * table) is not refused for its own opening line (task 36d962d2). The opening
989
+ * line is a marker line (`createMarkerLineProbe`), so marker text inside a
990
+ * value or after a content line opens no block.
991
+ * A scan that ended at a foreign table with no END marker anywhere after it
992
+ * keeps the legacy behaviour (no split-block check), except that hook event tables directly after the block are collected, named and
809
993
  * checked (`collectKeptHookTables`), and a harness marker in their run
810
994
  * refuses through the split-block check. The END marker is looked up as a
811
- * whole line outside multi-line strings (`findExactLine`), so END text in a
812
- * foreign table's value does not route a config here. Like the BEGIN path,
995
+ * marker line too, so END text in a foreign table's value does not route a
996
+ * config here. Like the BEGIN path,
813
997
  * the split-block check matches its markers as substrings, so a value in the
814
998
  * zone that holds marker text refuses too (see `assertNoSplitBlock`).
815
999
  */
816
1000
  function legacyManagedRange(text, start, scan, configPath, blockOpening) {
1001
+ assertNoOperatorLineBeforeHookTable(text, start, scan, configPath, blockOpening);
817
1002
  if (scan.sawEndMarker) {
818
1003
  return { start, end: scan.end, foreignSectionsPreserved: [], keptHookTables: [] };
819
1004
  }
820
- if (scan.endedAtOperatorHookTable || findExactLine(text, CODEX_MANAGED_END, scan.end) !== -1) {
821
- return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening);
1005
+ // The split-block zone starts after the block's own opening line: when the
1006
+ // scan owned nothing (the generated header or a foreign table directly
1007
+ // follows the source prefix), `scan.end` is that opening line itself, which
1008
+ // is a split-block marker and would refuse a block with nothing in it
1009
+ // (task 36d962d2).
1010
+ const zoneFrom = Math.max(scan.end, lineEndAfter(text, start));
1011
+ if (scan.endedAtOperatorHookTable || findMarkerLine(text, CODEX_MANAGED_END, "whole", scan.end) !== -1) {
1012
+ return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening, zoneFrom);
822
1013
  }
823
1014
  const run = collectKeptHookTables(text, scan.end, text.length);
824
1015
  // The scan ended at a hook table spelled without the `[[hooks.` prefix: a
@@ -829,21 +1020,23 @@ function legacyManagedRange(text, start, scan, configPath, blockOpening) {
829
1020
  // as one without (task e8f4fc03). The run stops at the first non-hook table:
830
1021
  // past it the legacy reading below stays unchanged.
831
1022
  if (run.tables.length > 0)
832
- assertNoSplitBlock(text, scan.end, run.end, configPath, false);
1023
+ assertNoSplitBlock(text, zoneFrom, run.end, configPath, false);
833
1024
  const keptHookTables = run.tables;
834
1025
  const foreignSectionsPreserved = collectForeignSectionHeaders(text, scan.end, text.length, keptHookTables);
835
1026
  return { start, end: scan.end, foreignSectionsPreserved, keptHookTables };
836
1027
  }
837
1028
  function findManagedRange(text, configPath) {
838
- const begin = text.indexOf(CODEX_MANAGED_BEGIN);
1029
+ // BEGIN is found as a marker line (`createMarkerLineProbe`): BEGIN text in
1030
+ // an operator value (a string, a trailing comment) is neither the block nor
1031
+ // a second marker, so the install neither deletes operator content after
1032
+ // it nor splices the fresh block into the value (task 36d962d2).
1033
+ const begin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix");
839
1034
  if (begin !== -1) {
840
- const secondBegin = text.indexOf(CODEX_MANAGED_BEGIN, begin + CODEX_MANAGED_BEGIN.length);
1035
+ const secondBegin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix", lineEndAfter(text, begin));
841
1036
  if (secondBegin !== -1) {
842
1037
  // Names the second marker's line number only, never that line's own
843
- // text: the marker is found by a substring search, so the line may be
844
- // foreign content (a value holding a token, say) that merely contains
845
- // the marker text, and this refusal is printed to stderr and `--json`.
846
- const secondBeginLineNumber = lineNumberAt(text, lineStartAt(text, secondBegin));
1038
+ // text, since this refusal is printed to stderr and `--json`.
1039
+ const secondBeginLineNumber = lineNumberAt(text, secondBegin);
847
1040
  throw new CodexInstallRefusalError(`Codex config ${configPath} has more than one '${CODEX_MANAGED_BEGIN}' marker ` +
848
1041
  `(a second one at line ${secondBeginLineNumber}); refusing to ` +
849
1042
  "guess which one is current. Delete the entire unwanted BEGIN/END pair -- including " +
@@ -854,42 +1047,114 @@ function findManagedRange(text, configPath) {
854
1047
  "but that discards any Codex state (hook trust entries, marketplaces, plugins) " +
855
1048
  "written to the file since that backup.", configPath);
856
1049
  }
857
- let start = lineStartAt(text, begin);
1050
+ let start = begin;
858
1051
  const prev = previousLine(text, start);
859
- if (prev) {
860
- // A BOM sitting before the source-prefix comment on line 1 must not
861
- // hide the prefix from this check (task b34ed105).
862
- const prevText = prev.start === 0 ? prev.text.replace(/^\uFEFF/, "") : prev.text;
863
- if (prevText.startsWith(CODEX_MANAGED_SOURCE_PREFIX))
864
- start = prev.start;
1052
+ // Only a marker line is the block's source prefix: the closing line of a
1053
+ // multi-line string that ends with the prefix text is the operator's
1054
+ // value, not part of the block (task 36d962d2). Trimming also drops a BOM
1055
+ // sitting before the source-prefix comment on line 1 (task b34ed105).
1056
+ if (prev && createMarkerLineProbe(text)(prev.start, CODEX_MANAGED_SOURCE_PREFIX, "prefix")) {
1057
+ start = prev.start;
865
1058
  }
866
1059
  start = startPastBom(text, start);
867
1060
  const contentStart = lineEndAfter(text, begin);
868
1061
  const scan = scanOwnedContentEnd(text, contentStart);
1062
+ assertNoOperatorLineBeforeHookTable(text, begin, scan, configPath, `the '${CODEX_MANAGED_BEGIN}' line`);
869
1063
  const end = scan.end;
870
1064
  if (scan.sawEndMarker) {
871
1065
  return { start, end, foreignSectionsPreserved: [], keptHookTables: [] };
872
1066
  }
873
1067
  return rangeBeforeStrayEnd(text, start, end, configPath);
874
1068
  }
875
- const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
1069
+ const source = findMarkerLine(text, CODEX_MANAGED_SOURCE_PREFIX, "prefix");
876
1070
  if (source !== -1) {
877
- const start = startPastBom(text, lineStartAt(text, source));
1071
+ const start = startPastBom(text, source);
878
1072
  return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
879
1073
  }
880
- const generated = text.indexOf(GENERATED_HEADER);
1074
+ const generated = findMarkerLine(text, GENERATED_HEADER, "prefix");
881
1075
  if (generated !== -1) {
882
- const start = startPastBom(text, lineStartAt(text, generated));
1076
+ const start = startPastBom(text, generated);
883
1077
  const scan = scanOwnedContentEnd(text, start);
884
- const end = scan.end;
885
- const candidate = text.slice(start, end);
886
- if (candidate.includes("[[hooks.") ||
887
- candidate.includes("[[hooks.pre_tool_use]]")) {
1078
+ // The generated header opens a legacy block only when a hook table header
1079
+ // line (`[[hooks.`, the older `[[hooks.pre_tool_use]]` included) follows
1080
+ // it inside the scanned range, found by the same top-level line rule as
1081
+ // the markers: `[[hooks.` text in a value, a multi-line string line or a
1082
+ // comment under an operator table is operator content, and taking it for
1083
+ // a header made the install replace the operator's keys after the header
1084
+ // line with the fresh block (task 36d962d2).
1085
+ // The search stops at the end of the scanned range: a candidate past it
1086
+ // cannot open the block, and each one the text test lets through costs a
1087
+ // parse of the document before it.
1088
+ const hookHeader = findMarkerLine(text, "[[hooks.", "prefix", start, scan.end);
1089
+ if (hookHeader !== -1) {
888
1090
  return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
889
1091
  }
1092
+ assertNoLaterGeneratedHeaderBlock(text, generated, scan.end, configPath);
890
1093
  }
1094
+ assertNoOrphanHarnessHookTable(text, configPath);
891
1095
  return null;
892
1096
  }
1097
+ /**
1098
+ * Refuses when a generated-header line outside the range being replaced sits
1099
+ * directly above a hook table, either after a stale first header or after a
1100
+ * genuine block: that is a genuine legacy block, and the install
1101
+ * reads only the first generated-header line, so it would add the fresh block
1102
+ * and leave the genuine one running next to it. Which of the two lines to
1103
+ * trust is not something the file says, so the install names both lines and
1104
+ * refuses. A later block whose first hook table is not directly under its
1105
+ * generated-header line (a key or value line in between) is not recognised
1106
+ * here, a documented boundary. Line numbers only, never a line's text.
1107
+ */
1108
+ function assertNoLaterGeneratedHeaderBlock(text, firstHeader, from, configPath, firstBlockOwned = false) {
1109
+ const later = findMarkerAboveHookTable(text, GENERATED_HEADER, from, (t) => HOOK_ARRAY_HEADER_RE.test(t));
1110
+ if (later === null)
1111
+ return;
1112
+ const firstDescription = firstBlockOwned
1113
+ ? `the one at line ${lineNumberAt(text, firstHeader)} belongs to the block being replaced`
1114
+ : `the one at line ${lineNumberAt(text, firstHeader)} has no hook table of its own (a table of yours follows it)`;
1115
+ const fix = firstBlockOwned
1116
+ ? `Delete the unwanted later block, including its generated-header line and hook tables`
1117
+ : `Delete the line at line ${lineNumberAt(text, firstHeader)} (it is a comment, and no block hangs on it)`;
1118
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has two '${GENERATED_HEADER}' lines and the install can replace ` +
1119
+ `only one block: ${firstDescription}, the one at line ${lineNumberAt(text, later.marker)} sits ` +
1120
+ `above the hook table at line ${lineNumberAt(text, later.header)}. Installing would add the ` +
1121
+ "fresh block and leave that hook table running next to it, so the install refuses to guess " +
1122
+ `which line is current. ${fix}, then re-run ` +
1123
+ "`harness apply --runtime codex --install`. The file is untouched.", configPath);
1124
+ }
1125
+ /**
1126
+ * Refuses outside the range being replaced (including the append path) when
1127
+ * a `# harness hook:` comment line sits directly above a hook table: that is a hook table an earlier install
1128
+ * wrote whose opening lines are gone, and the install would add the fresh block
1129
+ * and leave it running next to it (the hook would run twice, or a hook the
1130
+ * manifest retired would keep running). The comment line is found as a marker
1131
+ * line (`createMarkerLineProbe`), above a `[[hooks.` header or a hook event
1132
+ * header spelled another way (`isHookEventArrayHeader`). A table of the
1133
+ * operator's own that carries such a comment is refused too, since the two
1134
+ * cannot be told apart; the guidance offers rewording the comment. Line numbers
1135
+ * and the table's own header only, never a line's text.
1136
+ */
1137
+ function assertNoOrphanHarnessHookTable(text, configPath, range = null) {
1138
+ let orphan = findMarkerAboveHookTable(text, HARNESS_HOOK_COMMENT_PREFIX, 0, (t) => HOOK_ARRAY_HEADER_RE.test(t) || isHookEventArrayHeader(t));
1139
+ // An owned pair is replaced together. Search the unowned tail from a known
1140
+ // top-level boundary; a pair before the range must still refuse.
1141
+ if (orphan !== null && range !== null && orphan.header >= range.start && orphan.header < range.end) {
1142
+ orphan = findMarkerAboveHookTable(text, HARNESS_HOOK_COMMENT_PREFIX, range.end, (t) => HOOK_ARRAY_HEADER_RE.test(t) || isHookEventArrayHeader(t));
1143
+ }
1144
+ if (orphan === null)
1145
+ return;
1146
+ const header = describeTableHeader(text.slice(orphan.header, lineEndAfter(text, orphan.header)));
1147
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a '${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment ` +
1148
+ `line (line ${lineNumberAt(text, orphan.marker)}) directly above the hook table ${header} (line ` +
1149
+ `${lineNumberAt(text, orphan.header)}), but no harness block marker (the '${CODEX_MANAGED_BEGIN}' ` +
1150
+ "line, the source-prefix line, or the generated-header line above a hook table) opens a block " +
1151
+ "around it. harness cannot tell that table from one an earlier install wrote, and the install " +
1152
+ "would add its fresh block next to it, so that hook would run twice (or a hook the manifest " +
1153
+ "retired would keep running). If harness wrote the table, delete it together with its comment " +
1154
+ "line. If it is your own table, delete or reword the comment line, since the install reads a " +
1155
+ `comment line that starts with '${HARNESS_HOOK_COMMENT_PREFIX}' as harness-owned. Then re-run ` +
1156
+ "`harness apply --runtime codex --install`. The file is untouched.", configPath);
1157
+ }
893
1158
  function ensureTrailingNewline(s) {
894
1159
  return s.endsWith("\n") ? s : `${s}\n`;
895
1160
  }
@@ -1482,6 +1747,13 @@ export function planCodexConfigInstall(opts) {
1482
1747
  }
1483
1748
  const managedBlock = buildManagedBlock(opts.generatedContent, opts.generatedPath);
1484
1749
  const range = withBackupLocation(linkPath, () => findManagedRange(currentContent, configPath));
1750
+ if (range !== null) {
1751
+ assertNoOrphanHarnessHookTable(currentContent, configPath, range);
1752
+ const firstGenerated = findMarkerLine(currentContent, GENERATED_HEADER, "prefix", range.start, range.end);
1753
+ if (firstGenerated !== -1) {
1754
+ assertNoLaterGeneratedHeaderBlock(currentContent, firstGenerated, range.end, configPath, true);
1755
+ }
1756
+ }
1485
1757
  let nextContent;
1486
1758
  let summary;
1487
1759
  let removedHookIds = [];
@@ -1490,7 +1762,7 @@ export function planCodexConfigInstall(opts) {
1490
1762
  let keptTableDescriptions = [];
1491
1763
  let hasEndMarkerAfterKept = false;
1492
1764
  if (range) {
1493
- const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
1765
+ const oldHookIds = extractHookIds(currentContent, range.start, range.end);
1494
1766
  const newHookIds = extractHookIds(managedBlock);
1495
1767
  removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
1496
1768
  keptHookTables = range.keptHookTables;