@lannguyensi/harness 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +20 -6
- package/dist/cli/apply/install-codex-config.js +181 -80
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +6 -6
- package/dist/cli/approve/understanding.js +29 -31
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/bounded-stdin.d.ts +27 -0
- package/dist/cli/bounded-stdin.js +82 -0
- package/dist/cli/bounded-stdin.js.map +1 -0
- package/dist/cli/doctor/settings-drift.js +12 -2
- package/dist/cli/doctor/settings-drift.js.map +1 -1
- package/dist/cli/pack/auto-approve-path.js +23 -3
- package/dist/cli/pack/auto-approve-path.js.map +1 -1
- package/dist/cli/pack/hook-bootstrap.d.ts +71 -5
- package/dist/cli/pack/hook-bootstrap.js +92 -18
- package/dist/cli/pack/hook-bootstrap.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.d.ts +6 -0
- package/dist/cli/pack/hook-branch-protection.js +11 -2
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +6 -0
- package/dist/cli/pack/hook-codex-pre-tool-use.js +20 -2
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate.d.ts +6 -0
- package/dist/cli/pack/hook-post-merge-gate.js +17 -2
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.d.ts +7 -0
- package/dist/cli/pack/hook-pre-tool-use.js +25 -4
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-runtime-reality.d.ts +2 -0
- package/dist/cli/pack/hook-runtime-reality.js +49 -9
- package/dist/cli/pack/hook-runtime-reality.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +6 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +11 -2
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.d.ts +6 -0
- package/dist/cli/pack/hook-solution-acceptance.js +11 -2
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/hook-subagent-start.d.ts +2 -0
- package/dist/cli/pack/hook-subagent-start.js +21 -4
- package/dist/cli/pack/hook-subagent-start.js.map +1 -1
- package/dist/cli/policy/intercept.d.ts +2 -0
- package/dist/cli/policy/intercept.js +9 -12
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/record/index.js +97 -21
- package/dist/cli/record/index.js.map +1 -1
- package/dist/cli/session-start/branch-check.js +4 -1
- package/dist/cli/session-start/branch-check.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +0 -15
- package/dist/cli/session-start/index.js +4 -62
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/shared-options.d.ts +15 -4
- package/dist/cli/session-start/shared-options.js +13 -15
- package/dist/cli/session-start/shared-options.js.map +1 -1
- package/dist/cli/session-start/stale-base-check.js +4 -1
- package/dist/cli/session-start/stale-base-check.js.map +1 -1
- package/dist/cli/session-start/toolchain-parity.js +4 -1
- package/dist/cli/session-start/toolchain-parity.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate.js +5 -2
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +48 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +70 -11
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +3 -2
- package/dist/policy-packs/builtin/understanding-before-execution.js +4 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/runtime/deletion-target-resolve.js +24 -1
- package/dist/runtime/deletion-target-resolve.js.map +1 -1
- package/dist/runtime/destructive-shell-floor.js +15 -1
- package/dist/runtime/destructive-shell-floor.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +26 -0
- package/dist/runtime/shell-word.js +94 -7
- package/dist/runtime/shell-word.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.61.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Upgrade notes
|
|
13
|
+
|
|
14
|
+
- Re-run `harness apply` once. The understanding gate's SubagentStart hook command is now emitted with the `UNDERSTANDING_GATE_REPORT_DIR=<dir>` prefix like the other report-reading hooks; without it the new report-hash cross-check at subagent start cannot find the reports of a global manifest.
|
|
15
|
+
- Every PreToolUse gate of the pack hooks (`pre-tool-use`, `codex-pre-tool-use`, `branch-protection`, `solution-acceptance`, `solution-acceptance-writeguard`, `post-merge-gate`, `runtime-reality`) now blocks when its stdin does not deliver a complete, closed event within 3000 ms of idle time, instead of waiting for the host's hook timeout. Claude Code and Codex close stdin after writing the event, so normal use is unaffected; `harness pause` still wins.
|
|
16
|
+
- `harness apply --runtime codex --install` refuses a `~/.codex/config.toml` the TOML parser cannot read when the parse error sits before an already current harness block (exit 1, file untouched); it used to report "already up to date". Fix the TOML and re-run.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **The `harness pack hook` verbs no longer hang when stdin is never closed, and the PreToolUse gates block on a timed-out read** (task `7dfdcaaf`, follow-up to `c8cfc110`). The shared reader in `src/cli/pack/hook-bootstrap.ts` (used by the 15 pack hook verbs that read an event on stdin) and the reader in `src/cli/pack/hook-runtime-reality.ts` read to end of input, so an open pipe that never closes held the hook until the host's own hook timeout. Both now share the idle-bounded reader in `src/cli/bounded-stdin.ts` (3000 ms without a chunk; a slow but live pipe is not cut off). A PreToolUse gate (`pre-tool-use`, `codex-pre-tool-use`, `branch-protection`, `solution-acceptance`, `solution-acceptance-writeguard`, `post-merge-gate`, `runtime-reality`) fails closed on a timed-out read, whether nothing or part of the event arrived: it blocks the tool call with a reason starting `stdin timeout:` that names the 3000 ms bound (Claude Code gates emit their usual block envelope and exit 0, `codex-pre-tool-use` exits 2 with the reason on stderr, `runtime-reality` emits its deny envelope and exits 2), instead of applying its empty or malformed event handling; the operator pause still wins. Without this, a host that stalled for more than 3 s and then wrote a complete gated event and closed would have turned a block into an allow. Every other pack hook (PostToolUse, Stop, SubagentStart, SubagentStop, UserPromptSubmit and the producers) treats a timeout as the bytes it read: one stderr note (`no complete event JSON on stdin within 3000 ms (stdin never closed); continuing as an empty event`, or `stdin did not close within 3000 ms of the last data; using the N bytes read`) and it carries on with the text read so far. Nothing changes for input that arrives and closes within the bound; hook output formats are unchanged.
|
|
21
|
+
|
|
22
|
+
- **The understanding gate's hook-reachable reads of the reports directory and parse-error logs are bounded (the evidence read, the PostToolUse boundary expiry, the auto-approval precondition and its consume step, the subagent-delegation lookup, the parse-error lookup), so planted files can no longer hang or kill the hooks on those reads (the consume step's write is not covered, see the last sentence of this entry); the operator commands `harness approve understanding` and `harness gc` stay unbounded on purpose** (task `4b39022f`, closes residual (4) named under 0.60.0 for the evidence read). The read behind `checkPersistedReport` (the block diagnostic of both PreToolUse hooks, on the no-marker path and after a refused marker) and behind `expirePersistedReport` (the PostToolUse boundary expiry) listed the reports directory with a separate `stat` and then `readFileSync`: a `*.json` symlink flipped to a FIFO between the two hung the hook (measured 4/20 runs on the Claude hook, 1/20 on the Codex hook), and one file of a few hundred megabytes gave a V8 fatal error and no decision on both; each ends as a non-blocking error for the runtime, i.e. the call proceeds. The same unbounded listing sat in the auto-approval precondition and the subagent-delegation lookup (reached on the same paths whenever `auto_approve` is configured, which the `harness init` default is for `bypassPermissions`), and the parse-error lookup read `parse-errors/*.log` with a `stat` and `readFileSync` too. All of them now open each entry once through `readReportFileBounded` (read-only, `O_NONBLOCK`, `fstat` on the descriptor, regular files up to 1 MiB, read through the descriptor) and skip anything else (a FIFO, a directory, a device, a symlink to one, a file over the cap, an unreadable file, one that grows while read) as if it were not in the directory. The auto-approval precondition also declines, so the call stays blocked, when its listing had to skip any entry: an oversized or unreadable newest report never lets it fall back to an older pending one. Decisions are unchanged: the same deny with the same reason on the no-marker path and after a refused marker, with a FIFO or a 2 MiB file next to the reports; a regular report within the cap is read, sorted and reported exactly as before. Consequences: a report over 1 MiB is no longer picked up as the session's evidence, so a block diagnostic that would have named it (`unsigned persisted-report approval rejected: ...`) now reads `no reports found`; the boundary expiry no longer rewrites such a file and acts on the newest report that was within the cap (when the newest approved report is oversized, the next in-cap one is expired); a parse-error log over the cap is not surfaced. `harness approve understanding` and `harness gc` keep the unbounded listing on purpose, so an oversized report is still found and refused or aged out. The auto-approval consume step no longer re-reads the report by path: it rewrites the text the precondition read through the bounded reader, and the write is a temp file renamed over the entry, which never opens what a symlink points at. Before, a report symlink retargeted to a FIFO or a few-hundred-megabyte file between the precondition and the consume hung or killed the hook in about a third of the attempts on both runtimes. `harness approve understanding` (including `--force`) still reads the report by path. Still open: the write path of the consume step, whose atomic write opens its temp file in the agent-writable reports directory without `O_EXCL`/`O_NOFOLLOW`, so a FIFO planted at the temp name can still hang the hook (pre-existing, tracked separately); the total volume of planted files (a separate residual); an agent-planted oversized report crashes `harness approve understanding` itself until the file is deleted (`docs/okf/understanding-gate-lockout-recovery.md`).
|
|
23
|
+
|
|
24
|
+
- **`harness apply --runtime codex --install` no longer refuses a legacy block with no hook table in it, its no-END refusal no longer offers an END position that keeps a harness hook running, and marker text inside a value is no longer read as a managed-block marker** (task `36d962d2`, follow-up to `e8f4fc03`). (1) A legacy config (no BEGIN marker) whose source-prefix line, with or without the generated header, sits directly above an operator table was refused when that table was a hook event table spelled without the `[[hooks.` prefix (`[[ hooks.Stop ]]`) and no END followed, or when a surviving END followed any foreign table: the split-block zone started at the block's own opening line, which is itself a split-block marker. The zone now starts after that line, so these configs install (the operator table is kept and named, an orphan END is excised, a second install gives the same bytes); a harness marker past the opening line still refuses. (2) When no END marker exists and a harness-commented hook table follows the named table, in either spelling (`[[hooks.Stop]]` or `[[ hooks.Stop ]]`), the refusal said to add an END right after the last harness hook table (before the named table), which contradicts itself and, followed by its parenthetical, left the later harness table running next to the fresh block. That option is now offered only when no harness-commented hook table follows; otherwise the refusal offers moving the named table below the last harness hook table. (3) Every managed-block marker (the BEGIN and END lines, the source-prefix line, the generated header and the `# harness hook:` comment) is now found by one rule: a whole trimmed line (equal to BEGIN or END, starting with the others) at the top level of the document. Before, BEGIN was found anywhere in the text, so BEGIN text in a string value or a trailing comment of an operator hook table made the install silently delete operator content after it (a key, or the whole next operator hook table), and BEGIN text in a multi-line string had the fresh block spliced into that string, growing on every install; the source-prefix and generated-header text opened a legacy block at the closing line of a multi-line string or at a trailing comment; an END comment line inside a multi-line array ended the block there or was excised as a stray END; and a `# harness hook:` line inside a string was reported as a removed hook. Marker text in a value (a single-line or multi-line string, a trailing comment, an inline table, an element or a comment line of a multi-line array) is now operator content: the install writes a fresh block and leaves that value as it was, a second install gives the same bytes, an END line that no block owns stays, and BEGIN text in a value is no longer refused as a second BEGIN marker. The same top-level line rule now decides whether a generated-header line opens a legacy block, which needs a hook table header line (`[[hooks.`, the older `[[hooks.pre_tool_use]]` included) after it: `[[hooks.` text in a string value, a multi-line string line or a comment line below a generated-header comment in an operator hook table opened that block, and the install replaced the operator's keys after the header line with the fresh block; the block is now appended and those keys stay. Two marker searches still match marker text as substrings: the split-block refusal's candidate list, which can only refuse, and the trailing comment run at the end of the scan, whose lines are whole top-level comment lines. Known boundary, unchanged: that run (blank and comment lines ending the file after a block with no END marker) is consumed with the block when the source-prefix, generated-header or `# harness hook:` text appears anywhere in it, so an operator comment there that mentions such text mid-line is removed. Behaviour change for a config the TOML parser used by harness cannot read: when its parse error sits before an already current block, the install reported "already up to date" and now refuses (exit 1, file untouched, no backup), since a marker line after the parse error is not at the top level of a readable prefix; a parse error after a current block still reports up to date. Tests pin all three, the generated-header shapes and the unreadable-config refusal, plus the substring refusal and the escaped `hooks` root key on the legacy no-END path. The stale `# Harness Codex hook wiring. Generated source:` and `# Generated by harness apply --runtime codex.` lines of an empty legacy block still stay where they were (as before for a block followed by a foreign table); they are not removed.
|
|
25
|
+
|
|
26
|
+
- **`harness record dogfood` refuses a session id with leading or trailing whitespace** (task `eed7c036`). The verb checked the trimmed session id for the `dogfood:<session>` content but wrote the fact into the untrimmed session, so `--session ' s1'` produced content `dogfood:s1` in session `' s1'`. It now exits `64` naming `--session` and writes nothing, consistent with the other record validation, rather than trimming, so content and target can never name different ids. The other record verbs and the ledger write are unchanged. Docs only alongside: the `ResolvePackOptions.reportsDir` comment says the `UNDERSTANDING_GATE_REPORT_DIR` prefix goes on each report-reading hook command (not every contributed hook), and the in-flight subagent dogfood README marks `payloads/rendered-settings.json` as a historical capture that predates the prefixed `subagent-start` command.
|
|
27
|
+
|
|
28
|
+
- **`harness doctor` no longer aborts on a malformed `.last-apply` when the understanding pack is declared** (task `3128df7a`). The settings-drift check read the record unguarded, so unparseable JSON or a record without a `files` map threw out of doctor. It now skips its comparison and adds a warning naming the file (`<generated dir>/.last-apply is unreadable (...); settings drift not checked`), which counts toward `warningCount`, the same shape as the runtime-selection fallback. A well-formed record gives the same output as before.
|
|
29
|
+
|
|
30
|
+
- **`harness record review` / `review-subagent` / `dogfood` reject values that would plant a second ledger tag** (task `237cc609`). The verbs write their fact as space-separated tag text and the gates match tags by substring, so `--branch "x review-subagent:master"`, `--base "x review-subagent:master"` or `--verdict "ok review-subagent:master"` also satisfied another branch's `review-subagent-before-pr-create-bash` gate. Every ref-like or id-like value embedded in a tag (`--branch`, including a branch resolved from git; `--base`; `--task`; `--pr`; the session id `record dogfood` writes as `dogfood:<session>`) is now refused when the trimmed value contains a `:` or any ASCII whitespace, which git ref names forbid anyway, so no real branch is refused and every tag namespace, custom ones included, is closed for these values without a list (free text is checked only against the six gate-opening namespaces listed here, so a custom namespace there is not refused); Unicode whitespace without a `:` stays accepted. Free text (`--verdict` and the summaries of all three verbs) stays free and is rejected (not rewritten, so the audit text is never altered) only when it carries `review-subagent:`, `review:`, `dogfood:`, `preflight:`, `risk-approved:` or `risk-override:` immediately followed by a non-whitespace character; prose like `code-review: approved` is kept. Each refusal exits `64` naming the flag, and nothing is written. Task, PR and branch values still share tag namespaces (`record review-subagent --task master` writes `review-subagent:master`, `record review --pr master` writes `review:master`), so a value that is itself a valid branch name is not stopped. Hardening of the advisory ledger only: the substring matcher and `ledger_add` are unchanged, and docs/CLI.md "Tag-injection validation" states the rules.
|
|
31
|
+
|
|
32
|
+
- **`harness pack hook subagent-start` no longer mints an in-flight subagent record under an approval whose report was edited** (task `dac02d5c`, closes residual (2) named under 0.60.0). After a parent marker matched, the hook now applies the same gate-read report-hash cross-check as both PreToolUse hooks (`verifyMatchedMarkerReport`, task marker first, session marker as fallback) before `writeInflightRecord`; when no report file carries the signed content any more it writes no record and says so on stderr (`approval refused, no report in the reports directory matches the content ...; no in-flight record for agent <id>`), so the subagent has nothing to present once the parent marker stops matching (claim switch, `max_age`) and blocks. An intact approval and a null-hash marker mint exactly as before; a task marker that fails the check while the session marker behind it verifies mints a record that names the session marker. The record format and its 24 h staleness window are unchanged. Codex has no SubagentStart wiring, so this is the Claude hook only. **Upgrade:** re-run `harness apply` once. The SubagentStart hook command is now emitted with the same `UNDERSTANDING_GATE_REPORT_DIR=<manifest-anchored dir>` prefix as the other report-reading hooks, so it reads the directory `harness approve understanding` wrote to; a settings file generated before this change keeps the bare command, which falls back to `<cwd>/.understanding-gate/reports`, a directory that usually does not exist, so the cross-check there neither sees the reports nor refuses an edited one.
|
|
33
|
+
|
|
34
|
+
- **A destructive command no longer escapes the risk floor or the deletion gate because one word carries a NUL-decoding ANSI-C escape** (task `5cc64860`, follow-up to `241d9e9e`). bash truncates a `$'...'` run at a NUL (measured on GNU bash 3.2.57, `printf '[%s]' <word> | od -c`; the first report measured 5.1.16), so `git push $'--force\0' origin`, `git reset $'--hard\0'`, `find / $'-delete\c@'`, `$'dd\0' of=/dev/sda` and `$'rm\0' -rf /home/x` run as their plain twins. The decoder keeps a literal U+0000 in the decoded word, so every exact comparison (a whole flag, a head name, a subcommand) in `src/runtime/destructive-shell-floor.ts` and `src/runtime/deletion-target-resolve.ts` missed them: the floor reported nothing (the plain twins are `high`, `high`, `critical`, `critical`) and the deletion gate made no decision for the `rm` form. Both scanners now examine the command a second time with every `$'...'` run that carries such an escape replaced by a single-quoted literal of the value bash passes, and keep both results: the floor keeps the hits of both scans, the resolver combines both verdicts (unresolvable if either reading is), so the second reading can only add a verdict, never remove one. `shell-word.ts` gains `decodeShellWordTruncatingNul` (each run cut at its first escape that decodes to NUL or any `\c` escape, the text around the run kept: `x$'a\0b'y` is `xay`) and `truncateNulRuns` (the rewrite of a whole command text, or `null` when no run carries such an escape, so a command without one is scanned once as before); the rewrite happens before the boundary split, so a `;`, `|` or `&` inside the cut-off part of a run (`git push $'--force\0;' origin`) disappears with it instead of tearing the word. `decodeShellWord`, its decoded values and the read-only classifier are unchanged. This is the comparison-only truncation variant, not a blanket fail-closed on any NUL word: a NUL escape in a harmless place (`echo $'a\0b'`, `git push $'origin\0' main`) stays as unclassified as before. It also gates a target that a NUL cuts back to a safe root itself (`rm -rf $'/tmp/\0x'` reaches bash as `/tmp/`, the safe root itself, which was not gated before). Over-cutting is the accepted direction: `\c` escapes other than `\c@` and `\u`/`\U` escapes that bash 3.2.57 leaves literal are cut too, which only adds a candidate reading, and a `$'` that bash reads inside single quotes is rewritten like a real run. A run without a closing quote is left as written. Measured base versus head over 276 commands (207 with a NUL escape: the read-only NUL corpus of `241d9e9e`, every NUL string in the runtime tests, the five commands above; 69 without one, among them the plain twins): no floor hit, no classified severity and no deletion verdict is lower at head, the 69 commands without a NUL escape give identical results, and 105 NUL commands are stricter; 27 of 27 sample words decode through the rewrite to exactly the bytes `/bin/bash` 3.2.57 passes. Pinned in `tests/runtime/destructive-shell-floor.test.ts`, `tests/runtime/deletion-target-resolve.test.ts`, `tests/runtime/intercept-deletion-gate.test.ts` and `tests/runtime/shell-word.test.ts`.
|
|
35
|
+
|
|
36
|
+
- **`harness policy intercept`'s stdin-timeout note now says what it does when nothing was read** (task `c5831891`, follow-up to `c8cfc110`). The shared note for an idle, empty stdin ended with `falling back to the default session resolution`, which fits the session-start producers but not `policy intercept`, which continues as an empty event. `stdinTimeoutNote` takes an optional caller tail; intercept passes `continuing as an empty event`, and the session-start producers' text is unchanged byte for byte. The idle-stdin tests in `tests/cli/bounded-stdin.test.ts` also restore the working directory in their `afterEach` cleanup before the temp dir is removed, so a regressed bound fails only the bound tests instead of breaking every later test with a deleted cwd.
|
|
37
|
+
|
|
38
|
+
- **`harness session-start branch-check`, `stale-base-check`, `toolchain-parity` and `harness policy intercept` no longer hang when stdin is never closed** (task `c8cfc110`). They awaited stdin's `end` with no bound, so a backgrounded compound command (or any caller holding an open pipe) left the hook idle forever. All five stdin-reading hook-style entries (these four and `preflight`) now share one idle-bounded reader (`src/cli/bounded-stdin.ts`): the 3 s idle timer restarts on every chunk, so a slow but live payload is still read in full, and a timeout writes a stderr note and carries on with what was read (an empty read is an empty event, partial text is parsed). `policy intercept` keeps its fail posture: malformed event JSON still exits 0 with no decision and a stderr note. Hook output formats are unchanged. The pack hooks (`hook-bootstrap.ts`, `runtime-reality`) keep their own readers.
|
|
39
|
+
|
|
10
40
|
## [0.60.0] - 2026-10-01
|
|
11
41
|
|
|
12
42
|
### 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.61.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).
|
|
@@ -164,19 +164,33 @@ export declare function createTopLevelProbe(text: string): (lineStart: number) =
|
|
|
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
166
|
* fix (add one, or move the table below the last harness hook table) is
|
|
167
|
-
* offered instead (task 6a037359).
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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).
|
|
171
|
+
* When the named table is a hook event table spelled without the `[[hooks.`
|
|
172
|
+
* prefix, the guidance offers deleting it if harness wrote it, since the scan
|
|
173
|
+
* reads such a table as foreign with or without a comment line above it
|
|
174
|
+
* (task e8f4fc03). The legacy paths call
|
|
171
175
|
* this too, so the substring matching above applies there as well: a value
|
|
172
|
-
* holding marker text refuses.
|
|
176
|
+
* holding marker text refuses. This candidate list and the trailing comment
|
|
177
|
+
* run of `scanOwnedContentEnd` are the two marker searches that do not go
|
|
178
|
+
* through `createMarkerLineProbe`, on purpose. This one can only refuse,
|
|
179
|
+
* never install, so matching marker text in a value costs a refusal, never
|
|
180
|
+
* operator content. The comment run's lines are whole top-level comment
|
|
181
|
+
* lines, so marker text there is never inside a value, but that run (blank
|
|
182
|
+
* and comment lines ending the file after a block with no END marker) is
|
|
183
|
+
* consumed when the source prefix, the generated header or the
|
|
184
|
+
* `# harness hook:` text appears anywhere in it, so an operator comment
|
|
185
|
+
* there that mentions such text mid-line is removed with the block.
|
|
186
|
+
* `blockOpening` names the line the block
|
|
173
187
|
* starts at in the delete-the-lines fix: the BEGIN marker by default; a
|
|
174
188
|
* legacy config (no BEGIN marker) whose END marker survived passes its
|
|
175
189
|
* source-prefix line or generated header instead, since a BEGIN marker it
|
|
176
190
|
* does not have is no line to delete from (task 01053b27). For the generated
|
|
177
191
|
* header the fix deletes that line and the END marker line too: a generated
|
|
178
192
|
* header left alone above END no longer marks a harness block
|
|
179
|
-
* (`findManagedRange` needs a `[[hooks.` line after it), so the install
|
|
193
|
+
* (`findManagedRange` needs a top-level `[[hooks.` header line after it), so the install
|
|
180
194
|
* would add a fresh block and leave that pair behind as an orphan. Exported for
|
|
181
195
|
* direct tests only: the scan
|
|
182
196
|
* that feeds it never hands it a line that is not a real header, so its own
|
|
@@ -65,11 +65,6 @@ const HOOK_ID_LINE_RE = /^# harness hook: (.+) \(budget_ms=\d+\)$/;
|
|
|
65
65
|
// the two apart by the `# harness hook:` comment above the header instead
|
|
66
66
|
// (see `scanOwnedContentEnd`).
|
|
67
67
|
const HOOK_ARRAY_HEADER_RE = /^\[\[hooks\./;
|
|
68
|
-
/** True when a run of comment/blank lines holds a line the generator writes
|
|
69
|
-
* above every hook table (`# harness hook: <id> (budget_ms=N)`). */
|
|
70
|
-
function runHoldsHarnessHookComment(run) {
|
|
71
|
-
return run.split("\n").some((line) => line.trim().startsWith(HARNESS_HOOK_COMMENT_PREFIX));
|
|
72
|
-
}
|
|
73
68
|
export function defaultCodexConfigPath(homeDir) {
|
|
74
69
|
return path.join(homeDir ?? os.homedir(), ".codex", "config.toml");
|
|
75
70
|
}
|
|
@@ -189,35 +184,65 @@ function nextTripleDelim(rawLine, delimIn) {
|
|
|
189
184
|
return delim;
|
|
190
185
|
}
|
|
191
186
|
/**
|
|
192
|
-
* The
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
187
|
+
* The one rule every managed-block marker is located by (task 36d962d2): a
|
|
188
|
+
* line is a marker line only when its trimmed text is the marker (`whole`)
|
|
189
|
+
* or starts with it (`prefix`) AND it sits at the top level of the document
|
|
190
|
+
* (`createTopLevelProbe`). Marker text anywhere else is operator content:
|
|
191
|
+
* inside a single-line or multi-line string (basic or literal), after a
|
|
192
|
+
* content line as a trailing comment, inside an inline table, or on a line
|
|
193
|
+
* inside a multi-line array (an element, or a comment line between
|
|
194
|
+
* elements). Treating such text as a marker made the install replace,
|
|
195
|
+
* delete, or splice a fresh block into the operator's value. Every lookup
|
|
196
|
+
* of BEGIN, END, the source prefix, the generated header and the
|
|
197
|
+
* `# harness hook:` comment goes through this probe or `findMarkerLine`, and
|
|
198
|
+
* so does the `[[hooks.` header line that lets a generated header open a
|
|
199
|
+
* legacy block (`findManagedRange`).
|
|
200
|
+
* Two marker searches stay outside it on purpose: the split-block refusal's
|
|
201
|
+
* candidate list (`assertNoSplitBlock`), which can only refuse, never
|
|
202
|
+
* install, and the trailing comment run of `scanOwnedContentEnd`, whose
|
|
203
|
+
* lines are whole top-level comment lines by construction. That run (blank
|
|
204
|
+
* and comment lines ending the file after a block with no END marker) is
|
|
205
|
+
* consumed as stale harness output when the source prefix, the generated
|
|
206
|
+
* header or the `# harness hook:` text appears anywhere in it, so an
|
|
207
|
+
* operator comment there that mentions such text mid-line is removed with
|
|
208
|
+
* the block.
|
|
209
|
+
*
|
|
210
|
+
* The returned probe answers for line starts queried in non-decreasing
|
|
211
|
+
* order (the top-level probe's contract, which a line that fails the text
|
|
212
|
+
* test never touches).
|
|
207
213
|
*/
|
|
208
|
-
function
|
|
214
|
+
function createMarkerLineProbe(text) {
|
|
215
|
+
const topLevel = createTopLevelProbe(text);
|
|
216
|
+
return (lineStart, marker, shape) => {
|
|
217
|
+
const trimmed = text.slice(lineStart, lineEndAfter(text, lineStart)).trim();
|
|
218
|
+
const textMatches = shape === "whole" ? trimmed === marker : trimmed.startsWith(marker);
|
|
219
|
+
return textMatches && topLevel(lineStart);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** The start of the first marker line (`createMarkerLineProbe`) at or after
|
|
223
|
+
* the line start `from`, or -1. Candidates are visited in order, one line
|
|
224
|
+
* each, so the probe's non-decreasing-offset contract holds. */
|
|
225
|
+
function findMarkerLine(text, marker, shape, from = 0) {
|
|
226
|
+
const isMarkerLine = createMarkerLineProbe(text);
|
|
209
227
|
let pos = from;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
for (;;) {
|
|
229
|
+
const idx = text.indexOf(marker, pos);
|
|
230
|
+
if (idx === -1)
|
|
231
|
+
return -1;
|
|
232
|
+
const lineStart = lineStartAt(text, idx);
|
|
233
|
+
if (isMarkerLine(lineStart, marker, shape))
|
|
234
|
+
return lineStart;
|
|
235
|
+
pos = lineEndAfter(text, lineStart);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** True when a line in `[from, to)` is a marker line for the prefix
|
|
239
|
+
* `marker`. */
|
|
240
|
+
function rangeHoldsMarkerLine(text, from, to, marker, isMarkerLine) {
|
|
241
|
+
for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
|
|
242
|
+
if (isMarkerLine(pos, marker, "prefix"))
|
|
243
|
+
return true;
|
|
219
244
|
}
|
|
220
|
-
return
|
|
245
|
+
return false;
|
|
221
246
|
}
|
|
222
247
|
/** Answers, for a line start in `text`, whether that line sits at the top
|
|
223
248
|
* level of the document, that is, not inside a multi-line array, a multi-line
|
|
@@ -399,6 +424,7 @@ function scanOwnedContentEnd(text, start) {
|
|
|
399
424
|
let sawOwnedContent = false;
|
|
400
425
|
let sawMarkedHookTable = false;
|
|
401
426
|
const classify = createLineClassifier(text);
|
|
427
|
+
const isMarkerLine = createMarkerLineProbe(text);
|
|
402
428
|
while (pos < text.length) {
|
|
403
429
|
const lineEnd = lineEndAfter(text, pos);
|
|
404
430
|
const rawLine = text.slice(pos, lineEnd);
|
|
@@ -409,7 +435,7 @@ function scanOwnedContentEnd(text, start) {
|
|
|
409
435
|
pos = lineEnd;
|
|
410
436
|
continue;
|
|
411
437
|
}
|
|
412
|
-
if (
|
|
438
|
+
if (isMarkerLine(pos, CODEX_MANAGED_END, "whole")) {
|
|
413
439
|
return { end: lineEnd, sawEndMarker: true };
|
|
414
440
|
}
|
|
415
441
|
if (kind === "comment") {
|
|
@@ -423,7 +449,7 @@ function scanOwnedContentEnd(text, start) {
|
|
|
423
449
|
}
|
|
424
450
|
if (kind === "hookHeader") {
|
|
425
451
|
const marked = commentRunStart !== null &&
|
|
426
|
-
|
|
452
|
+
rangeHoldsMarkerLine(text, commentRunStart, pos, HARNESS_HOOK_COMMENT_PREFIX, isMarkerLine);
|
|
427
453
|
if (marked) {
|
|
428
454
|
sawMarkedHookTable = true;
|
|
429
455
|
}
|
|
@@ -449,6 +475,11 @@ function scanOwnedContentEnd(text, start) {
|
|
|
449
475
|
if (commentRunStart !== null && sawOwnedContent) {
|
|
450
476
|
const run = text.slice(commentRunStart, pos);
|
|
451
477
|
const hasComment = /(^|\n)[ \t]*#/.test(run);
|
|
478
|
+
// Matched as substrings on purpose, outside `createMarkerLineProbe`:
|
|
479
|
+
// every line of this run is a whole comment or blank line at the top
|
|
480
|
+
// level (a string line resets the run, and at the end of a document that
|
|
481
|
+
// parses nothing is still open), so marker text here is never inside a
|
|
482
|
+
// value, and a comment that mentions a marker is stale harness output.
|
|
452
483
|
const harnessAuthored = run.includes(HARNESS_HOOK_COMMENT_PREFIX) ||
|
|
453
484
|
run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
|
|
454
485
|
run.includes(GENERATED_HEADER);
|
|
@@ -458,6 +489,33 @@ function scanOwnedContentEnd(text, start) {
|
|
|
458
489
|
}
|
|
459
490
|
return { end: pos, sawEndMarker: false };
|
|
460
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* True when a harness-commented hook table (a `# harness hook:` comment line
|
|
494
|
+
* followed, later in the zone, by a `[[hooks.` header or a hook event array
|
|
495
|
+
* header spelled without that prefix, `[[ hooks.SessionStart ]]`) lies after the named
|
|
496
|
+
* table's header line, before `to`. The comment is found as a marker line
|
|
497
|
+
* (`createMarkerLineProbe`), so a value that merely contains the marker text
|
|
498
|
+
* does not count. Used only to pick the fix guidance of a refusal (task
|
|
499
|
+
* 36d962d2).
|
|
500
|
+
*/
|
|
501
|
+
function harnessHookTableFollows(text, header, to) {
|
|
502
|
+
if (header === null)
|
|
503
|
+
return false;
|
|
504
|
+
let pos = lineEndAfter(text, header.start);
|
|
505
|
+
let sawComment = false;
|
|
506
|
+
const isMarkerLine = createMarkerLineProbe(text);
|
|
507
|
+
while (pos < to) {
|
|
508
|
+
const lineEnd = Math.min(lineEndAfter(text, pos), to);
|
|
509
|
+
const trimmed = text.slice(pos, lineEnd).trim();
|
|
510
|
+
if (isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
|
|
511
|
+
sawComment = true;
|
|
512
|
+
else if (sawComment && (HOOK_ARRAY_HEADER_RE.test(trimmed) || isHookEventArrayHeader(trimmed))) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
pos = lineEnd;
|
|
516
|
+
}
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
461
519
|
/**
|
|
462
520
|
* Refuses loudly (mirror case of the stray-END bug) when the zone between
|
|
463
521
|
* `from` and `to` still contains a recognizable fragment of a harness
|
|
@@ -486,19 +544,33 @@ function scanOwnedContentEnd(text, start) {
|
|
|
486
544
|
* document (`to` is `text.length`), telling the operator to move something
|
|
487
545
|
* below a marker that does not exist would be nonsensical, so a different
|
|
488
546
|
* fix (add one, or move the table below the last harness hook table) is
|
|
489
|
-
* offered instead (task 6a037359).
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
547
|
+
* offered instead (task 6a037359). Adding one is offered only when no
|
|
548
|
+
* harness-commented hook table follows the named table
|
|
549
|
+
* (`harnessHookTableFollows`): an END placed before the named table would
|
|
550
|
+
* leave that later table running next to the fresh block (task 36d962d2).
|
|
551
|
+
* When the named table is a hook event table spelled without the `[[hooks.`
|
|
552
|
+
* prefix, the guidance offers deleting it if harness wrote it, since the scan
|
|
553
|
+
* reads such a table as foreign with or without a comment line above it
|
|
554
|
+
* (task e8f4fc03). The legacy paths call
|
|
493
555
|
* this too, so the substring matching above applies there as well: a value
|
|
494
|
-
* holding marker text refuses.
|
|
556
|
+
* holding marker text refuses. This candidate list and the trailing comment
|
|
557
|
+
* run of `scanOwnedContentEnd` are the two marker searches that do not go
|
|
558
|
+
* through `createMarkerLineProbe`, on purpose. This one can only refuse,
|
|
559
|
+
* never install, so matching marker text in a value costs a refusal, never
|
|
560
|
+
* operator content. The comment run's lines are whole top-level comment
|
|
561
|
+
* lines, so marker text there is never inside a value, but that run (blank
|
|
562
|
+
* and comment lines ending the file after a block with no END marker) is
|
|
563
|
+
* consumed when the source prefix, the generated header or the
|
|
564
|
+
* `# harness hook:` text appears anywhere in it, so an operator comment
|
|
565
|
+
* there that mentions such text mid-line is removed with the block.
|
|
566
|
+
* `blockOpening` names the line the block
|
|
495
567
|
* starts at in the delete-the-lines fix: the BEGIN marker by default; a
|
|
496
568
|
* legacy config (no BEGIN marker) whose END marker survived passes its
|
|
497
569
|
* source-prefix line or generated header instead, since a BEGIN marker it
|
|
498
570
|
* does not have is no line to delete from (task 01053b27). For the generated
|
|
499
571
|
* header the fix deletes that line and the END marker line too: a generated
|
|
500
572
|
* header left alone above END no longer marks a harness block
|
|
501
|
-
* (`findManagedRange` needs a `[[hooks.` line after it), so the install
|
|
573
|
+
* (`findManagedRange` needs a top-level `[[hooks.` header line after it), so the install
|
|
502
574
|
* would add a fresh block and leave that pair behind as an orphan. Exported for
|
|
503
575
|
* direct tests only: the scan
|
|
504
576
|
* that feeds it never hands it a line that is not a real header, so its own
|
|
@@ -550,11 +622,17 @@ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blo
|
|
|
550
622
|
: `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
|
|
551
623
|
"harness-owned tables remain between them",
|
|
552
624
|
]
|
|
553
|
-
:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
625
|
+
: harnessHookTableFollows(text, foreignHeaderLine, zoneEnd)
|
|
626
|
+
? // An END marker placed before the named table would leave the
|
|
627
|
+
// harness-commented hook table after it running next to the fresh
|
|
628
|
+
// block, so the only fix offered is moving the named table past it
|
|
629
|
+
// (task 36d962d2).
|
|
630
|
+
[`move ${firstForeignHeader} below the last harness hook table`]
|
|
631
|
+
: [
|
|
632
|
+
`add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
|
|
633
|
+
`(before ${firstForeignHeader})`,
|
|
634
|
+
`move ${firstForeignHeader} below the last harness hook table`,
|
|
635
|
+
];
|
|
558
636
|
// An uncommented hook table in the middle may be a harness table whose
|
|
559
637
|
// comment line was deleted, so moving it (the operator-table fix) is only
|
|
560
638
|
// right for an operator-owned one; name the restore option first (task
|
|
@@ -690,10 +768,16 @@ function collectForeignSectionHeaders(text, from, to, kept = []) {
|
|
|
690
768
|
return count > 1 ? `[${root}.*] (${count} tables)` : header;
|
|
691
769
|
});
|
|
692
770
|
}
|
|
693
|
-
|
|
771
|
+
/** The hook ids named by the `# harness hook:` marker lines
|
|
772
|
+
* (`createMarkerLineProbe`) in `[from, to)` of `text`, so a value holding
|
|
773
|
+
* that text is never reported as a removed hook. */
|
|
774
|
+
function extractHookIds(text, from = 0, to = text.length) {
|
|
694
775
|
const ids = new Set();
|
|
695
|
-
|
|
696
|
-
|
|
776
|
+
const isMarkerLine = createMarkerLineProbe(text);
|
|
777
|
+
for (let pos = from; pos < to; pos = lineEndAfter(text, pos)) {
|
|
778
|
+
if (!isMarkerLine(pos, HARNESS_HOOK_COMMENT_PREFIX, "prefix"))
|
|
779
|
+
continue;
|
|
780
|
+
const m = HOOK_ID_LINE_RE.exec(text.slice(pos, lineEndAfter(text, pos)).trim());
|
|
697
781
|
if (m?.[1] !== undefined)
|
|
698
782
|
ids.add(m[1]);
|
|
699
783
|
}
|
|
@@ -765,8 +849,8 @@ function startPastBom(text, start) {
|
|
|
765
849
|
* `blockOpening` is the line the split-block refusal names as the start of
|
|
766
850
|
* the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
|
|
767
851
|
*/
|
|
768
|
-
function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
|
|
769
|
-
const strayIdx =
|
|
852
|
+
function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening, zoneFrom = end) {
|
|
853
|
+
const strayIdx = findMarkerLine(text, CODEX_MANAGED_END, "whole", end);
|
|
770
854
|
if (strayIdx === -1) {
|
|
771
855
|
assertNoSplitBlock(text, end, text.length, configPath, false);
|
|
772
856
|
const keptHookTables = collectKeptHookTables(text, end, text.length).tables;
|
|
@@ -775,7 +859,7 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
|
|
|
775
859
|
}
|
|
776
860
|
const strayLineStart = lineStartAt(text, strayIdx);
|
|
777
861
|
const strayLineEnd = lineEndAfter(text, strayIdx);
|
|
778
|
-
assertNoSplitBlock(text,
|
|
862
|
+
assertNoSplitBlock(text, zoneFrom, strayLineStart, configPath, true, blockOpening);
|
|
779
863
|
const keptHookTables = collectKeptHookTables(text, end, strayLineStart).tables;
|
|
780
864
|
const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart, keptHookTables);
|
|
781
865
|
return {
|
|
@@ -803,13 +887,17 @@ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
|
|
|
803
887
|
* harness-commented table there is kept and not named. The split-block
|
|
804
888
|
* refusal names `blockOpening` (the source-prefix line or the generated
|
|
805
889
|
* header) as the start of the block, since there is no BEGIN marker (task
|
|
806
|
-
* 01053b27)
|
|
807
|
-
*
|
|
808
|
-
*
|
|
890
|
+
* 01053b27); the split-block zone starts after that opening line, so a block
|
|
891
|
+
* with no hook table in it (the source prefix directly above an operator
|
|
892
|
+
* table) is not refused for its own opening line (task 36d962d2). The opening
|
|
893
|
+
* line is a marker line (`createMarkerLineProbe`), so marker text inside a
|
|
894
|
+
* value or after a content line opens no block.
|
|
895
|
+
* A scan that ended at a foreign table with no END marker anywhere after it
|
|
896
|
+
* keeps the legacy behaviour (no split-block check), except that hook event tables directly after the block are collected, named and
|
|
809
897
|
* checked (`collectKeptHookTables`), and a harness marker in their run
|
|
810
898
|
* refuses through the split-block check. The END marker is looked up as a
|
|
811
|
-
*
|
|
812
|
-
*
|
|
899
|
+
* marker line too, so END text in a foreign table's value does not route a
|
|
900
|
+
* config here. Like the BEGIN path,
|
|
813
901
|
* the split-block check matches its markers as substrings, so a value in the
|
|
814
902
|
* zone that holds marker text refuses too (see `assertNoSplitBlock`).
|
|
815
903
|
*/
|
|
@@ -817,8 +905,14 @@ function legacyManagedRange(text, start, scan, configPath, blockOpening) {
|
|
|
817
905
|
if (scan.sawEndMarker) {
|
|
818
906
|
return { start, end: scan.end, foreignSectionsPreserved: [], keptHookTables: [] };
|
|
819
907
|
}
|
|
820
|
-
|
|
821
|
-
|
|
908
|
+
// The split-block zone starts after the block's own opening line: when the
|
|
909
|
+
// scan owned nothing (the generated header or a foreign table directly
|
|
910
|
+
// follows the source prefix), `scan.end` is that opening line itself, which
|
|
911
|
+
// is a split-block marker and would refuse a block with nothing in it
|
|
912
|
+
// (task 36d962d2).
|
|
913
|
+
const zoneFrom = Math.max(scan.end, lineEndAfter(text, start));
|
|
914
|
+
if (scan.endedAtOperatorHookTable || findMarkerLine(text, CODEX_MANAGED_END, "whole", scan.end) !== -1) {
|
|
915
|
+
return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening, zoneFrom);
|
|
822
916
|
}
|
|
823
917
|
const run = collectKeptHookTables(text, scan.end, text.length);
|
|
824
918
|
// The scan ended at a hook table spelled without the `[[hooks.` prefix: a
|
|
@@ -829,21 +923,23 @@ function legacyManagedRange(text, start, scan, configPath, blockOpening) {
|
|
|
829
923
|
// as one without (task e8f4fc03). The run stops at the first non-hook table:
|
|
830
924
|
// past it the legacy reading below stays unchanged.
|
|
831
925
|
if (run.tables.length > 0)
|
|
832
|
-
assertNoSplitBlock(text,
|
|
926
|
+
assertNoSplitBlock(text, zoneFrom, run.end, configPath, false);
|
|
833
927
|
const keptHookTables = run.tables;
|
|
834
928
|
const foreignSectionsPreserved = collectForeignSectionHeaders(text, scan.end, text.length, keptHookTables);
|
|
835
929
|
return { start, end: scan.end, foreignSectionsPreserved, keptHookTables };
|
|
836
930
|
}
|
|
837
931
|
function findManagedRange(text, configPath) {
|
|
838
|
-
|
|
932
|
+
// BEGIN is found as a marker line (`createMarkerLineProbe`): BEGIN text in
|
|
933
|
+
// an operator value (a string, a trailing comment) is neither the block nor
|
|
934
|
+
// a second marker, so the install neither deletes operator content after
|
|
935
|
+
// it nor splices the fresh block into the value (task 36d962d2).
|
|
936
|
+
const begin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix");
|
|
839
937
|
if (begin !== -1) {
|
|
840
|
-
const secondBegin = text
|
|
938
|
+
const secondBegin = findMarkerLine(text, CODEX_MANAGED_BEGIN, "prefix", lineEndAfter(text, begin));
|
|
841
939
|
if (secondBegin !== -1) {
|
|
842
940
|
// Names the second marker's line number only, never that line's own
|
|
843
|
-
// text
|
|
844
|
-
|
|
845
|
-
// the marker text, and this refusal is printed to stderr and `--json`.
|
|
846
|
-
const secondBeginLineNumber = lineNumberAt(text, lineStartAt(text, secondBegin));
|
|
941
|
+
// text, since this refusal is printed to stderr and `--json`.
|
|
942
|
+
const secondBeginLineNumber = lineNumberAt(text, secondBegin);
|
|
847
943
|
throw new CodexInstallRefusalError(`Codex config ${configPath} has more than one '${CODEX_MANAGED_BEGIN}' marker ` +
|
|
848
944
|
`(a second one at line ${secondBeginLineNumber}); refusing to ` +
|
|
849
945
|
"guess which one is current. Delete the entire unwanted BEGIN/END pair -- including " +
|
|
@@ -854,14 +950,14 @@ function findManagedRange(text, configPath) {
|
|
|
854
950
|
"but that discards any Codex state (hook trust entries, marketplaces, plugins) " +
|
|
855
951
|
"written to the file since that backup.", configPath);
|
|
856
952
|
}
|
|
857
|
-
let start =
|
|
953
|
+
let start = begin;
|
|
858
954
|
const prev = previousLine(text, start);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
955
|
+
// Only a marker line is the block's source prefix: the closing line of a
|
|
956
|
+
// multi-line string that ends with the prefix text is the operator's
|
|
957
|
+
// value, not part of the block (task 36d962d2). Trimming also drops a BOM
|
|
958
|
+
// sitting before the source-prefix comment on line 1 (task b34ed105).
|
|
959
|
+
if (prev && createMarkerLineProbe(text)(prev.start, CODEX_MANAGED_SOURCE_PREFIX, "prefix")) {
|
|
960
|
+
start = prev.start;
|
|
865
961
|
}
|
|
866
962
|
start = startPastBom(text, start);
|
|
867
963
|
const contentStart = lineEndAfter(text, begin);
|
|
@@ -872,19 +968,24 @@ function findManagedRange(text, configPath) {
|
|
|
872
968
|
}
|
|
873
969
|
return rangeBeforeStrayEnd(text, start, end, configPath);
|
|
874
970
|
}
|
|
875
|
-
const source = text
|
|
971
|
+
const source = findMarkerLine(text, CODEX_MANAGED_SOURCE_PREFIX, "prefix");
|
|
876
972
|
if (source !== -1) {
|
|
877
|
-
const start = startPastBom(text,
|
|
973
|
+
const start = startPastBom(text, source);
|
|
878
974
|
return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
|
|
879
975
|
}
|
|
880
|
-
const generated = text
|
|
976
|
+
const generated = findMarkerLine(text, GENERATED_HEADER, "prefix");
|
|
881
977
|
if (generated !== -1) {
|
|
882
|
-
const start = startPastBom(text,
|
|
978
|
+
const start = startPastBom(text, generated);
|
|
883
979
|
const scan = scanOwnedContentEnd(text, start);
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
980
|
+
// The generated header opens a legacy block only when a hook table header
|
|
981
|
+
// line (`[[hooks.`, the older `[[hooks.pre_tool_use]]` included) follows
|
|
982
|
+
// it inside the scanned range, found by the same top-level line rule as
|
|
983
|
+
// the markers: `[[hooks.` text in a value, a multi-line string line or a
|
|
984
|
+
// comment under an operator table is operator content, and taking it for
|
|
985
|
+
// a header made the install replace the operator's keys after the header
|
|
986
|
+
// line with the fresh block (task 36d962d2).
|
|
987
|
+
const hookHeader = findMarkerLine(text, "[[hooks.", "prefix", start);
|
|
988
|
+
if (hookHeader !== -1 && hookHeader < scan.end) {
|
|
888
989
|
return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
|
|
889
990
|
}
|
|
890
991
|
}
|
|
@@ -1490,7 +1591,7 @@ export function planCodexConfigInstall(opts) {
|
|
|
1490
1591
|
let keptTableDescriptions = [];
|
|
1491
1592
|
let hasEndMarkerAfterKept = false;
|
|
1492
1593
|
if (range) {
|
|
1493
|
-
const oldHookIds = extractHookIds(currentContent
|
|
1594
|
+
const oldHookIds = extractHookIds(currentContent, range.start, range.end);
|
|
1494
1595
|
const newHookIds = extractHookIds(managedBlock);
|
|
1495
1596
|
removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
|
|
1496
1597
|
keptHookTables = range.keptHookTables;
|