@lannguyensi/harness 0.61.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.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +8 -5
- package/dist/cli/apply/install-codex-config.js +196 -25
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +26 -1
- package/dist/cli/approve/understanding.js +173 -30
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/bounded-stdin.d.ts +15 -2
- package/dist/cli/bounded-stdin.js +32 -3
- package/dist/cli/bounded-stdin.js.map +1 -1
- package/dist/cli/gc/index.d.ts +6 -2
- package/dist/cli/gc/index.js +22 -7
- package/dist/cli/gc/index.js.map +1 -1
- package/dist/cli/index.js +15 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pack/auto-approve-path.js +16 -1
- package/dist/cli/pack/auto-approve-path.js.map +1 -1
- package/dist/cli/pack/hook-bootstrap.d.ts +0 -12
- package/dist/cli/pack/hook-bootstrap.js +1 -27
- package/dist/cli/pack/hook-bootstrap.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +2 -1
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-runtime-reality.js +2 -2
- package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
- package/dist/cli/policy/intercept.js +52 -13
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/io/atomic-write.d.ts +37 -1
- package/dist/io/atomic-write.js +61 -8
- package/dist/io/atomic-write.js.map +1 -1
- package/dist/io/display-path.d.ts +11 -0
- package/dist/io/display-path.js +29 -0
- package/dist/io/display-path.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +83 -20
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +127 -39
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.62.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
### Upgrade notes
|
|
19
|
+
|
|
20
|
+
- `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.
|
|
21
|
+
|
|
22
|
+
- `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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
- 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).
|
|
29
|
+
|
|
30
|
+
- 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`.
|
|
31
|
+
|
|
32
|
+
- `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).
|
|
33
|
+
|
|
34
|
+
- `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).
|
|
35
|
+
|
|
36
|
+
- **`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`.
|
|
37
|
+
|
|
38
|
+
- **`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.
|
|
39
|
+
|
|
40
|
+
- **`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.
|
|
41
|
+
|
|
42
|
+
- **`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.
|
|
43
|
+
|
|
10
44
|
## [0.61.0] - 2026-10-02
|
|
11
45
|
|
|
12
46
|
### Upgrade notes
|
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ steps).
|
|
|
98
98
|
|
|
99
99
|
## Status
|
|
100
100
|
|
|
101
|
-
The current release is `v0.
|
|
101
|
+
The current release is `v0.62.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 (
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
|
224
|
-
* each, so the probe's non-decreasing-offset contract holds.
|
|
225
|
-
|
|
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 {
|
|
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 (
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
887
|
-
* harness-commented table
|
|
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
|
-
|
|
988
|
-
|
|
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 = [];
|