@lannguyensi/harness 0.61.0 → 0.63.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 (48) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/cli/apply/install-codex-config.d.ts +8 -5
  4. package/dist/cli/apply/install-codex-config.js +196 -25
  5. package/dist/cli/apply/install-codex-config.js.map +1 -1
  6. package/dist/cli/approve/understanding.d.ts +36 -2
  7. package/dist/cli/approve/understanding.js +191 -42
  8. package/dist/cli/approve/understanding.js.map +1 -1
  9. package/dist/cli/bounded-stdin.d.ts +15 -2
  10. package/dist/cli/bounded-stdin.js +32 -3
  11. package/dist/cli/bounded-stdin.js.map +1 -1
  12. package/dist/cli/explain-policy.d.ts +11 -0
  13. package/dist/cli/explain-policy.js +41 -3
  14. package/dist/cli/explain-policy.js.map +1 -1
  15. package/dist/cli/gc/index.d.ts +6 -2
  16. package/dist/cli/gc/index.js +22 -7
  17. package/dist/cli/gc/index.js.map +1 -1
  18. package/dist/cli/index.js +15 -14
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/pack/auto-approve-path.js +29 -3
  21. package/dist/cli/pack/auto-approve-path.js.map +1 -1
  22. package/dist/cli/pack/hook-bootstrap.d.ts +0 -12
  23. package/dist/cli/pack/hook-bootstrap.js +1 -27
  24. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  25. package/dist/cli/pack/hook-codex-pre-tool-use.js +4 -2
  26. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  27. package/dist/cli/pack/hook-pre-tool-use.js +19 -5
  28. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  29. package/dist/cli/pack/hook-runtime-reality.js +2 -2
  30. package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
  31. package/dist/cli/policy/intercept.js +75 -103
  32. package/dist/cli/policy/intercept.js.map +1 -1
  33. package/dist/cli/policy/risk-envelope-enrichment.d.ts +40 -0
  34. package/dist/cli/policy/risk-envelope-enrichment.js +108 -0
  35. package/dist/cli/policy/risk-envelope-enrichment.js.map +1 -0
  36. package/dist/io/atomic-write.d.ts +37 -1
  37. package/dist/io/atomic-write.js +61 -8
  38. package/dist/io/atomic-write.js.map +1 -1
  39. package/dist/io/display-path.d.ts +11 -0
  40. package/dist/io/display-path.js +29 -0
  41. package/dist/io/display-path.js.map +1 -0
  42. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  43. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  44. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  45. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +152 -21
  46. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +286 -48
  47. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.63.0] - 2026-10-04
11
+
12
+ ### Upgrade notes
13
+
14
+ - The understanding gate's PreToolUse hook now reads at most 8192 `*.json` entries and 32 MiB of report data from the reports directory (and the same bounds for `parse-errors/`). A directory past either bound fails closed: the block names no report evidence and the opt-in auto-approval declines until non-report or stale `*.json` entries are removed by hand (`harness gc` removes only aged approved or expired reports). Directories within the bounds behave as before.
15
+ - `harness explain-policy` JSON and YAML output gains a `parity` block; consumers that validate the projection strictly should allow the new key.
16
+
17
+ ### Fixed
18
+
19
+ - `harness explain-policy` now builds its Action Envelope through the same enrichment as `harness policy intercept` (task 7c3919a2). Before, it read only the ambient process environment, so `DATABASE_URL=postgres://u@prod-db/app psql ...` resolved `production` in the hook but `unknown` (`applies: false`) in the debug verb; inline `VAR=value` prefixes (quoted values and several assignments included), a leading `cd <path> &&` and a leading `git switch|checkout <branch> &&` are now merged by one shared helper (`src/cli/policy/risk-envelope-enrichment.ts`). The verb stays hypothetical and ledger-free, and its output gains a `parity` block naming what it does not evaluate (`ledger_requires`, `kubectl_target`). A fixture corpus test fails when the two verbs disagree.
20
+ - **The four other full-directory reads on the PreToolUse hook path are bounded by entry count and by bytes, and their directory listing stops early, so in every case measured a planted directory no longer pushes a hook call past the 15 s budget** (task `169e6286`, follow-up to `805be2af`, #617). The hash scan was already bounded, but the evidence read (`checkPersistedReport`, after a refused marker and on the no-marker path), the auto-approval precondition listing (`attemptAutoApproval`), the subagent-delegation lookup and the parse-error log lookup (`findLatestParseError`, `parse-errors/*.log`) still opened every entry of an agent-writable directory, so a directory of hundreds of thousands of planted entries took the hook past the 15 s PreToolUse budget, which the runtime treats as an allow (the 0.62.0 entry for 805be2af records 408195 opens and 28.9 s of CPU in the Claude Code hook and 22.0 s in the Codex hook at 400k planted entries). Three bounds now apply to each of the four readers. (1) An entry bound, `MAX_HOOK_LISTING_ENTRIES` (8192, the hash scan's 32 MiB budget over its 4 KiB per-entry floor). (2) A byte budget: every entry a reader opens is charged against the hash scan's 32 MiB budget (`MAX_HASH_SCAN_BYTES`) at the larger of its size (taken from `fstat` on the open descriptor, before the read) and 4 KiB, so each reader reads at most about 32 MiB (the last file read may add up to the 1 MiB per-file cap); each of the four readers has its own budget, none is shared across the hook call, so one hook call can read up to about 32 MiB per reader that runs. (3) An early-stopping listing: `listDirNamesBounded` reads the directory one entry at a time with `opendirSync` and stops as soon as it has seen more than 8192 matching names, or more than 16384 entries of any name (so millions of non-matching names fail closed too), always closes the handle, and returns the names in ascending byte order (the order `readdirSync` returned before, so a tie in a reader's newest-first sort does not follow the filesystem's iteration order). Past any bound a reader fails closed, never allows: the evidence read reports no report evidence (the block still stands), the auto-approval declines and the call stays blocked, the delegation lookup captures no report for the child (stderr says so), the parse-error lookup names none; the evidence note, the auto-approval decline note and the delegation note say which bound was crossed and name the remedy (remove non-report or stale `*.json` entries from the directory by hand; `harness gc` removes only aged approved or expired reports, so it does not clear planted entries). Decision: all or nothing past a bound rather than a newest-first window like the hash scan, because a partial view could change a selection (an auto-approval would adopt an older pending report while a newer one, hidden past the window, is already approved); the cost is that a legitimate directory over either bound loses the evidence detail and the auto-approval until entries are removed (the hash scan, which allows or denies on the signed marker, is unchanged). A directory at or under the bounds reads exactly as before, so a realistic large directory (tested at 1000 reports) still allows and still auto-approves. The operator commands (`harness approve understanding`, `harness gc`) and the PostToolUse boundary expiry pass no bound and are unchanged. Measured with an in-process harness on the real hook entry points (one run per case, one machine, no-marker and auto-approval paths, Claude Code and Codex): before the byte budget and the early-stopping listing (entry bound only), 8191 and 8192 hard-linked 1 MiB entries took the Claude Code hook 60.6 s and 69.6 s (the Codex hook 51.3 s at 8192) and 3M planted tiny entries took the auto-approval path 17.6 to 17.8 s; after, the same cases block in 0.72 s at most (8191 and 8192 hard-linked 1 MiB entries, 400k and 3M planted tiny entries, each on the Claude Code and Codex hooks, plain and auto-approval). Residuals, not changed here: when a signed marker is present the hash scan (`scanReportHashes`) still does its own `readdir` plus name sort of the whole reports directory, uncharged (a plain `readdir` of the 3M-entry directory took 31 s wall on one run here, 9.4 s on a warmer one in review); on a Claude Code subagent call (a non-empty `agent_id`) that misses the marker, the in-flight record check (`verifyInflightRecord`) lists the in-flight session directory `.inflight/<session>/` uncharged (8.3 s at 3M planted entries in review, the same as before this change); the evidence-ledger query the hook makes shares the same 15 s budget and is not bounded by this change; and each of the three listings of the reports directory a Claude Code hook call can make (evidence read, delegation lookup, auto-approval precondition) lists it on its own instead of once. Pinned by `tests/cli/pack-hook-bounded-dir-reads.test.ts` on both runtimes (open, stat, bytes-read and directory-read counts on the fs layer, never wall time; the delegation lookup is Claude-only because the Codex hook has none). The docs that called this a follow-up are updated (`docs/policy-packs/understanding-before-execution.md` residuals (4) and (5), `docs/okf/gate-fail-posture-matrix.md`, `docs/okf/understanding-gate-lockout-recovery.md`). The released 0.62.0 entry for 805be2af still names the evidence read as an open residual; this entry closes it.
21
+
22
+ ## [0.62.0] - 2026-10-03
23
+
24
+ ### Changed
25
+
26
+ - The publish workflow now installs npm with `npm install -g npm@^11.5.1` (the documented trusted-publishing floor) instead of the floating `npm@11`, and the publish step's two success-on-registry exits (the "already on the registry" idempotency check before the retry loop and the loop's "reached the registry despite a non-zero publish exit" branch) now probe `npm view <pkg>@<version> dist.attestations` and fail with an error when the attestation is empty (or when the probe itself fails), so a partially completed publish is no longer announced green. CI only; no package code change.
27
+
28
+ - CI: `release.yml` now passes step values into `run:` scripts through `env:` and shell variables instead of interpolating `${{ }}` expressions into the script text. No behavior change for normal tags and versions.
29
+
30
+ ### Upgrade notes
31
+
32
+ - `harness policy intercept` (the PreToolUse hook of the policy engine) now blocks when its stdin does not deliver a complete, closed event within 3000 ms of idle time, instead of continuing as an empty event. Claude Code and Codex close stdin after writing the event, so normal use is unaffected; `harness pause` still wins. A hand-written registration of `harness policy intercept` on a non-PreToolUse event whose stalled read names no event gets the same block.
33
+
34
+ - `harness apply --runtime codex --install` now refuses three legacy config shapes it used to install over (exit 1, file untouched, each refusal names line numbers and what to change): a config with two generated-header lines whose first one has a foreign table below it and whose second one sits above a hook table; a key or value line between a legacy block's opening line (source prefix or generated header) and its first hook table; a `# harness hook:` comment line directly above a hook table when no BEGIN marker, source-prefix line or generated-header line opens a block around it. Each of these used to leave a harness hook running next to the fresh block or delete an operator key. A table of your own that carries a `# harness hook:` comment is refused too; reword the comment.
35
+
36
+ ### Fixed
37
+
38
+ - **The understanding gate's report-hash scan now has a total budget (bytes read, with a per-entry floor) and reads the newest report first; both approval producers refuse a report whose approval rewrite would exceed the size cap; the `--force` help names what it overrides and what it does not** (task `805be2af`, follow-up to `fa423e9b`, #586; source commits e24ebce and de60a8e). (1) Decision on the volume residual: fail closed instead of keeping the documented residual. The per-file cap of 1 MiB bounded each report file, not their number, so 160 planted 1 MiB key-heavy files took 15.9 to 17.6 s to scan (two earlier measurements), past the 15 s PreToolUse budget, which the runtime treats as an allow. The scan now takes the `*.json` entries in descending name order (report names start with a timestamp, so the newest first, where the approved report usually is) and charges each entry the larger of the bytes it read and a 4 KiB floor (`MIN_SCAN_ENTRY_COST_BYTES`, about one real report), whatever its read returned (a directory named `*.json`, a file over the cap, a FIFO and an unreadable file read nothing but still cost an open). It stops once the charges add up to `MAX_HASH_SCAN_BYTES` (32 MiB) without finding the signed content, so it opens at most 8192 entries and reads about 32 MiB (the last file read may add up to the 1 MiB per-file cap); both PreToolUse runtimes (Claude Code `pre-tool-use`, Codex `codex-pre-tool-use`, through `verifyMatchedMarkerReport`) then deny with the existing mismatch reason plus a note that the directory holds more report data than the scan budget covers. A first version charged only the bytes of files it read; an adversarial review showed that 400k planted `{}` files (no bytes to charge) still took about 22 s of CPU in the scan and kept the allow-by-timeout path open through the entry count, which the floor closes for the hash scan only (see the evidence-read residual below). A time slice was rejected: its verdict would depend on machine load, a budget of bytes and entries is deterministic and testable; a separate entry cap was rejected as the same bound with a second knob; the 4 KiB floor keeps the capacity for real reports (about 3.6 KiB each) near 8192 instead of the 9000 a pure byte budget gave. Measured with an in-process scan harness (task 805be2af) (the scan alone, in process, one run each, one machine, report of about 3.6 KiB; the machine load average was about 24 for the last run, so wall times are inflated): 100, 1000 and 5000 realistic reports (0.4, 3.5, 17.8 MiB) took 0 to 10 ms, 1 to 61 ms and 3 to 230 ms before the change, 0 to 4 ms, 1 to 31 ms and 4 to 214 ms with the bytes-only budget and 1 to 8 ms, 1 to 351 ms and 14 to 1288 ms with the floor (each range spans the approved report being the oldest or the newest), all allowing in every run. 160 planted 1 MiB key-heavy files (159.4 MiB) took 10.0 to 15.2 s before and 1.9 s with the bytes-only budget; with the floor the scan stops after 33 opens (3.4 s of CPU, 13.6 s wall under that load) and denies where the 15 s budget used to turn it into an allow. Entry-count cases measured with an in-process scan harness counting opens and CPU time (counts opens and CPU time, not wall clock): 400k tiny files took 400000 opens and 21.9 s of CPU at the bytes-only budget and 8192 opens and 0.75 s with the floor, 100k tiny files 8192 opens and 0.24 s, 100k directories named `*.json` 8192 opens and 0.20 s, 100k files over the cap 8192 opens and 0.21 s, each denying with the budget note. Residuals: a legitimate approval whose report sits behind 8192 newer entries (or about 32 MiB of them) denies with the mismatch reason; `harness approve understanding` writes a new, newest report and recovers, but only when the newer entries are real reports (see below). The `readdir` and name sort of the directory itself are not charged, so a directory of hundreds of thousands of entries still costs that listing before the scan starts (inside the 0.75 s measured for 400k). Evidence-read residual (still open, the hook as a whole is not bounded by entry count): the hash scan is bounded (8192 entries, about 32 MiB, per-entry floor), but the evidence read that follows a refused marker (`checkPersistedReport` through `listPersistedReportsBounded`, shared with the no-marker path and called by both PreToolUse hooks) still opens and parses every `*.json` entry. Measured end to end with an in-process hook harness at 400k planted entries: the Claude Code hook took 408195 opens and 28.9 s of CPU, the Codex hook 22.0 s of CPU, both past the 15 s PreToolUse budget, which the runtime treats as an allow. The auto-approval precondition listing, the subagent-delegation lookup and the parse-error log lookup are further full-directory reads on the same hook path; a follow-up task bounds all of them; this task changes no code there. Recovery by re-approval works only when the newer entries are real reports: planted `*.json` names that sort after the timestamped report names (for example names starting with a letter) stay newer than any new report and keep denying until the operator removes them. A third, unchanged residual: `expirePersistedReport` (PostToolUse boundary expiry) rewrites an approved report with `expiredAt` and `expiredBy` added and no size check, so a report approved within about 100 bytes of the cap can end up over it, after which the gate reads no file for it and `harness approve understanding` refuses on that oversized entry until the operator removes or shrinks it. (2) `harness approve understanding` and the auto-approval path checked the size cap on the report as written, but the approval rewrite pretty-prints it and adds `approvedAt` and `approvedBy`, so a report accepted just under the cap could cross it afterwards and deny on every call, with neither re-approval nor `--force` able to clear it (a band of about 150 bytes). Both now render the rewrite first and refuse on its bytes: the CLI exits 1 before any marker, ledger tag or report change (also under `--force`), the auto path declines as `report invalid: size` and leaves the report `pending`. (3) `harness approve understanding --force` help now says what it overrides and what it does not: it also overrides the nesting-depth refusal (the marker is then signed with no content binding, `reportContentHash: null`) and never the size refusal (also on the pretty-printed approval rewrite), an unreadable report or one that is not a regular file. Tests pin the budget deny, the newest-first allow, an unchanged allow for 1000 and 5000 realistic reports, and a deny for entries past the per-entry floor (tiny files, directories, files over the cap) on both runtimes, and the cap-minus-50-byte report on the approve CLI (exit code 1, no ledger call) and on both auto paths.
39
+
40
+ - Restore test typechecking by completing approval marker fixtures, checking nullable report hashes and runtime fixtures, and discarding subprocess return values in void callbacks (9d74bae, #612).
41
+
42
+ - Codex install (`8c2cca88b07919c82d0e6d6da0dc3122f1a59443`) refuses operator content between BEGIN and the first hook table, marked orphan hooks outside its replacement range (including after foreign tables or END), and a separate later generated block above a canonical hook table. Refusals give line guidance and leave the config untouched. A canonical legacy block before a later BEGIN, uncommented respelled later blocks, and uncommented harness commands on the append path remain ownership boundaries; see `docs/okf/codex-adapter-parity-gaps.md`.
43
+
44
+ - `harness approve understanding` escapes successful ledger tags and marker-write diagnostics; `harness gc` escapes unreadable record paths and malformed delegation diagnostics. Stored identities, validation, and retention remain unchanged (#610).
45
+
46
+ - `harness approve understanding` escapes report-controlled parse-error summaries, approval status, mode, fallback creation times, resolved session ids and marker paths before terminal display. Guessed session ids retain their existing resolution and marker naming. `harness gc` also escapes in-flight approval timestamps. The shared display renderer now escapes Unicode format characters (including bidi overrides), U+2028 and U+2029 as JSON Unicode escapes (beba45f, #610).
47
+
48
+ - **`harness approve understanding` refuses, and `harness gc` reports, a planted oversized, non-regular or symlinked entry in the reports directory instead of reading it in full** (task `1ccfe922`, follow-up to `4b39022f`). Both commands listed the reports directory unbounded: a file of a few hundred megabytes was read whole, and one larger than the string limit was skipped silently, so approve signed a marker bound to no report (measured: a 600 MB planted `*.json` gave exit 0 and a marker). They now list through the bounded reader. Approve exits 1 before any side effect (no marker, ledger tag, report flip or task marker), naming each `*.json` entry that is over the 1 MiB cap, not a regular file (FIFO, directory), a symbolic link whatever it points at, or unreadable; `--force` does not override it (an oversized report used to be refusable only by validation, so `--force` could sign a marker bound to no report; that no longer happens). Its re-read of the selected report, its hash and the final report rewrite are bounded as well, open with `O_NOFOLLOW` (a link is refused by the open itself, with no earlier `lstat` to race), and refuse an oversized or non-regular report that is swapped in after the listing even under `--force`, which now covers content reasons only (a report nested too deeply still signs a marker bound to no report). Every report path that `harness approve understanding` prints (its refusals and its success output) and that `harness gc` prints is an escaped literal (`\u001b`, `\r`, `\n`, DEL and C1 bytes escaped, not stripped), so a file name chosen by the gated agent cannot write control bytes to the operator's terminal or forge a line. `harness gc` lists such an entry as unparseable, never reads it and never deletes it. A regular in-cap report signs the same marker as before. To recover from the refusal, remove or move the named file; see `docs/okf/understanding-gate-lockout-recovery.md`.
49
+
50
+ - **`harness policy intercept` blocks a PreToolUse call on a timed-out stdin read instead of allowing it** (task `aca3de04`, follow-up to `7dfdcaaf`). After the idle-bounded read timed out (3000 ms without a chunk) the intercept continued as an empty event, which matches no policy and allows, so a host that stalled past the bound and then wrote a complete gated event and closed got an allow where the policy would have blocked (the hook had already exited when the event arrived). A timed-out read now refuses the tool call after the operator pause check: a reason starting `stdin timeout:` that names the 3000 ms bound, the usual `decision: "block"` envelope with the PreToolUse `permissionDecision: "deny"` on stdout, exit 0, one `BLOCK` line on stderr, and no policy is evaluated, so it applies whatever the declared enforcement tiers are. The hook command carries no event name, so the event the read itself declares decides: a timed-out read whose text is a JSON object naming a hook event other than `PreToolUse` keeps the previous behaviour (the `using the N bytes read` note, then the event is evaluated like any other); anything else (nothing read, a truncated or unparseable prefix, a `PreToolUse` event, an object with no event name) is treated as the PreToolUse call the verb is registered for. A stdin that closes within the bound is decided exactly as before, and the empty-event note `continuing as an empty event` is no longer written by this verb.
51
+
52
+ - **`harness apply --runtime codex --install` refuses the legacy shapes it used to half-handle, its no-END refusal no longer offers a move that changes nothing, and a lookup that cost a parse per line stops at the scanned range** (task `c78a155b`, closes the residuals named after task `36d962d2`). (1) The install read only the first generated-header line: when a foreign table followed it, a genuine legacy block further down was never replaced and its hook kept running next to the fresh block. It now refuses when a later generated-header line sits directly above a `[[hooks.` table, naming both lines; a later block whose first hook table is not directly under its header line stays unrecognised (a documented boundary, as is a later source-prefix line and a second genuine block after the first). (2) A key or value line between a legacy block's opening line and its first hook table is not harness output, since a block harness wrote has only comment lines there; the replacement deleted it with the block, and for a key of an operator hook table the semantic net could not see the loss (it compares only what is outside the hook event arrays). The legacy paths now refuse and tell the operator to move the line above the opening line. The BEGIN path is unchanged: a key between the BEGIN line and the first hook table is still consumed with the block, the same reasoning as above applies to it, and it is tracked as a follow-up (a key after the first hook table is a field of that harness table and is consumed with it). Two shapes the semantic net used to catch first (a top-level key after a legacy source-prefix line, a generated header pasted inside a foreign table) now refuse here with this message. (3) On the append path a `# harness hook:` comment line directly above a hook table (`[[hooks.` or a respelled hook event header) left that table running next to the fresh block; it now refuses, naming the comment line and the table's header. The check is linear and parses nothing for a pair inside a multi-line string. (4) The no-END refusal offered "move the table below the last harness hook table" even when no harness-commented table follows it, so the table already sat there and the same refusal came back; that branch now offers only adding the END marker, and the move stays the only offer when a harness-commented hook table does follow. (5) The lookup that lets a generated header open a block searched the whole rest of the document and probed every `[[hooks.` line in it; it now stops at the end of the scanned range. Measured on a config with a generated header, `[tui]` and a multi-line string of N `[[hooks.` lines (wall time of one plan, the machine shared with other jobs): 363 ms at 2000 lines and 7088 ms at 8000 before, 1 ms and 31 ms after. The BEGIN, END and source-prefix searches over the whole document are not bounded by a scan and keep their cost for marker text in strings; that cost is unchanged by this change. Reproduced with the previous review rounds' shape harnesses at base and head: no shape got worse, the two append-path orphan shapes, the two stale-header shapes and the two operator-key shapes moved from installing wrongly to refusing.
53
+
54
+ - **`atomicWriteFile` creates its temp file exclusively under a random name, so a file planted at the temp name can no longer hang the writer or redirect the write** (task `4d28617e`, closes the write-path residual named under 0.61.0). The shared helper behind the approval markers, the persisted-report rewrites (the auto-approval consume step and the PostToolUse boundary expiry), the in-flight and delegation records, the active claim, the permission-mode observation, the parse-error log, the manifest and settings writers and their backups wrote `.<base>.<pid>.<ms>.tmp` next to the target and opened it with plain `"w"`: a FIFO planted at that name (a name an agent can read off the process table and the clock) blocked the open until the host's hook timeout, so the understanding gate's consume step, which writes into the agent-writable reports directory, left the report pending and the call proceeded as a non-blocking hook error, and a planted symlink was written through to wherever it pointed. The temp name now carries 64 random bits (`crypto.randomBytes`) and is opened with `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`: anything already at the chosen name (a FIFO, a symlink including a dangling one, a regular file) makes the call throw `EEXIST` at once, never blocking and never writing through the link, and the planted entry is left alone (a path the call did not create is never removed). `O_NOFOLLOW` is not defined on Windows; there `O_EXCL | O_CREAT` alone carries the refusal (it never follows a symlink on create). After the temp file exists, any failure (write, fsync, close, rename) removes it again. Failure postures are unchanged because every caller already treats a throw as a failed write: the consume step declines with the report left `pending` (`auto-approval declined: could not consume the report (...)`), the boundary expiry is skipped with a `persisted-report expiry skipped (...)` note, `harness approve understanding` and the other marker writers report the failed write, and the settings and manifest writers abort before replacing anything. A successful write is unchanged: same bytes, the mode each caller requests (0o644 when none is given), subject to the umask, same rename. Tests: helper unit tests (planted regular file, symlink, dangling symlink and FIFO at the chosen name, the flags, the suffix, cleanup after a failed rename) and built-CLI cases on both the Claude and the Codex PreToolUse hook (FIFOs planted at every former `pid.millisecond` name no longer hang the hook and the report is still minted; a FIFO, a symlink or a regular file planted at the chosen name declines within the bound with the report left pending and nothing written through or removed). Still open from the 0.61.0 entry: the total volume of planted files and the unbounded `harness approve understanding` listing of an oversized planted report.
55
+
10
56
  ## [0.61.0] - 2026-10-02
11
57
 
12
58
  ### Upgrade notes
package/README.md CHANGED
@@ -98,7 +98,7 @@ steps).
98
98
 
99
99
  ## Status
100
100
 
101
- The current release is `v0.61.0`. All seven planned phases have
101
+ The current release is `v0.63.0`. All seven planned phases have
102
102
  shipped; phase acceptance criteria are in
103
103
  [`docs/ROADMAP.md`](docs/ROADMAP.md), and what shipped in each version
104
104
  is in [`CHANGELOG.md`](CHANGELOG.md).
@@ -163,11 +163,14 @@ export declare function createTopLevelProbe(text: string): (lineStart: number) =
163
163
  * point at that marker; when there is no END marker anywhere in the
164
164
  * document (`to` is `text.length`), telling the operator to move something
165
165
  * below a marker that does not exist would be nonsensical, so a different
166
- * fix (add one, or move the table below the last harness hook table) is
167
- * offered instead (task 6a037359). Adding one is offered only when no
168
- * harness-commented hook table follows the named table
169
- * (`harnessHookTableFollows`): an END placed before the named table would
170
- * leave that later table running next to the fresh block (task 36d962d2).
166
+ * fix is offered instead (task 6a037359): adding one right after the last
167
+ * harness hook table, unless a harness-commented hook table follows the named
168
+ * table (`harnessHookTableFollows`), since an END placed before the named table
169
+ * would leave that later table running next to the fresh block, and then only
170
+ * moving the named table below the last harness hook table is offered (task
171
+ * 36d962d2). With no such table after it the named table already sits below
172
+ * the last harness hook table, so that move is never offered next to the END
173
+ * fix: it would change nothing and bring the same refusal back.
171
174
  * When the named table is a hook event table spelled without the `[[hooks.`
172
175
  * prefix, the guidance offers deleting it if harness wrote it, since the scan
173
176
  * reads such a table as foreign with or without a comment line above it
@@ -194,9 +194,11 @@ function nextTripleDelim(rawLine, delimIn) {
194
194
  * elements). Treating such text as a marker made the install replace,
195
195
  * delete, or splice a fresh block into the operator's value. Every lookup
196
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`).
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.
200
202
  * Two marker searches stay outside it on purpose: the split-block refusal's
201
203
  * candidate list (`assertNoSplitBlock`), which can only refuse, never
202
204
  * install, and the trailing comment run of `scanOwnedContentEnd`, whose
@@ -220,9 +222,13 @@ function createMarkerLineProbe(text) {
220
222
  };
221
223
  }
222
224
  /** 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) {
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) {
226
232
  const isMarkerLine = createMarkerLineProbe(text);
227
233
  let pos = from;
228
234
  for (;;) {
@@ -230,11 +236,60 @@ function findMarkerLine(text, marker, shape, from = 0) {
230
236
  if (idx === -1)
231
237
  return -1;
232
238
  const lineStart = lineStartAt(text, idx);
239
+ if (lineStart >= to)
240
+ return -1;
233
241
  if (isMarkerLine(lineStart, marker, shape))
234
242
  return lineStart;
235
243
  pos = lineEndAfter(text, lineStart);
236
244
  }
237
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);
265
+ let delim = null;
266
+ let runMarker = null;
267
+ let pos = from;
268
+ while (pos < text.length) {
269
+ const lineEnd = lineEndAfter(text, pos);
270
+ const rawLine = text.slice(pos, lineEnd);
271
+ const startedInsideString = delim !== null;
272
+ delim = nextTripleDelim(rawLine, delim);
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
+ }
289
+ pos = lineEnd;
290
+ }
291
+ return null;
292
+ }
238
293
  /** True when a line in `[from, to)` is a marker line for the prefix
239
294
  * `marker`. */
240
295
  function rangeHoldsMarkerLine(text, from, to, marker, isMarkerLine) {
@@ -423,6 +478,13 @@ function scanOwnedContentEnd(text, start) {
423
478
  let commentRunStart = null;
424
479
  let sawOwnedContent = false;
425
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
+ : {};
426
488
  const classify = createLineClassifier(text);
427
489
  const isMarkerLine = createMarkerLineProbe(text);
428
490
  while (pos < text.length) {
@@ -436,7 +498,7 @@ function scanOwnedContentEnd(text, start) {
436
498
  continue;
437
499
  }
438
500
  if (isMarkerLine(pos, CODEX_MANAGED_END, "whole")) {
439
- return { end: lineEnd, sawEndMarker: true };
501
+ return { end: lineEnd, sawEndMarker: true, ...keyBeforeHookTable() };
440
502
  }
441
503
  if (kind === "comment") {
442
504
  if (commentRunStart === null)
@@ -445,9 +507,10 @@ function scanOwnedContentEnd(text, start) {
445
507
  continue;
446
508
  }
447
509
  if (kind === "foreignHeader") {
448
- return { end: commentRunStart ?? pos, sawEndMarker: false };
510
+ return { end: commentRunStart ?? pos, sawEndMarker: false, ...keyBeforeHookTable() };
449
511
  }
450
512
  if (kind === "hookHeader") {
513
+ firstHookHeader ??= pos;
451
514
  const marked = commentRunStart !== null &&
452
515
  rangeHoldsMarkerLine(text, commentRunStart, pos, HARNESS_HOOK_COMMENT_PREFIX, isMarkerLine);
453
516
  if (marked) {
@@ -457,9 +520,17 @@ function scanOwnedContentEnd(text, start) {
457
520
  // An operator-authored hook table (no `# harness hook:` comment
458
521
  // above it) after the harness tables ends the owned region, like a
459
522
  // foreign table, and keeps a comment attached to it (task 01053b27).
460
- return { end: commentRunStart ?? pos, sawEndMarker: false, endedAtOperatorHookTable: true };
523
+ return {
524
+ end: commentRunStart ?? pos,
525
+ sawEndMarker: false,
526
+ endedAtOperatorHookTable: true,
527
+ ...keyBeforeHookTable(),
528
+ };
461
529
  }
462
530
  }
531
+ else if (firstHookHeader === null) {
532
+ firstContentLine ??= pos;
533
+ }
463
534
  commentRunStart = null;
464
535
  sawOwnedContent = true;
465
536
  pos = lineEnd;
@@ -484,10 +555,10 @@ function scanOwnedContentEnd(text, start) {
484
555
  run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
485
556
  run.includes(GENERATED_HEADER);
486
557
  if (hasComment && !harnessAuthored) {
487
- return { end: commentRunStart, sawEndMarker: false };
558
+ return { end: commentRunStart, sawEndMarker: false, ...keyBeforeHookTable() };
488
559
  }
489
560
  }
490
- return { end: pos, sawEndMarker: false };
561
+ return { end: pos, sawEndMarker: false, ...keyBeforeHookTable() };
491
562
  }
492
563
  /**
493
564
  * True when a harness-commented hook table (a `# harness hook:` comment line
@@ -543,11 +614,14 @@ function harnessHookTableFollows(text, header, to) {
543
614
  * point at that marker; when there is no END marker anywhere in the
544
615
  * document (`to` is `text.length`), telling the operator to move something
545
616
  * below a marker that does not exist would be nonsensical, so a different
546
- * fix (add one, or move the table below the last harness hook table) is
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).
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.
551
625
  * When the named table is a hook event table spelled without the `[[hooks.`
552
626
  * prefix, the guidance offers deleting it if harness wrote it, since the scan
553
627
  * reads such a table as foreign with or without a comment line above it
@@ -628,11 +702,14 @@ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blo
628
702
  // block, so the only fix offered is moving the named table past it
629
703
  // (task 36d962d2).
630
704
  [`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
- ];
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
+ ];
636
713
  // An uncommented hook table in the middle may be a harness table whose
637
714
  // comment line was deleted, so moving it (the operator-table fix) is only
638
715
  // right for an operator-owned one; name the restore option first (task
@@ -870,6 +947,25 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFro
870
947
  keptHookTables,
871
948
  };
872
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
+ }
873
969
  /**
874
970
  * The range of a legacy (no BEGIN marker) config: the source-prefix or
875
971
  * generated-header paths. When the scan ended at an operator hook table,
@@ -883,8 +979,8 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFro
883
979
  * kept as if it were operator content (the fresh block would repeat it)
884
980
  * when it sits before that END, or, after kept tables, anywhere later when
885
981
  * 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
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
888
984
  * refusal names `blockOpening` (the source-prefix line or the generated
889
985
  * header) as the start of the block, since there is no BEGIN marker (task
890
986
  * 01053b27); the split-block zone starts after that opening line, so a block
@@ -902,6 +998,7 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFro
902
998
  * zone that holds marker text refuses too (see `assertNoSplitBlock`).
903
999
  */
904
1000
  function legacyManagedRange(text, start, scan, configPath, blockOpening) {
1001
+ assertNoOperatorLineBeforeHookTable(text, start, scan, configPath, blockOpening);
905
1002
  if (scan.sawEndMarker) {
906
1003
  return { start, end: scan.end, foreignSectionsPreserved: [], keptHookTables: [] };
907
1004
  }
@@ -962,6 +1059,7 @@ function findManagedRange(text, configPath) {
962
1059
  start = startPastBom(text, start);
963
1060
  const contentStart = lineEndAfter(text, begin);
964
1061
  const scan = scanOwnedContentEnd(text, contentStart);
1062
+ assertNoOperatorLineBeforeHookTable(text, begin, scan, configPath, `the '${CODEX_MANAGED_BEGIN}' line`);
965
1063
  const end = scan.end;
966
1064
  if (scan.sawEndMarker) {
967
1065
  return { start, end, foreignSectionsPreserved: [], keptHookTables: [] };
@@ -984,13 +1082,79 @@ function findManagedRange(text, configPath) {
984
1082
  // comment under an operator table is operator content, and taking it for
985
1083
  // a header made the install replace the operator's keys after the header
986
1084
  // line with the fresh block (task 36d962d2).
987
- const hookHeader = findMarkerLine(text, "[[hooks.", "prefix", start);
988
- if (hookHeader !== -1 && hookHeader < scan.end) {
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) {
989
1090
  return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
990
1091
  }
1092
+ assertNoLaterGeneratedHeaderBlock(text, generated, scan.end, configPath);
991
1093
  }
1094
+ assertNoOrphanHarnessHookTable(text, configPath);
992
1095
  return null;
993
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
+ }
994
1158
  function ensureTrailingNewline(s) {
995
1159
  return s.endsWith("\n") ? s : `${s}\n`;
996
1160
  }
@@ -1583,6 +1747,13 @@ export function planCodexConfigInstall(opts) {
1583
1747
  }
1584
1748
  const managedBlock = buildManagedBlock(opts.generatedContent, opts.generatedPath);
1585
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
+ }
1586
1757
  let nextContent;
1587
1758
  let summary;
1588
1759
  let removedHookIds = [];