@lannguyensi/harness 0.59.0 → 0.59.2

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 CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.59.2] - 2026-09-30
11
+
12
+ ### Security
13
+
14
+ - **`fast-uri` 3.1.8 in the lockfile** (transitive under ajv; lockfile-only, no source change), closing GHSA-hrr3-gc8f-f4qj (inconsistent host case normalization via percent-encoded octets; moderate).
15
+
16
+ ### Fixed
17
+
18
+ - **The Codex install keeps an operator-authored `[[hooks.<Event>]]` table that sits after the harness hook tables** (task `01053b27`). In a config with a drifted END marker, or a BEGIN marker, a legacy source prefix or a legacy generated header but no END marker, the ownership scan folded such a table into the replaced block and deleted it without naming it in `removedHookIds`; the TOML-semantic safety net does not compare hook event arrays, so it did not catch this either. The scan now tells a harness hook table from an operator one by the `# harness hook: <id> (budget_ms=N)` comment the generator writes above every hook table: a `[[hooks.*]]` header without that comment above it, after at least one header that has it, ends the owned range like a foreign table, together with a comment run attached to it, and the table is kept byte for byte after the fresh END marker (apply twice gives the same bytes). Failure modes, both by design: a block whose hook tables never carried the comment (older generator output) has no commented table before its first hook table and is still replaced whole; a harness table whose comment the operator deleted ends the range there, so a later harness table that still has its comment makes the install refuse through the split-block check with the file untouched, while a deleted comment on the last harness table leaves that table in place after the fresh block as if it were an operator table, so the install names every kept operator hook table (header and line number, never value text) among the preserved sections, in `--dry-run` (`preserving foreign section: [[hooks.Stop]] (line 14 before the install, line 40 after it)`) and in the install summary (`preserved foreign section: ...`), and in the plan's `keptOperatorHookTables`, and it refuses, file untouched, when a kept table's hook command equals a command of the fresh block or starts with `harness `, asking the operator to restore the comment line or delete the table, or, when an END marker follows the kept table, to move an operator-owned table below it. A retired hook whose command does not start with `harness ` (an environment-prefixed report-dir wrap, the memory-router hook, a custom manifest hook) is kept and named, not refused. The kept tables' commands are read from the parsed whole config (the parser the safety net uses), each table matched to its parsed entry by the order of the `[[hooks.<Event>]]` headers, so no part of the file is parsed on its own and the parser's code frame never reaches the output; the kept-table scan and the header order match read each line with the same line classifier as the managed-range scan and the foreign-section listing, so a header-shaped array element on its own line inside a kept table's multi-line value (`["x"]`, `['x']`, `[1979-05-27]`) counts as a header only where it starts at the top level of the document (never, for an element; see the task `6b56d735` entry), and it neither hides that table's own commands, nor ends the run of kept tables, nor is printed: a hook table after it is still named and still refused when it looks harness-generated; the order match reads a header-shaped line as a header only when its root key resolves to `hooks`, checked before the top-level check, so the element lines of a large array add no parse there, other than an element that is the string `hooks` (`["hooks"]`), which reads as a `hooks` root and is probed like a header (the preserved-section listing still checks each element line of a foreign table after the kept tables, as the task `6b56d735` entry measures), and a same-event table spelled another way anywhere in the file (`[[ hooks.Stop ]]`, `[["hooks".Stop]]`, `[[hooks . Stop]]`) is counted, so the kept table is still matched to its own entry; only hook commands count (`hooks.<Event>[].command` and `hooks.<Event>[].hooks[].command`), not any other key named `command`; a kept table the order match cannot place exactly (an escape sequence in a quoted header key of that event, or a nested `hooks` table with no entry before it) refuses with its header and line number only. The split-block refusal for an uncommented table in the middle of the block now offers both fixes: restore the comment line, or move an operator-owned table below the END marker. In the two legacy paths (no BEGIN marker) a harness-commented table after such an operator table, before the END marker when one follows, now refuses through the same split-block check instead of being kept beside the fresh block; content below that END is left untouched, so a harness-commented table there is kept and not named, as on the BEGIN path. There the split-block refusal names the lines between the source-prefix line and the END marker, or, on the generated-header path, every line from the generated header through the END marker line, both included (a generated header left alone above END no longer marks a harness block, so the install would add a fresh block and leave that pair behind), not a BEGIN marker the config does not have. In those legacy paths an END marker that survived its deleted BEGIN line and follows the kept tables is handled like a drifted END: the install removes it (the written config holds one END, the fresh block's, before the kept tables), the preserved-section listing stops at it, and the kept-table refusal offers moving an operator-owned table below it. A bare `key =` line directly under a harness table cannot be told from that table's own fields and is still consumed with it; bare keys under an operator table follow that table. Pinned in `tests/cli/apply/apply-codex-runtime.test.ts` (drifted END, no END with a foreign table after it, the legacy source-prefix and legacy generated-header paths, idempotency, the uncommented-block control, the deleted-comment cases including the refusal clauses, the naming in the plan, the dry-run and the summary with both line numbers, the multi-line array value, an array element line followed by a named, a duplicate and a retired harness table, the hook-command-only reading, the order-match refusals, a multi-line string holding a hook header line and the literal-quoted header, the END-marker option, the legacy paths with a surviving END and their split-block text, that text's delete fix followed literally, a harness-commented table below the END marker, END-marker text inside a harness table's multi-line string, same-event tables spelled another way above BEGIN or below END, tables whose root only starts with `hooks` or differs from it in case and a `[[hooks.<Event>.hooks2]]` sub-table (not counted), a `[[hooks]]` array-of-tables root and a kept header whose comment holds U+2028 (a refusal, not a crash), a deeper array of tables under a kept nested hooks table, an escaped event key next to an event named `null`, and the quoted-key, indented-header, indented-comment and CRLF spellings; the order match's parse count in `tests/cli/apply/apply-codex-hook-header-map-cost.test.ts`; output parsed to show the operator table survives semantically): the drifted-END and no-END fixtures fail against the previous implementation and pass now. Mutation probes (`agent-primitives probe --pre 'npm run build'`) reverting each clause of the new condition are killed by those fixtures.
19
+
20
+ - **The Codex install no longer takes an array element on its own line for a table header** (task `6b56d735`). A multi-line array whose element sits on its own line (`["x"]`, `['x']`, `[1]`, `[1979-05-27]`) has the shape of a table header, and the install's header recognition accepted it. `harness apply --runtime codex --install --dry-run` printed such an element as `preserving foreign section: ...` (a token inside an `args` array leaked into the plan, the install summary and `--json`), an element inside a hook table ended the harness-owned region early so the install refused a config that parses, and the split-block refusal named the element as the foreign table on stderr and in `--json`. A header candidate now counts only when the document text before it parses on its own, so a line inside an open array, inline table or multi-line string is never a header, even when a real table of the same name exists elsewhere. The refusal names a header-shaped line that is not a header by its line number, never by its text. A line counts as top level exactly when the text before it parses (the full-prefix verdict); as a fast path the check first parses only the text since the last confirmed top-level line and confirms the line when that parses, and when it does not, the full prefix decides. So a config with thousands of ordinary tables stays fast, and a table after an array-of-tables element whose sub-table was confirmed before it is still listed. Any candidate whose segment fails costs a full-prefix parse as well, quadratic in the number of such candidates: a foreign array with many element lines each on its own line (measured in-process: about 0.8 s for 2000 such lines, 12 s for 8000), or many sub-tables that each follow a later element of the same array of tables (about 1.5 s for 2000). In a config that does not parse, the install now refuses with the parse-error message instead of the split-block refusal, since header recognition stops at the parse error. No other splice behaviour changes.
21
+
22
+ - **The solution-acceptance null-verdict deny now opens with a reason that states only what the gate observed** (task `8e9d7333`, follow-up to the note-line fix of task `8fe0f633`; agent-facing text and tests only, no gate decision changes). `evaluateGate`'s reason for a missing verdict read `no solution-acceptance verdict recorded for "<id>" (run mcp__grounding-mcp__solution_evaluate first)`; "recorded" is falsified by a marker recorded then deleted, an attempt finishing between the marker read and the lock read, and an `lstat` failure folded into `missing`, and "run ... first" contradicted the live-attempt deny, whose converge step says to poll the same attempt. It now reads `no usable solution-acceptance verdict for "<id>"`: the gate only knows that it received no usable verdict, so the line makes no claim about a read: the unusable-id state reads nothing and the invalid-record state reads a marker that is not a valid record. What was read stays in the note line, and the next-action guidance stays in the note line and the converge steps below it. The tests that pinned the old wording were updated; the state table now asserts on every state's deny that the first line carries no "recorded", no "first" and no read claim, against a state list the test owns and compares to the source list, and absence tests cover the plain reason and the never-evaluated and live-attempt denies.
23
+
24
+ ## [0.59.1] - 2026-09-29
25
+
26
+ ### Fixed
27
+
28
+ - **The solution-acceptance deny note no longer names an unobserved cause for the never-evaluated reading, and its state table now pins what the docs claimed** (task `8fe0f633`, follow-up to the late review findings of task `799de976`; agent-facing text and tests only, no gate decision changes). The never-evaluated line said `solution_evaluate` "has not (yet) been called for this id, or a prior call never got far enough to record one", but what the gate established is only that its one marker read found nothing (the shared reader folds every `lstat` failure into `missing`) and that the lock read not-live at a later moment; a marker recorded then deleted, an attempt finishing between the two reads, and a non-`ENOENT` `lstat` failure each falsify it. It now reads `No verdict marker was found for "<id>"; no attempt reads as currently live: run solution_evaluate for this id.` (with the unknown-liveness clause, `No verdict marker was found for "<id>" and liveness could not be determined: ...`). Only that note line changed: the `evaluateGate` reason above it (`no solution-acceptance verdict recorded for "<id>" (run ... solution_evaluate first)`) is a different module surface and is unchanged, so its `recorded` and `first` wording is still open to the same three falsifiers. The state table gained the three missing liveness rows (symlink, not-a-regular-file and unreadable marker, each with an unknown-liveness fixture): it now holds fourteen rows covering the twelve reachable (note state, liveness) combinations (two combinations have a second fixture), so its "one exact line per reachable (note state, liveness) combination" claim is true; a mutant hardcoding the not-live clause into the symlink line no longer survives. Each row declares the coordinate it means to reach, and `renderNullVerdictNote` (exported, with the renderer record and the reachable set `NULL_VERDICT_NOTE_COORDINATES`) is total over reachable coordinates only: `live` renders only with the live-attempt state, the unusable-id state carries no liveness at all, and any other pair throws, so a row can no longer claim a state or a liveness its fixture never reaches, and the set of lines that must not appear is derived from the renderer instead of listed by hand. The classifier's parameter excludes the `ok` outcome by type and the hook classifies a denial only when its read yielded no verdict, which removes an arm that would have called a valid record invalid if the gate ever gained a second null return. The single marker observation now has two guards: a `dependency-cruiser` rule (`npm run check:boundaries`) forbidding the hook from importing the shared marker reader directly, and a call-count test asserting one `readVerdictDetailed` call per invocation. `readVerdict`'s doc comment no longer claims callers it does not have. The OKF log entry for task `799de976` moved to its chronological place and its commit reference now names the merge commit.
29
+
30
+ - **The Codex install splice keeps a trailing operator comment at EOF when there is no END marker, and keeps a leading UTF-8 BOM exactly once** (task `b34ed105`). Two pre-existing comment-only or BOM-only effects of the managed-range detection, documented as limitations in the task `6a037359` entry, with no semantic loss (the TOML-semantic safety net saw no difference): (1) in a config with a BEGIN marker, a legacy source prefix or a legacy generated header but no END marker, the ownership scan ran to EOF and folded a trailing operator comment after the last harness hook table into the replaced block; it now backs off to the start of that trailing comment run, so the comment is kept once, after the fresh END marker (a run of only blank lines is still consumed, and so is a trailing run that holds a harness-authored line such as the generated header, a `# harness hook:` line or the source prefix, since that is stale harness output; the back-off also needs at least one owned content line before the run); (2) a BOM in front of the BEGIN marker, a legacy source-prefix comment or a legacy generated header on line 1 was dropped by the splice, and a BOM before a source-prefix line hid it from the previous-line check, so the prefix comment was duplicated; the managed range now starts just past the BOM, and the BOM is kept exactly once with no duplicate prefix line. The BOM change leaves the ranges of files without a BOM unchanged. Pinned in `tests/cli/apply/apply-codex-runtime.test.ts`, with one fixture per harness-authored clause (generated header, `# harness hook:` line, source prefix): the no-END, legacy source-prefix and legacy generated-header trailing-comment fixtures and the BOM + BEGIN, BOM + source-prefix and BOM + generated-header fixtures fail against the previous implementation and pass now. Mutation probes (`agent-primitives probe`) reverting the EOF back-off and reverting the BOM handling are killed by those fixtures.
31
+
32
+ - **The solution-acceptance write-guard no longer refuses a command that only names a pack source file** (task `e8c9edaf`). `bashReferencesVerdictDir`'s glob/brace leaf-word fallback fires when the command text carries any of `*?[{` and also contains a distinctive word of the verdict directory's leaf (`solution`, `verdicts`). `solution` is the first word of the pack's own name, so any non-read-only Bash whose text named `docs/policy-packs/solution-acceptance.md`, `src/cli/pack/hook-solution-acceptance-writeguard.ts`, `src/policy-packs/builtin/solution-acceptance-runtime.ts` or one of the pack's test files was refused as a verdict-dir write as soon as a `[x]`, a `**bold**`, a `.*` in a sed expression or a `{...}` appeared anywhere in the text (a heredoc body, a `git commit -m`); for Codex `apply_patch` the `***` of every patch header made any patch that edits a pack file refuse. The fallback now scans the command with the pack name scrubbed out of each plain, whitespace-delimited word (a word with no `*?[]{}` character cannot expand at runtime, so its literal text reaches the verdict directory only through the leaf, tail or env token that the unchanged direct checks already see), but only when the command carries no runtime-construction syntax: a `$` anywhere (parameter expansion, command substitution, `$'...'`), a backtick, `<(` or `>(`, `printf -v`, a `.` in command position, or one of the words `eval`, `source`, `read`, `declare`, `typeset`, `local`, `export`, `set`, `let`, `mapfile`, `readarray`, `exec`, `xargs`, `sh`, `bash`, `zsh`, `dash`, `ksh`. A command that can build text at runtime could derive the directory leaf from the very pack-name word the scrub would erase, so it is scanned unscrubbed, exactly as before this change. Everything else is unchanged: the literal directory, the `$SOLUTION_VERDICT_DIR` token, the `agent-grounding/solution-verdicts` tail and the literal leaf are still matched on the untouched command; a word that carries a metacharacter is never scrubbed, so `solution-acceptance/../solution-ver*` and `[solution-acceptance]*` keep their leaf word; a custom directory whose own leaf contains the pack name is scanned unscrubbed; the brace/glob leaf-word fallback, `cd` target handling, the cwd-inside check and the read-only classifier are not touched. Both call sites (the Bash arm and the `apply_patch` arm) share the matcher, so both narrow. Still refused (fails closed, cannot be decided without evaluating the shell): a command with a glob/brace character that also contains the word `solution` or `verdicts` outside a plain pack-name word (prose in a heredoc body, a glob over the pack sources such as `solution-acceptance*`), and a command that carries runtime-construction syntax next to a glob/brace character and the word `solution` or `verdicts` (so a heredoc body with a `$` in it, for example, is refused again when it also carries a glob character and the pack name). A name that is built at runtime and never shows the word `solution` or `verdicts` in the command text is not caught, before or after this change; that stays an open residual, as the guard header already says. The named residuals stay open and unchanged: a backslash-escaped leaf, a case-variant leaf on a case-insensitive filesystem, a trailing backslash on a `cd` target, a path in which globs or braces obscure every segment so that neither `solution` nor `verdicts` survives in the text (for example `sol*verd*`), and a path constructed at runtime inside an interpreter. Pinned by the refuse-side fixtures (committed first, passing against the previous matcher) and the allow-side fixtures in `tests/cli/pack-hook-solution-acceptance-writeguard.test.ts` and `tests/policy-packs/solution-acceptance-runtime.test.ts`.
33
+
10
34
  ## [0.59.0] - 2026-09-25
11
35
 
12
36
  ### Upgrade notes
@@ -52,9 +76,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
52
76
 
53
77
  **Upgrade note:** Codex tracks per-hook trust in `[hooks.state]` (`<config path>:<event>:<group index>:<handler index>` keys, `trusted_hash` computed over a normalized hook identity, not raw source text; see `codex-rs/hooks/src/engine/discovery.rs`'s `hook_hash`/`hook_trust_status` and `codex-rs/hooks/src/lib.rs`'s `hook_key`, openai/codex commit `30fc6864cc1318121eca1843c217fe00ce1212f1`). Harness never writes or forges `trusted_hash`; it only stops deleting the table that holds it. A hook whose emitted command, matcher, or `[[hooks.<Event>]]` table position changes (the key embeds the table's ordinal index for that event) gets a new hash/key and reverts to `Untrusted` until the operator re-approves it in Codex, same as any hand-edited hook; this was already true before this fix, it is just no longer masked by the whole trust table being deleted. The UG Codex `post-tool-use` hook disappearing from an install is separate and expected: it is suppressed under `approval_lifecycle: { mode: session }` (`resolveExpireOnToolMatch`, `src/policy-packs/builtin/understanding-before-execution.ts`), pinned by `tests/policy-packs/expand.test.ts` ("Codex adapter contributes..." for the default-present case, "...suppressed when approval_lifecycle.mode = session..." for the opt-out); re-running the install under `mode: session` correctly removes that stale block, it is not a regression. A command, matcher, or timeout change (also an `async`/`status_message` change) yields a new hash under the SAME key; a position change instead moves which key the entry lives under; either way the affected hook's status reads `Modified` or `Untrusted` and Codex will not run it until re-approved. Separately, a `[hooks.state]` entry can carry a positional `enabled` override for that same key; adding or removing a hook earlier in an event's list shifts every later hook's ordinal index, which can land an operator's own `enabled = false` override onto a DIFFERENT hook than the one they disabled. Review Codex's hook list after any install that changes the hook roster (adds, removes, or reorders a hook for an event), not only after one that changes a single hook's command. Also: since harness 0.57.0 (stay-in-scope became an explicit opt-in), a re-install removes the codex "stay in scope" hook block unless `stay_in_scope` is enabled in the manifest -- expected, not a regression, same class as the `post-tool-use` disappearance above.
54
78
 
55
- Also hardened in the same change (task `6a037359`): a dedicated fixture in `tests/cli/apply/apply-codex-runtime.test.ts` for each guard that previously had no fixture exercising it -- an operator comment sitting directly above the first foreign table (stays attached to that table, not folded into the replaced block, and appears exactly once); two harness-managed BEGIN markers (refused, file untouched); a BEGIN with no END where a foreign table is followed further down by a real `# harness hook:` comment (refused, since the block boundaries can no longer be read as a plain line scan); a managed block whose END marker is followed by a trailing operator comment at EOF (kept, not silently replaced away; this holds only when the END marker is present: in a config with no END marker, a trailing comment after the last harness hook table is still read as part of the block and replaced, a known limitation); exact (not `arrayContaining`) equality on `foreignSectionsPreserved` and `removedHookIds`, plus one exact byte-for-byte equality on the whole installed output for the drifted fixture. A CLI-level test (through the built program, not only the library function) asserts `--dry-run`'s and the applied install's own stdout for the removed-hook-block and preserved-foreign-section lines. The refusal itself is more actionable: it names the RESOLVED config path (never a hard-coded `~/.codex`), the first foreign table (its bracketed header only) and the line number of the first line beyond it that still looks like harness content (never that line's own text), and either tells the operator to move that table below the END marker or delete the lines between BEGIN and END (when one exists), or, when there is no END marker at all, to add one after the last harness hook table or move the foreign table below it instead (telling the operator to move something below a marker that does not exist would be nonsensical); a duplicate-BEGIN refusal tells the operator to delete the entire unwanted BEGIN/END pair, including its own marker lines and source-prefix comment, not just the lines between them. Either refusal offers the `.harness-backup-*` restore only as a last resort with an explicit warning that it discards any Codex state (hook trust, marketplaces, plugins) written since that backup; it throws a dedicated `CodexInstallRefusalError` (a `HarnessExitError` at `EX_FAIL`) instead of a plain `Error`, and `--json` emits a structured `{ outcome: "codex-install-refuse", configPath, error }` object on stdout the same way `target-exists-refuse`/`drift-refuse` already do, instead of only the plain-text message reaching stderr. The install's own permissions no longer widen a secret-bearing config: it preserves the EXISTING file's mode for both the rewritten config and its `.harness-backup-*` copy (0640 as well as 0600, pinned), defaulting to `0600` (not `atomicWriteFile`'s own `0644`) only when creating a config that did not exist before. `foreignSectionsPreserved` prints a namespace's ROOT with a wildcard and its table count when more than one table collapses into it (e.g. `[marketplaces.*] (2 tables)`), instead of silently representing several preserved tables as one indistinguishable header; a single table under a root still prints its own full header. The namespace is the header's first key, or its first two keys under `hooks` (e.g. `[hooks.state.*] (27 tables)`, so Codex's trust table is not reported as `hooks`, which also holds the harness-managed hook arrays), with keys split quote-aware and a trailing comment never part of the namespace or of a printed header. Table-header recognition is a full single-line TOML header shape, quote-aware so a key containing `[`, `]`, `.` or `#` (a real path used as a TOML key, e.g. `[projects."/Users/x/Photos [2024]"]`) is still recognized as one header on every managed-range detection path (a drifted END, no END, and the legacy no-BEGIN path), instead of being misread as ending the header early or as still-owned content; a multi-line array's own continuation line inside a still-owned hook table (e.g. ` [1, 2],`) is never misread as a foreign table header ending the region early. The stray-END search matches a whole trimmed line, not a raw substring, so a foreign single-line value that merely contains the END marker text as a substring is never mistaken for the real stray END. A TOML multi-line string is now tracked by its own opening delimiter (`"""` or `'''`), closed only by that same delimiter, with an escaped quote and a delimiter inside a comment or a single-line string ignored, so a foreign table's own multi-line string value (or a still-owned hook table's own multi-line matcher) can contain a line that reads exactly like a comment, a table header, or even the END marker without corrupting the scan.
79
+ Also hardened in the same change (task `6a037359`): a dedicated fixture in `tests/cli/apply/apply-codex-runtime.test.ts` for each guard that previously had no fixture exercising it -- an operator comment sitting directly above the first foreign table (stays attached to that table, not folded into the replaced block, and appears exactly once); two harness-managed BEGIN markers (refused, file untouched); a BEGIN with no END where a foreign table is followed further down by a real `# harness hook:` comment (refused, since the block boundaries can no longer be read as a plain line scan); a managed block whose END marker is followed by a trailing operator comment at EOF (kept, not silently replaced away; a config with no END marker keeps a trailing operator comment too, unless the trailing run holds a harness-authored line, see the task `b34ed105` entry above); exact (not `arrayContaining`) equality on `foreignSectionsPreserved` and `removedHookIds`, plus one exact byte-for-byte equality on the whole installed output for the drifted fixture. A CLI-level test (through the built program, not only the library function) asserts `--dry-run`'s and the applied install's own stdout for the removed-hook-block and preserved-foreign-section lines. The refusal itself is more actionable: it names the RESOLVED config path (never a hard-coded `~/.codex`), the first foreign table (its bracketed header only) and the line number of the first line beyond it that still looks like harness content (never that line's own text), and either tells the operator to move that table below the END marker or delete the lines between BEGIN and END (when one exists), or, when there is no END marker at all, to add one after the last harness hook table or move the foreign table below it instead (telling the operator to move something below a marker that does not exist would be nonsensical); a duplicate-BEGIN refusal tells the operator to delete the entire unwanted BEGIN/END pair, including its own marker lines and source-prefix comment, not just the lines between them. Either refusal offers the `.harness-backup-*` restore only as a last resort with an explicit warning that it discards any Codex state (hook trust, marketplaces, plugins) written since that backup; it throws a dedicated `CodexInstallRefusalError` (a `HarnessExitError` at `EX_FAIL`) instead of a plain `Error`, and `--json` emits a structured `{ outcome: "codex-install-refuse", configPath, error }` object on stdout the same way `target-exists-refuse`/`drift-refuse` already do, instead of only the plain-text message reaching stderr. The install's own permissions no longer widen a secret-bearing config: it preserves the EXISTING file's mode for both the rewritten config and its `.harness-backup-*` copy (0640 as well as 0600, pinned), defaulting to `0600` (not `atomicWriteFile`'s own `0644`) only when creating a config that did not exist before. `foreignSectionsPreserved` prints a namespace's ROOT with a wildcard and its table count when more than one table collapses into it (e.g. `[marketplaces.*] (2 tables)`), instead of silently representing several preserved tables as one indistinguishable header; a single table under a root still prints its own full header. The namespace is the header's first key, or its first two keys under `hooks` (e.g. `[hooks.state.*] (27 tables)`, so Codex's trust table is not reported as `hooks`, which also holds the harness-managed hook arrays), with keys split quote-aware and a trailing comment never part of the namespace or of a printed header. Table-header recognition is a full single-line TOML header shape, quote-aware so a key containing `[`, `]`, `.` or `#` (a real path used as a TOML key, e.g. `[projects."/Users/x/Photos [2024]"]`) is still recognized as one header on every managed-range detection path (a drifted END, no END, and the legacy no-BEGIN path), instead of being misread as ending the header early or as still-owned content; a multi-line array's own continuation line inside a still-owned hook table (e.g. ` [1, 2],`) is never misread as a foreign table header ending the region early. The stray-END search matches a whole trimmed line, not a raw substring, so a foreign single-line value that merely contains the END marker text as a substring is never mistaken for the real stray END. A TOML multi-line string is now tracked by its own opening delimiter (`"""` or `'''`), closed only by that same delimiter, with an escaped quote and a delimiter inside a comment or a single-line string ignored, so a foreign table's own multi-line string value (or a still-owned hook table's own multi-line matcher) can contain a line that reads exactly like a comment, a table header, or even the END marker without corrupting the scan.
56
80
 
57
- A TOML-parsing safety net now runs after the byte-preserving splice above computes its output, before anything is written: both the current and the about-to-be-written config are parsed with `smol-toml` (a new runtime dependency, pinned `~1.8.0`) and compared. The invariant: both must parse, and once every harness-managed hook event array (`[[hooks.<Event>]]`) is removed from both documents, everything else must come out identical: `[hooks.state]` and every other single-table `hooks.*` key, plus all non-hooks content (`[projects.*]`, `[plugins.*]`, `[marketplaces.*]`, `[mcp_servers.*]`, top-level keys). Any difference refuses via `CodexInstallRefusalError`, naming the first differing key path, with the file left untouched (a `--dry-run` plan hits the same check and reports the same refusal). For that content the net is independent of the header-recognition and multi-line-string fixes above, so a future scanner regression there refuses instead of silently dropping content. It does not cover the hook event arrays themselves: an operator-authored `[[hooks.<Event>]]` entry that the line scan counts as part of the harness-managed block (for example one sitting between the block and a drifted END marker, or after a BEGIN with no END marker) is still replaced without a refusal; tracked as follow-up task `01053b27`. An operator hook entry outside the managed block is kept whatever its header spelling (a trailing comment, a quoted event key). A leading UTF-8 byte order mark is ignored for parsing (the written file keeps it unless the harness block itself starts on the file's first line, which is harmless because Codex's own parser strips it too), and an integer beyond 2^53 (TOML integers are 64-bit) is compared exactly instead of failing the parse. A config the harness parser cannot read refuses with "the TOML parser used by harness could not read this file", the parser's line, column and one-line reason, not a claim that the operator's syntax is wrong; a would-be output that does not parse names an operator-defined inline `hooks` table or inline hook-event array as the likely cause before a harness defect. No refusal quotes a value line of the config or the parser's code frame; only table headers and key paths are named: parse refusals carry only that position and reason (never the parser's code frame, which would echo neighbouring lines such as an API token), and the split-block and duplicate-BEGIN refusals name a line number instead of that line's text.
81
+ A TOML-parsing safety net now runs after the byte-preserving splice above computes its output, before anything is written: both the current and the about-to-be-written config are parsed with `smol-toml` (a new runtime dependency, pinned `~1.8.0`) and compared. The invariant: both must parse, and once every harness-managed hook event array (`[[hooks.<Event>]]`) is removed from both documents, everything else must come out identical: `[hooks.state]` and every other single-table `hooks.*` key, plus all non-hooks content (`[projects.*]`, `[plugins.*]`, `[marketplaces.*]`, `[mcp_servers.*]`, top-level keys). Any difference refuses via `CodexInstallRefusalError`, naming the first differing key path, with the file left untouched (a `--dry-run` plan hits the same check and reports the same refusal). For that content the net is independent of the header-recognition and multi-line-string fixes above, so a future scanner regression there refuses instead of silently dropping content. It does not cover the hook event arrays themselves: an operator-authored `[[hooks.<Event>]]` entry that the line scan counts as part of the harness-managed block would be replaced without a refusal. The scan no longer counts one that sits after the harness hook tables without the `# harness hook:` comment above it, whether between the block and a drifted END marker or after a BEGIN with no END marker (named scope of task `01053b27`, see its entry above); the remaining cases, still replaced silently, are entries the scan cannot tell apart: an operator table before the first harness table inside the block, an operator table under a comment run that holds a `# harness hook:` line, bare key lines directly under a harness table, and an operator table after an old block that never carried the comments. An operator hook entry outside the managed block is kept whatever its header spelling (a trailing comment, a quoted event key). A leading UTF-8 byte order mark is ignored for parsing (the splice keeps it exactly once, also when the harness block starts on the file's first line, see the task `b34ed105` entry above), and an integer beyond 2^53 (TOML integers are 64-bit) is compared exactly instead of failing the parse. A config the harness parser cannot read refuses with "the TOML parser used by harness could not read this file", the parser's line, column and one-line reason, not a claim that the operator's syntax is wrong; a would-be output that does not parse names an operator-defined inline `hooks` table or inline hook-event array as the likely cause before a harness defect. No refusal quotes a value line of the config or the parser's code frame; only table headers and key paths are named: parse refusals carry only that position and reason (never the parser's code frame, which would echo neighbouring lines such as an API token), and the split-block and duplicate-BEGIN refusals name a line number instead of that line's text.
58
82
 
59
83
  ## [0.58.0] - 2026-09-23
60
84
 
@@ -81,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
81
105
 
82
106
  - **`harness pack hook solution-acceptance`'s completion-gate deny text now distinguishes the three `gate.verdict === null` readings instead of appending the same reconnect-vs-retry paragraph to all three** (task `799de976`). The hook reads grounding-mcp's documented attempt-lock anchor (`<verdict dir>/<id>.attempt-lock`, README "Attempt lock anchor" row at `grounding-mcp-v0.12.0`) via `proper-lockfile`'s own read-only `checkSync` (already a harness dependency; no new one added) to tell "an attempt is live" apart from "never evaluated" and "a marker path exists but what is there was not accepted as a verdict"; liveness is three-valued (`live`, `not-live`, `unknown`, through a read-only `checkFileLock` in `src/io/lock.ts`), and a note never asserts an absent attempt or a missing marker when the check could not read; the full reconnect-vs-retry paragraph (`solution-acceptance-reconnect.ts`) is now appended only for the live-attempt reading, and the other two readings get their own short, reading-named line instead; the paragraph's own wording is corrected to say the hook itself read the anchor and detected the live-attempt reading, not that it cannot tell the three readings apart (its shared facts, `RECONNECT_FACT_*` and `renderReconnectInstructionsSection`, stay byte-identical). A lock directory left behind by a dead process is read as stale using the producer's OWN staleness window (`DEFAULT_ATTEMPT_LOCK_STALE_MS = 30_000`, `grounding-mcp-v0.12.0` `solution-attempt-log.ts:102`, the default `acquireAttemptLock` passes to `proper-lockfile` when no caller overrides `staleMs`), so this hook's "live" reading and the producer's reclamation rule agree as long as the producer keeps that default and no caller overrides it: an operational pin re-checked on every grounding-mcp bump, not a guarantee. `instructions.md` and grounding-mcp's lock layout are unchanged. See `docs/policy-packs/solution-acceptance.md`, "Reading the attempt-lock anchor to distinguish the three readings", for the full decision record and source citations. Pinned in `tests/cli/pack-hook-solution-acceptance.test.ts` (one case per reading, the overlap of a live lock with an unreadable marker, a stale-lock regression, two check-error-path regressions, a symlinked anchor) and in `tests/io/lock.test.ts` and a literal-wording pin in `tests/policy-packs/solution-acceptance-reconnect.test.ts`; mutation probes removing the live-attempt branch, neutralising the stale rule, substituting a hand-copied paragraph, flipping the check-error catch arm, and reverting the corrected wording (`agent-primitives probe --pre 'npm run build'`) all killed.
83
107
 
84
- - **Review-round fixes: the deny note now states only what the hook established** (task `799de976`, commit `3d3bddf`; the same defect class recurred in four consecutive rounds, so this round closes the CLASS). Each round's note asserted something the hook had not established, and each fix replaced one guessed cause with a narrower guess that the next reachable state falsified: first "no attempt reads as currently live" where the liveness check had thrown, then "the verdict directory could not be read" where only the lock path was unreadable, then "the verdict directory or the attempt-lock path could not be read" for an id from which NEITHER path can be derived. An id `sanitizeVerdictId` rejects (`.`, `..`, anything sanitizing to an empty basename, reachable from an active claim; the `SOLUTION_VERDICT_ID` path validates through the same function and answers null instead) is now its own `NullVerdictInfo` union member, settled before anything is read and carrying no `liveness` field, so "the id was unusable" has no field left to render as "a path could not be read"; its note names that one fact, points at the active claim, and asks for a usable id instead of repeating `solution_evaluate` for an id that can never be accepted. The remaining unknown-liveness line names no cause at all. Reading (3) no longer says "could not be read or parsed" for a marker `readVerdict` refuses by policy (a symlink, a non-regular file); it names the complete set of ways that read answers null for a present path, at the hook and at the shared reading-label site (`RECONNECT_THREE_READINGS_LABELS`). A state table in `tests/cli/pack-hook-solution-acceptance.test.ts` ("null-verdict deny note: one exact line per reachable state") pins one exact expected line per reachable (id-usability, reading, liveness) combination, nine rows, and asserts no other state's line appears, so this class fails there rather than reaching an agent; it doubles as the checklist for extending the classifier. The stale-lock fixture's back-date is a literal (90 s) rather than derived from `ATTEMPT_LOCK_STALE_MS`, so it discriminates a widened window instead of staying stale under every mutation of the constant it was meant to pin. Mutation probes restoring the guessed cause, narrowing reading (3)'s wording back, and widening the stale window to 300 s (`agent-primitives probe`, the last one scoped to the stale test alone) all killed.
108
+ - **Review-round fixes: the deny note now states only what the hook established** (task `799de976`, commit `3d3bddf`; the same defect class recurred in four consecutive rounds, so this round closes the CLASS). Each round's note asserted something the hook had not established, and each fix replaced one guessed cause with a narrower guess that the next reachable state falsified: first "no attempt reads as currently live" where the liveness check had thrown, then "the verdict directory could not be read" where only the lock path was unreadable, then "the verdict directory or the attempt-lock path could not be read" for an id from which NEITHER path can be derived. An id `sanitizeVerdictId` rejects (`.`, `..`, anything sanitizing to an empty basename, reachable from an active claim; the `SOLUTION_VERDICT_ID` path validates through the same function and answers null instead) is now its own `NullVerdictInfo` union member, settled before anything is read and carrying no `liveness` field, so "the id was unusable" has no field left to render as "a path could not be read"; its note names that one fact, points at the active claim, and asks for a usable id instead of repeating `solution_evaluate` for an id that can never be accepted. The remaining unknown-liveness line names no cause at all. Reading (3) no longer says "could not be read or parsed" for a marker `readVerdict` refuses by policy (a symlink, a non-regular file); it names the complete set of ways that read answers null for a present path, at the hook and at the shared reading-label site (`RECONNECT_THREE_READINGS_LABELS`). A state table in `tests/cli/pack-hook-solution-acceptance.test.ts` ("null-verdict deny note: one exact line per reachable state") pins one exact expected line per reachable (id-usability, reading, liveness) combination, nine rows at that commit (eleven after the round-6 fixture additions, fourteen once the liveness axis was completed, see the `[Unreleased]` entry for task `8fe0f633`), and asserts no other state's line appears, so this class fails there rather than reaching an agent; it doubles as the checklist for extending the classifier. The stale-lock fixture's back-date is a literal (90 s) rather than derived from `ATTEMPT_LOCK_STALE_MS`, so it discriminates a widened window instead of staying stale under every mutation of the constant it was meant to pin. Mutation probes restoring the guessed cause, narrowing reading (3)'s wording back, and widening the stale window to 300 s (`agent-primitives probe`, the last one scoped to the stale test alone) all killed.
85
109
 
86
110
  - **Round-6 review fix: the deny text is classified from ONE read of the marker** (task `799de976`, commit `244f9c3`). The gate read the marker at the decision site and the deny text classified it again a few statements later, so the two could disagree: a background attempt finishing in that window, the case this pack exists for, made the note tell the agent its fresh marker had been rejected when the gate had simply not seen it yet, and the reasons the note enumerated did not include the one that actually applied. A new `readVerdictDetailed` reports WHY a read yielded no verdict (`invalid-id`, `missing`, the shared reader's `symlink` / `not-regular` / `unreadable`, plus `invalid-record`) in one observation; `readVerdict` is the same function with the reason dropped, so every caller that needs only the gate decision is unchanged. Reading (3) no longer restates a disjunction over the rejection kinds: each kind renders its own line naming what that read established. The lines are a `Record` keyed by the exported `NULL_VERDICT_NOTE_STATES`, so a state with no line fails the build, and the state table's new coverage test fails when a listed state has no fixture, which makes the table a checklist rather than a convention. Two fixtures are added (a schema-invalid record, and a regular file the process may not read), the symlink and directory fixtures now pin their own distinct lines, and the unusable-id remedy names the verb that clears the claim instead of asking for an impossible re-run. The liveness helper loses a `catch` that became unreachable once id usability was settled first: an arm no mutant can kill is worse than no arm. Mutation probes collapsing one rejection line into another, and adding a note state with no fixture, both killed; the removed race itself is not fixture-testable, which is why the fix removes the second observation rather than adding a test for it.
87
111
 
package/README.md CHANGED
@@ -2,85 +2,20 @@
2
2
 
3
3
  **Declarative control plane for agent harnesses.**
4
4
 
5
- One zod-validated YAML manifest for grounding, tools, memory, hooks,
6
- policies, and workflows, plus a CLI that describes, validates, diffs,
7
- applies, audits, and *enforces*.
8
-
9
- > Most config tools tell you what an agent is configured to use.
10
- > `harness` tells you what an agent is *allowed to do*, under this
11
- > exact context, and why.
5
+ ## Overview
12
6
 
13
7
  A coding agent like Claude Code is configured across half a dozen
14
- files (`settings.json`, `CLAUDE.md`, memory notes, MCP registrations,
15
- hook scripts, per-project overrides), and no single file answers
16
- *"what can this agent do right now, and why is it set up that way?"*.
17
- `harness` puts all of it in one YAML you read, validate, and diff;
18
- generates the config the agent loads from it; and at runtime blocks
19
- tool calls that violate the declared rules while recording every
20
- decision.
21
-
22
- ## See it work
23
-
24
- One rule, declared in `harness.yaml`: *no session may merge a PR
25
- until it has logged a review.*
26
-
27
- Claude Code goes to merge PR 42. Before the tool call runs, the
28
- runtime hands the event to `harness`, which checks it against the
29
- manifest. The hook protocol wire shape is the legacy engine-vocabulary
30
- envelope (operators see this on stderr; agents read it via
31
- `permissionDecisionReason` when the policy declares no `ux:` block):
32
-
33
- ```console
34
- $ harness policy intercept # Claude Code runs this before each tool call
35
- {"decision":"block","reason":"review-before-merge: no matching ledger entry for tag `review:42`","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"review-before-merge: no matching ledger entry for tag `review:42`"}}
36
- ```
37
-
38
- Built-in block-enforcement policies ship a `ux:` block since v0.17.0,
39
- so the agent sees a plain-language three-section form
40
- ([`docs/for-agents.md`](docs/for-agents.md#agent-facing-block-messages-ux-block));
41
- the engine-vocabulary text above stays in the audit ledger.
42
-
43
- Blocked. `harness explain` says exactly why:
44
-
45
- ```console
46
- $ harness explain review-before-merge --trace
47
- name: review-before-merge
48
- decision: deny
49
- enforcement: block
50
- reason: no matching ledger entry for tag `review:42`
51
- ledgerTag: review:42
52
- extract:
53
- PR_NUMBER: "42"
54
- requiresEval:
55
- matchedCount: 0
56
- reason: no matching ledger entry for tag `review:42`
57
- # ... (trimmed; the full trace also shows the matched trigger, every extracted variable, and the ledger query)
58
- ```
59
-
60
- The rule pulled `PR_NUMBER=42` out of the tool call and looked for a
61
- `review:42` entry in the evidence ledger. There wasn't one. So the
62
- reviewer (or a review subagent) logs that entry, and the *same* merge
63
- call, retried, goes straight through, no restart, no config edit:
64
-
65
- ```console
66
- $ harness policy intercept # same call, after the review was logged
67
- $ # (no output, exit 0: allowed)
68
- ```
69
-
70
- Every one of those decisions is recorded:
71
-
72
- ```console
73
- $ harness audit --since 1h --policy review-before-merge
74
- timestamp policy outcome reason
75
- ------------------- ------------------- ------- --------------------------------------------
76
- 2026-05-14 19:09:03 review-before-merge deny no matching ledger entry for tag `review:42`
77
- 2026-05-14 19:09:13 review-before-merge allow 1 matching ledger entry for tag `review:42`
78
- ```
79
-
80
- Declare the rule once; every session is held to it, with a paper
81
- trail of every decision.
82
-
83
- ## Concepts in six lines
8
+ files: `settings.json`, `CLAUDE.md`, memory notes, MCP registrations,
9
+ hook scripts, per-project overrides. No single file answers "what can
10
+ this agent do right now, and why is it set up that way?" `harness`
11
+ puts all of it in one zod-validated YAML manifest you read, validate,
12
+ and diff; generates the config the agent runtime loads from it; and at
13
+ runtime blocks tool calls that violate the declared rules while
14
+ recording every decision to an evidence ledger. Most config tools
15
+ describe what an agent is configured to use; `harness` decides what it
16
+ is allowed to do, under the exact context, and records why.
17
+
18
+ ## Key concepts
84
19
 
85
20
  | Term | What it is |
86
21
  |------|-----------|
@@ -91,213 +26,82 @@ trail of every decision.
91
26
  | **hook** | A script the agent runtime runs at a lifecycle event (session start, before every tool call, ...). How policies get enforced. |
92
27
  | **policy pack** | A reusable bundle of policies, hooks, and templates shipped under one name and enabled with a single manifest key. |
93
28
 
94
- ## What harness does
95
-
96
- ```mermaid
97
- flowchart LR
98
- declare["1. Declare<br/><code>harness.yaml</code>"]
99
- apply["2. Apply<br/><code>harness apply</code>"]
100
- enforce["3. Enforce<br/>hooks + policies<br/>at runtime"]
101
- record[("4. Record<br/>evidence ledger")]
102
- observe["5. Observe<br/><code>audit</code> / <code>explain</code> /<br/><code>session-export</code>"]
103
-
104
- declare --> apply
105
- apply --> enforce
106
- enforce --> record
107
- record --> observe
108
- observe -. refine .-> declare
109
- ```
110
-
111
- Observe → refine → declare is the whole loop. The read-side surfaces
112
- (`audit`, `explain --trace`, `session-export`) replay rows the runtime
113
- already recorded, so what flows back into the manifest is grounded in
114
- what actually happened.
115
-
116
- ## Pick your audience
117
-
118
- - **Operator?** [`docs/for-humans.md`](docs/for-humans.md): install through first `apply`, first real policy, diagnostics cheat sheet.
119
- - **Agent (or onboarding one)?** [`docs/for-agents.md`](docs/for-agents.md): workflow lifecycle, policy / ledger sequence, CLI cheat sheet by side-effect class, the audit triumvirate.
120
- - **Writing your own policy?** [`docs/writing-custom-policies.md`](docs/writing-custom-policies.md): three tripwires, four worked recipes (each validated in CI), author loop, field reference.
121
- - **Guarding destructive runtime commands?** [`docs/runtime-reality-hook.md`](docs/runtime-reality-hook.md): block compose/systemctl/kill/deploy calls when the live process state has drifted from what the docs expect.
122
- - **Looking up a CLI verb?** [`docs/CLI.md`](docs/CLI.md): every command the `harness` binary exposes, grouped by purpose (manifest, runtime, hooks, approvals, gates, preflight).
123
-
124
- ## Install
29
+ ## Quick start
125
30
 
126
31
  ```bash
127
- npm i -g @lannguyensi/harness
32
+ npm i -g @lannguyensi/harness # Node 20 or newer
33
+ harness init --interactive # guided wizard, or: --template solo|team|full
128
34
  ```
129
35
 
130
- The CLI binary is `harness`. Node 20 or newer required.
36
+ The wizard detects your `~/.claude/` and `~/.codex/` setup, MCP
37
+ servers already wired in `settings.json`, and the harness binary
38
+ version, then writes a starting `harness.yaml`. Full operator
39
+ walkthrough: [`docs/for-humans.md`](docs/for-humans.md); a five-minute
40
+ non-interactive path plus a profile comparison table:
41
+ [`docs/quickstart.md`](docs/quickstart.md).
131
42
 
132
- ## First-time setup
43
+ ## Usage
133
44
 
134
- In a hurry? [`docs/quickstart.md`](docs/quickstart.md) is the bare
135
- command path, install to wired-in, no prose.
45
+ Preview which policies would fire for a tool call, before any ledger
46
+ I/O or file write:
136
47
 
137
48
  ```bash
138
- harness init --interactive
139
- ```
140
-
141
- Guided wizard. Detects `~/.claude/` and `~/.codex/`, MCP servers
142
- already wired in `settings.json`, harness binary version. Picks a
143
- profile (`solo` / `team` / `full` / `custom`) and writes a starting
144
- `harness.yaml`. Ctrl-C aborts cleanly. Walkthrough +
145
- limitations: [`docs/init-interactive.md`](docs/init-interactive.md).
146
-
147
- Every enabled `tools.mcp[]` server is then registered with Claude
148
- Code via `claude mcp add-json --scope user` — the surface Claude Code
149
- actually reads for user-scope MCP servers; harness itself never
150
- writes `~/.claude.json`. This step requires the `claude` CLI on
151
- `$PATH`; if it's missing, the wizard warns and prints the equivalent
152
- `claude mcp add-json` commands to run by hand instead of failing.
153
- `harness doctor` includes a "Claude Code MCP Registration" section
154
- that re-verifies the live registration via `claude mcp list`.
155
-
156
- ### Profiles at a glance
157
-
158
- | Profile | External accounts / tools required | Best for |
159
- |---------|------------------------------------|----------|
160
- | `solo` | None. `npm` + Claude Code is enough. | Single operators who want the Understanding Gate without committing to a tasking system. |
161
- | `team` | An **agent-tasks** account ([hosted](https://agent-tasks.opentriologue.ai) or [self-hosted](https://github.com/LanNguyenSi/agent-tasks)). | Teams that already use `agent-tasks` for PR review tracking. The merge gate (`review:<pr-number>` ledger tag) wires against the agent-tasks MCP. |
162
- | `full` | Same as `team` plus `@lannguyensi/agent-preflight` and `gh` on PATH. | Operators who want every reference policy enforced (dogfood gate, preflight gates, review-subagent gate, merge gate). |
163
-
164
- **Not using agent-tasks?** Pick `solo`. The `team` and `full` review gates currently match only the agent-tasks MCP tool names, so a `gh pr create` workflow stays unprotected by them today. Tool-agnostic gates that also match `gh pr` are tracked in the backlog.
165
-
166
- If you prefer non-interactive (CI, fresh-VM provisioning), pick a
167
- template directly:
168
-
169
- ```bash
170
- harness init --template solo # memory-router + understanding-before-execution pack
171
- harness init --template team # solo + agent-tasks MCP + review-before-merge policy
172
- harness init --template full # everything from the Appendix A reference manifest
49
+ harness dry-run "merge PR 42" \
50
+ --tool mcp__agent-tasks__pull_requests_merge \
51
+ --tool-args '{"prNumber":42}'
173
52
  ```
174
53
 
175
- Use `harness init --probe` for a JSON snapshot of detected runtimes
176
- and MCPs without writing anything.
177
-
178
- ## Try it without installing
54
+ Once a manifest is applied, the same shape runs live as
55
+ `harness policy intercept` (invoked by the runtime's PreToolUse hook),
56
+ and `harness explain <policy> --trace` / `harness audit --since 1h`
57
+ replay what actually fired and why. The full install-to-audit
58
+ walkthrough is in [`docs/quickstart.md`](docs/quickstart.md).
59
+
60
+ ## Documentation
61
+
62
+ - [`docs/for-humans.md`](docs/for-humans.md): operator path, install through first real policy, diagnostics cheat sheet.
63
+ - [`docs/for-agents.md`](docs/for-agents.md): agent integration contract, workflow lifecycle, CLI cheat sheet by side-effect class.
64
+ - [`docs/quickstart.md`](docs/quickstart.md): five-minute bare-command path and profile comparison.
65
+ - [`docs/init-interactive.md`](docs/init-interactive.md): the `harness init --interactive` wizard, walkthrough and limitations.
66
+ - [`docs/CLI.md`](docs/CLI.md): every CLI verb, grouped by purpose.
67
+ - [`docs/risk-gate.md`](docs/risk-gate.md): the four-way `allow / warn / require_approval / deny` Risk Gate.
68
+ - [`docs/writing-custom-policies.md`](docs/writing-custom-policies.md): tripwires, worked recipes, and the policy field reference.
69
+ - [`docs/runtime-reality-hook.md`](docs/runtime-reality-hook.md): blocking destructive runtime commands when live process state has drifted from what the docs expect.
70
+ - [`docs/policy-packs/README.md`](docs/policy-packs/README.md): the built-in policy packs (`understanding-before-execution`, `branch-protection`, `solution-acceptance`, `post-merge-gate`).
71
+ - [`docs/uninstall.md`](docs/uninstall.md): the single-command teardown, dry-run by default.
72
+ - [`docs/examples/full-manifest.yaml`](docs/examples/full-manifest.yaml): a schema-coverage reference (not a runnable config; its header explains why).
73
+ - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): manifest schema, file layout, CLI surface (historical design intent; see its own note for the current shape).
74
+ - [`docs/VISION.md`](docs/VISION.md): why a declarative control plane, not more individual tools.
75
+ - [`docs/ROADMAP.md`](docs/ROADMAP.md): the phase-by-phase plan and the acceptance criteria each phase shipped against.
76
+ - [`CHANGELOG.md`](CHANGELOG.md): what shipped in each version.
77
+
78
+ ### Related projects
79
+
80
+ - [`agent-grounding`](https://github.com/LanNguyenSi/agent-grounding): evidence-ledger, claim-gate, review-claim-gate; `grounding-mcp` is the canonical client surface harness queries.
81
+ - [`agent-memory`](https://github.com/LanNguyenSi/agent-memory): the memory surfaces the control plane inventories.
82
+ - [`agent-tasks`](https://github.com/LanNguyenSi/agent-tasks): MCP-registered task platform whose registration and health appear in `harness describe`.
83
+ - [`agent-preflight`](https://github.com/LanNguyenSi/agent-preflight): local preflight validator; the canonical implementation of preflight-hook content harness wires.
84
+ - [`codebase-oracle`](https://github.com/LanNguyenSi/codebase-oracle): opt-in MCP for multi-repo RAG search; wire via `harness add mcp codebase-oracle --command codebase-oracle,mcp`.
85
+ - [`agent-dx`](https://github.com/LanNguyenSi/agent-dx): ships `git-batch-cli`, a day-to-day tool whose inventory appears in `harness describe`.
179
86
 
180
- `harness dry-run` reports which hooks fire and which policies match
181
- for a given tool call, against the reference manifest, before any
182
- ledger I/O:
87
+ ## Development and contributing
183
88
 
184
89
  ```bash
185
- git clone https://github.com/LanNguyenSi/harness && cd harness
186
- npm install && npm run build
187
- node dist/cli/main.js dry-run "merge PR 42" \
188
- --tool mcp__agent-tasks__pull_requests_merge \
189
- --tool-args '{"prNumber":42}' \
190
- --config docs/examples/full-manifest.yaml
90
+ npm install
91
+ npm run build
92
+ npm test
191
93
  ```
192
94
 
193
- `docs/examples/full-manifest.yaml` is a schema-coverage example, not a
194
- runnable config (the file header spells out the contract). For a
195
- manifest tailored to your machine, install globally and run
196
- `harness init --interactive`.
197
-
198
- ## Uninstall
199
-
200
- `harness uninstall` is the single-command teardown: dry-run by default,
201
- `--apply` to mutate, `--restore-from <backup>` to roll back. Full
202
- inventory + recommended order in [`docs/uninstall.md`](docs/uninstall.md).
95
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full PR checklist
96
+ (import-boundary and duplication gates, changelog coverage, release
97
+ steps).
203
98
 
204
99
  ## Status
205
100
 
206
- harness ships in phases. All seven are released: read-only inventory →
207
- managed edits → declarative truth → policy layer → polish and dogfood
208
- lessons → the Understanding Gate Policy Pack → the Risk Gate. Phase 7
209
- (the Risk Gate) landed in `v0.27.0`. Operator-surface milestones along
210
- the way: `harness pause/resume` in `v0.22.0`, `migrate-home` in
211
- `v0.24.0`, Codex-runtime adapter polish in `v0.28.x` and `v0.29.0`,
212
- `approve risk --force` in `v0.30.0`, the opt-in `runtime-reality`
213
- drift gate in `v0.31.0`, the opt-in `solution-acceptance` pack in
214
- `v0.32.0`, the operator-only `approve branch-protection` marker in
215
- `v0.33.0`, the `harness gc` retention cleanup plus non-TTY-safe
216
- confirmations in `v0.34.0`, `apply` failing loud when policies are
217
- declared without `grounding-mcp` wired in `v0.35.0`, and the
218
- `harness doctor --rm-rogue-ledgers` cleanup mode plus the read-only
219
- Bash classifier re-admitting `sort` / `tree` / `file` in `v0.36.0`.
220
- Selected milestones since then (not exhaustive; see `CHANGELOG.md`
221
- for the full list): HMAC-signed understanding-gate approval markers
222
- in `v0.41.0`, the opt-in `post-merge-gate` policy pack in `v0.42.0`,
223
- the `&`-as-command-boundary trigger fix in `v0.43.0`, per-policy
224
- cross-repository attribution for `${REPO}`/`${BRANCH}` preflight
225
- facts in `v0.44.0`, and `apply --runtime codex|opencode` next-steps
226
- hints in `v0.46.0`.
227
- The current release is `v0.59.0`.
228
-
229
- The phase-by-phase plan with acceptance criteria lives in
230
- [`docs/ROADMAP.md`](docs/ROADMAP.md); what shipped in each version is
231
- in [`CHANGELOG.md`](CHANGELOG.md).
232
-
233
- ## Policy Packs
234
-
235
- A *Policy Pack* is a reusable bundle of hooks, policies, instruction
236
- template, and permission profiles shipped under one name and enabled
237
- from `harness.yaml` with a single key:
238
-
239
- ```yaml
240
- policy_packs:
241
- - name: understanding-before-execution
242
- config:
243
- mode: grill_me # fast_confirm | grill_me | strict
244
- permission_profile: safe-start # safe-start | implementation-after-approval | high-risk-grill-me
245
- ```
246
-
247
- Manage packs with `harness pack add / remove / list`. Four packs ship
248
- today: [`understanding-before-execution`](docs/policy-packs/understanding-before-execution.md)
249
- (forces an Understanding Report before any write-capable tool fires),
250
- [`branch-protection`](docs/policy-packs/branch-protection.md)
251
- (blocks source mutations on protected branches without an explicit
252
- override), the opt-in
253
- [`solution-acceptance`](docs/policy-packs/solution-acceptance.md)
254
- completion gate (added in `v0.32.0`; holds a task done until an
255
- accepted solution verdict is logged), and the opt-in
256
- [`post-merge-gate`](docs/policy-packs/post-merge-gate.md) pack (added
257
- in `v0.42.0`; denies mutating git/gh work on a branch whose tip has
258
- already been merged). Custom packs from `path:`, `npm:`, or `git:` sources are
259
- out of scope for v1 (see the pack docs for the future-vocabulary
260
- contract).
261
-
262
- ## What's next
263
-
264
- The seven-phase roadmap is complete. The Risk Gate (Phase 7) shipped in
265
- `v0.27.0`: `harness policy intercept` reasons about the action itself
266
- (Action Envelope → Context Resolver → Risk Classifier), evaluates each
267
- policy's `when:` clauses, and enforces a four-way `allow / warn /
268
- require_approval / deny` decision, so `DROP TABLE users`, `kubectl
269
- delete namespace prod`, and `terraform destroy` against an unverified
270
- production target are blocked before the runtime fires them. See
271
- [`docs/risk-gate.md`](docs/risk-gate.md).
272
-
273
- Capability beyond the seven phases is not a quiet roadmap expansion: it
274
- lands as an explicit follow-up design doc or a separate sibling
275
- project, per [`docs/ROADMAP.md`](docs/ROADMAP.md) ("Out of scope across
276
- all phases").
277
-
278
- > Bring your favorite agent harness. Add governance.
279
-
280
- ## Why this exists
281
-
282
- On 2026-04-23, an `agent-grounding` checkout that was 16 commits
283
- behind origin led two tasks to be incorrectly called "stale". The
284
- check that would have caught it already existed:
285
- [`agent-preflight`](https://github.com/LanNguyenSi/agent-preflight)
286
- runs `git fetch` + `git status` and emits a structured `ready` +
287
- confidence-score result. The missing piece was not the check, it was
288
- the deterministic *trigger*: a `SessionStart` hook that invokes
289
- `preflight run` and a policy that gates further work on the result.
290
- Building that wiring needs an agreed-upon place for harness config to
291
- live first. That conversation is the origin of this repo.
292
-
293
- ## Related
294
-
295
- - [`agent-grounding`](https://github.com/LanNguyenSi/agent-grounding): evidence-ledger, claim-gate, review-claim-gate; `grounding-mcp` is the canonical client surface harness queries through `queryLedgerByTag`.
296
- - [`agent-memory`](https://github.com/LanNguyenSi/agent-memory): the memory surfaces the control plane inventories.
297
- - [`agent-tasks`](https://github.com/LanNguyenSi/agent-tasks): MCP-registered task platform whose registration + health appear in `harness describe`.
298
- - [`agent-preflight`](https://github.com/LanNguyenSi/agent-preflight): local preflight validator; the canonical implementation of preflight-hook content harness wires.
299
- - [`codebase-oracle`](https://github.com/LanNguyenSi/codebase-oracle): opt-in MCP for multi-repo RAG search; not in Full, wire via `harness add mcp codebase-oracle --command codebase-oracle,mcp`.
300
- - [`agent-dx`](https://github.com/LanNguyenSi/agent-dx): ships `git-batch-cli`, a day-to-day tool whose inventory appears in `harness describe`.
101
+ The current release is `v0.59.2`. All seven planned phases have
102
+ shipped; phase acceptance criteria are in
103
+ [`docs/ROADMAP.md`](docs/ROADMAP.md), and what shipped in each version
104
+ is in [`CHANGELOG.md`](CHANGELOG.md).
301
105
 
302
106
  ## License
303
107
 
@@ -397,7 +397,7 @@ function buildExpectedFiles(manifest, opts, manifestPath, generatedDir) {
397
397
  // settings.json, same reasoning as the codex branch above --
398
398
  // settings.json is Claude Code's contract and meaningless to
399
399
  // opencode. MEMORY.md ships unchanged. Pack instructions.md files
400
- // are NOT runtime-agnostic (HIGH-F1, batch18 fix-round, task
400
+ // are NOT runtime-agnostic (batch18 fix-round, task
401
401
  // f34eb233): each builtin pack's buildInstructions() branches on
402
402
  // `runtime` and, under opencode, documents that its hooks are
403
403
  // UNSUPPORTED/not-wired (opencode has no declarative hook/event
@@ -482,7 +482,7 @@ function buildPrevManifestForHints(record) {
482
482
  // `emitRestartHints` are the same view. A snapshot written by a
483
483
  // release that predates the derivation therefore compares equal to
484
484
  // today's manifest instead of emitting a one-time phantom "hooks
485
- // changed" hint (review round 3, 99f47307 Slice 1).
485
+ // changed" hint (task 99f47307, Slice 1).
486
486
  return withDerivedPolicies(parseManifest(parsed));
487
487
  }
488
488
  catch {