@lannguyensi/harness 0.58.2 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -3
  2. package/README.md +70 -266
  3. package/dist/cli/apply/apply.d.ts +56 -2
  4. package/dist/cli/apply/apply.js +229 -15
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/apply/index.d.ts +1 -1
  7. package/dist/cli/apply/index.js +1 -1
  8. package/dist/cli/apply/index.js.map +1 -1
  9. package/dist/cli/apply/install-codex-config.d.ts +15 -0
  10. package/dist/cli/apply/install-codex-config.js +156 -11
  11. package/dist/cli/apply/install-codex-config.js.map +1 -1
  12. package/dist/cli/delegate/index.js +11 -5
  13. package/dist/cli/delegate/index.js.map +1 -1
  14. package/dist/cli/index.js +13 -5
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/init/composer.js +1 -0
  17. package/dist/cli/init/composer.js.map +1 -1
  18. package/dist/cli/init/interactive.js +14 -5
  19. package/dist/cli/init/interactive.js.map +1 -1
  20. package/dist/cli/init/profiles.js +1 -0
  21. package/dist/cli/init/profiles.js.map +1 -1
  22. package/dist/cli/init/templates.js +1 -0
  23. package/dist/cli/init/templates.js.map +1 -1
  24. package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
  25. package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
  26. package/dist/cli/pack/hook-codex-pre-tool-use.js +8 -1
  27. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  28. package/dist/cli/pack/hook-post-tool-use.js +16 -4
  29. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  30. package/dist/cli/pack/hook-pre-tool-use.js +11 -1
  31. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  32. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +6 -0
  33. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  34. package/dist/cli/pack/hook-solution-acceptance.d.ts +70 -2
  35. package/dist/cli/pack/hook-solution-acceptance.js +81 -28
  36. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  37. package/dist/cli/pack/remove.js +8 -6
  38. package/dist/cli/pack/remove.js.map +1 -1
  39. package/dist/cli/smoke/index.js +12 -1
  40. package/dist/cli/smoke/index.js.map +1 -1
  41. package/dist/io/last-apply.d.ts +1 -0
  42. package/dist/io/last-apply.js +8 -1
  43. package/dist/io/last-apply.js.map +1 -1
  44. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +4 -2
  45. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +87 -3
  46. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  47. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +7 -2
  48. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -1
  49. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +2 -1
  50. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
  51. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  52. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  53. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  54. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +2 -1
  55. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +1 -1
  56. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -1
  57. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +24 -1
  58. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +20 -2
  59. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  60. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +31 -9
  61. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +29 -19
  62. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  63. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +50 -0
  64. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +46 -6
  65. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
  66. package/dist/policy-packs/builtin/understanding-before-execution.js +11 -7
  67. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  68. package/dist/runtime/approval-signing.d.ts +7 -0
  69. package/dist/runtime/approval-signing.js +31 -4
  70. package/dist/runtime/approval-signing.js.map +1 -1
  71. package/dist/runtime/task-providers/agent-tasks.d.ts +12 -1
  72. package/dist/runtime/task-providers/agent-tasks.js +12 -0
  73. package/dist/runtime/task-providers/agent-tasks.js.map +1 -1
  74. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.59.1] - 2026-09-29
11
+
12
+ ### Fixed
13
+
14
+ - **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.
15
+
16
+ - **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.
17
+
18
+ - **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`.
19
+
20
+ ## [0.59.0] - 2026-09-25
21
+
22
+ ### Upgrade notes
23
+
24
+ - Outside `approval_lifecycle: { mode: session }`, a live session approval written by an earlier release carries no task binding and is refused once: run `harness approve understanding` again after upgrading. From now on a session approval stops counting when the agent claims a different task (pre-approve a batch with `--task a b c`). Under `mode: session` nothing changes.
25
+ - Downgrading fails closed: an earlier release treats session and branch-protection markers written by 0.59.0 as forged; re-approve with the older binary after a downgrade.
26
+ - If you keep your own `expire_on_tool_match` list, add `mcp__agent-tasks__task_merge` to it so a review-then-`task_merge` path expires the approval.
27
+ - A plain `harness apply` now reuses the runtime the last apply used (for a record written by an earlier release it is inferred from the generated files and named in the output). If you alternate runtimes, pass `--runtime claude-code` (or `--target`) explicitly.
28
+
29
+ ### Changed
30
+
31
+ - **The understanding gate's approval now expires on the same boundary as the active-claim marker, and the session approval marker is bound to the task it was granted for** (task `5018c0c4`, operator decisions: align, then bind the session marker to the task). Expiry: the PostToolUse expiry hooks (`harness pack hook post-tool-use` and the Codex `codex-post-tool-use` sibling) share `matchPostToolUseBoundary`, which asks the one decider, `claimEffectForAgentTasksTool`, with the event's `tool_response` for every listed agent-tasks claim lifecycle verb (`ACTIVE_CLAIM_TOOL_NAMES`); a "claim kept" answer is not a boundary. `task_finish` to `review` keeps the session and task approval markers; `task_finish` to `done`, `task_abandon`, `task_merge` and `tasks_transition` to `done` expire them; an absent or unreadable `task_finish` result expires (the decider's fail-safe `release`). The former local `tasks_transition` status filter is replaced by the same decider (same outcome), and its skip diagnostic now reads `<tool> keeps the work claim per the active-claim decider`. `pull_requests_merge`, a listed `task_submit_pr` and a listed `task_start` still expire on list membership. Binding: every session marker `writeApprovalMarker` writes (the `harness approve understanding` CLI and the hook's auto-approval path) now records, as a signed `claimTaskId` field, the task id of the active claim at that moment (`null` when none is held); outside `approval_lifecycle: { mode: session }`, the PreToolUse gates (Claude and Codex), the subagent-start in-flight hand-off and `harness delegate`'s parent check accept the session marker only while the current active claim has that same id (`checkSessionApprovalMarker`). Under `mode: session` the binding is not applied (operator decision): the session marker, bound or unbound, counts for every task until `max_age`, as that mode documents; the field is still written, so leaving `mode: session` takes effect on the next check. A different task, or no claim where one was recorded (or the reverse), blocks exactly like a missing marker, with a diagnostic that says the approval belongs to another task (on stderr for the Claude hook, whose stdout deny reason stays the generic one; in the block reason for the Codex hook), and never reads as an expired approval. So, outside `mode: session`, an approval no longer carries over to the next task when the previous one was completed outside the session (merged in the UI or on GitHub, moved to `done` by a webhook), where no PostToolUse hook can expire anything. Resuming the same task id keeps the approval; the task-scoped markers (`--task`, the active-claim auto-resolve) are unchanged. `task_merge` is added to the explicit `expire_on_tool_match` list of every `harness init` scaffold (full, team, and the interactive composer) and of `docs/examples/full-manifest.yaml`: the runtime boundary list comes from that config alone, so without it the review-then-`task_merge` path would never expire the approval in-session. `task_merge` is also in `DEFAULT_BOUNDARY_TOOL_NAMES`, which only shapes the emitted PostToolUse matcher when no explicit list is configured. Pinned by `tests/cli/pack-hook-post-tool-use.test.ts`, `tests/cli/pack-hook-codex-post-tool-use.test.ts`, `tests/cli/pack-hook-session-approval-task-binding.test.ts` and `tests/cli/init-scaffold-approval-expiry.test.ts`, driven by the real content-block PostToolUse capture `tests/fixtures/track-active-claim/real-posttooluse-task-finish-2.1.280.json`. Upgrade notes: outside `mode: session`, a session marker written by an earlier release carries no binding and is not accepted, so a live session needs one fresh `harness approve understanding` after the upgrade, and a session approval stops counting when the agent claims a different task (pre-approve a batch with `--task a b c` to avoid a prompt per task); under `approval_lifecycle: { mode: session }` nothing changes, including the documented residual that an out-of-band completion followed by a `task_start` on another task keeps the approval until `max_age`; operators with their own `expire_on_tool_match` list add `mcp__agent-tasks__task_merge` to it if they merge through `task_merge`. A downgrade to an earlier release fails closed: its binary does not know the signed `claimTaskId` field, so it rejects every session and branch-protection marker written by this release as forged (not merely unapproved); re-approve with the older binary after a downgrade.
32
+
33
+ ### Fixed
34
+
35
+ - **`harness apply` without `--runtime` now reuses the runtime the last apply used instead of silently switching to `claude-code`** (task `b9e6d63c`). After `harness apply --runtime codex`, a plain `harness apply` used to regenerate the runtime-specific files (policy-pack `instructions.md`, `settings.json` instead of `codex/config.toml`) for `claude-code`, report `applied N file(s)`, and neither `--dry-run` nor the apply said the runtime had changed. `.last-apply` now records the runtime (`runtime` field), and apply without `--runtime` (including `--install`) reuses it and prints `runtime: codex (from last apply; pass --runtime to change)`. A `.last-apply` written by an older release has no such field: apply then reads the runtime off its files map (only `codex/config.toml` means codex, only `opencode/opencode.json` opencode, only `settings.json` claude-code) and prints `runtime: codex (inferred from the last apply's generated files; pass --runtime to change)`; when the map holds both `settings.json` and `codex/config.toml` (a machine that applied claude-code and later codex), the recorded policy-pack `instructions.md` entries decide when every one names the same runtime in its `## Runtime` section; when the map is still ambiguous it uses `claude-code` and prints `runtime: claude-code (default; the last apply did not record a runtime and generated files for claude-code and codex; pass --runtime codex to keep codex)`, or `runtime: claude-code (default; the last apply did not record a runtime, pass --runtime to choose)` when the map names no runtime. An explicit `--runtime` that differs from the previous one is honoured, and both `--dry-run` and the apply print `runtime: <old> -> <new> (switching from the last apply's runtime)`. `--target` without `--runtime` implies `claude-code` (it wires Claude Code's settings.json) and prints `runtime: claude-code (implied by --target)`, or `runtime: codex -> claude-code (--target implies claude-code)` after a codex apply; an explicit `--runtime codex` or `--runtime opencode` with `--target` is still refused. `harness smoke` and the `harness init --interactive` claude-code wire step pass `claude-code` explicitly. `harness smoke` keeps the runtime `.last-apply` records (new `apply()` option `preserveRecordedRuntime`): after a codex apply it prints `runtime: claude-code for smoke; the recorded runtime codex is kept, the next harness apply restores it`, and the next plain apply reuses codex and rewrites the codex files. `harness pack remove --force` keeps the recorded runtime when it prunes the pack's entries from `.last-apply` (it used to rebuild the record without it, so the next plain apply fell back to reading the files map). For an older record the `instructions.md` entries count only for the packs its own manifest snapshot lists (enabled ones), so an entry left over from a pack a later apply no longer had does not decide the runtime; without a readable snapshot every entry counts. An `--install` refusal for an inferred runtime says `inferred from the last apply's generated files` instead of `reused from the last apply`. With no `.last-apply` the default stays `claude-code`; an apply that changes nothing else stamps the runtime into a record that lacks it. A non-string `runtime` in `.last-apply` is ignored like an unknown value instead of failing every reader of the record. The `--json` result carries `runtime`, `runtimeSource`, `previousRuntime` and, for an ambiguous older record, `runtimeCandidates`. `harness diff --since-apply` is unchanged: it compares `.last-apply` against disk and never regenerates, so it has no runtime to select. Pinned by `tests/cli/apply/apply-runtime-reuse.test.ts` (with a fixture of the merged older record's shape), `tests/cli/smoke/smoke.test.ts` and `tests/cli/pack.test.ts`.
36
+
37
+ - **`harness apply --runtime codex --install` keeps a symlinked (dotfiles-managed) `~/.codex/config.toml` a symlink** (task `1637fbc8`). The install wrote through `atomicWriteFile`, whose temp-file rename replaced the link with a regular file and left the link's target (typically a dotfiles repo) unchanged, so the two silently diverged. `planCodexConfigInstall` now resolves a symlinked config path with `realpath` and plans against the resolved regular file: the atomic write's temp file is created in the target's own directory (so the rename stays on one filesystem), the target's file mode is preserved, and every link in the chain is left untouched. The backup holds the target's previous content with the target's mode but sits beside the link (for example `~/.codex/config.toml.harness-backup-<timestamp>`), never beside the target: the config holds secrets, and the target's directory is typically a dotfiles git work tree. The plan, the `--install` result and the `--json` outcome report the real file written as `configPath` (also shown by `--dry-run`) and the link as `linkPath` (absent for a regular file); the summary and the `harness init` wizard's install line add `(via symlink <link>)`, and a refusal that points at the `.harness-backup-*` files names the link's backup location. A dangling link (the refusal names the missing end of the chain), an unresolvable chain (a loop, a permission error) or a link to anything other than a regular file (such as a directory) is refused with a `CodexInstallRefusalError` naming the link, and nothing is written. `atomicWriteFile` itself and non-symlink config paths (including a regular config inside a symlinked `~/.codex` directory) are unchanged. Pinned by fixtures in `tests/cli/apply/apply-codex-runtime.test.ts` and `tests/cli/init-interactive.test.ts`.
38
+
10
39
  ## [0.58.2] - 2026-09-24
11
40
 
12
41
  ### Fixed
@@ -33,9 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
33
62
 
34
63
  **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.
35
64
 
36
- 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.
65
+ 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.
37
66
 
38
- 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.
67
+ 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 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.
39
68
 
40
69
  ## [0.58.0] - 2026-09-23
41
70
 
@@ -62,7 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
62
91
 
63
92
  - **`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.
64
93
 
65
- - **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.
94
+ - **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.
66
95
 
67
96
  - **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.
68
97
 
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.58.2`.
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.1`. 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
 
@@ -61,8 +61,11 @@ export interface ApplyOptions {
61
61
  /**
62
62
  * Phase 6 #6 — `--runtime <claude-code|codex>`. Selects which adapter
63
63
  * shape policy-pack hooks expand into and which artefacts apply
64
- * writes. Defaults to `claude-code` (settings.json output unchanged
65
- * from previous releases). When set to `codex`, settings.json is NOT
64
+ * writes. When omitted, `target` implies `claude-code`; otherwise the
65
+ * runtime recorded in `.last-apply` by the previous apply is reused
66
+ * (inferred from its files map for a record that predates the field);
67
+ * with no recorded or inferable runtime it defaults to `claude-code`
68
+ * (settings.json output unchanged from previous releases). When set to `codex`, settings.json is NOT
66
69
  * written; instead `harness.generated/codex/config.toml` carries the
67
70
  * Codex adapter configuration. The two runtimes are mutually
68
71
  * exclusive in a single apply for v1; cross-runtime applies are a
@@ -81,6 +84,18 @@ export interface ApplyOptions {
81
84
  now?: Date;
82
85
  /** Test-injectable confirmation prompt; defaults to a stdin readline. */
83
86
  prompt?: (message: string) => Promise<string>;
87
+ /**
88
+ * Keep the runtime `.last-apply` records instead of recording this
89
+ * apply's runtime. For a diagnostic apply (`harness smoke`) that must
90
+ * run under a fixed runtime without changing the operator's runtime
91
+ * selection: the files map is still updated, so the next plain apply
92
+ * reuses the kept runtime and rewrites that runtime's files back. A
93
+ * record without a runtime field gets the runtime inferred from it
94
+ * stamped instead (this apply's own files would otherwise change what
95
+ * the next apply infers); with nothing recorded or inferable the field
96
+ * stays absent.
97
+ */
98
+ preserveRecordedRuntime?: boolean;
84
99
  }
85
100
  export type ApplyOutcome = "no-changes" | "applied" | "drift-refuse" | "drift-discarded" | "would-apply" | "lock-drift-refuse" | "target-exists-refuse";
86
101
  export interface FileApplyOutcome {
@@ -93,7 +108,12 @@ export interface FileApplyOutcome {
93
108
  changed: boolean;
94
109
  }
95
110
  export interface CodexConfigInstallOutcome {
111
+ /** The regular file the install read and wrote; for a symlinked config,
112
+ * the link's resolved target (task 1637fbc8). */
96
113
  configPath: string;
114
+ /** The requested config path when it is a symlink, absent otherwise
115
+ * (task 1637fbc8). A backup of a symlinked config sits beside it. */
116
+ linkPath?: string;
97
117
  changed: boolean;
98
118
  written: boolean;
99
119
  summary: string;
@@ -152,7 +172,41 @@ export interface ApplyResult {
152
172
  targetMergeSummary?: string;
153
173
  /** Present when --runtime codex --install was requested. */
154
174
  codexConfigInstall?: CodexConfigInstallOutcome;
175
+ /** Adapter runtime this apply generated (or would generate) for. */
176
+ runtime: Runtime;
177
+ /**
178
+ * Where `runtime` came from: `explicit` (`--runtime` was passed),
179
+ * `target` (no `--runtime`, `--target` implies `claude-code`),
180
+ * `last-apply` (reused from the runtime `.last-apply` recorded),
181
+ * `inferred` (reused from a `.last-apply` that predates the field, read
182
+ * off its files map), `unrecorded` (such a record whose files map names
183
+ * no single runtime, so the default is used), or `default` (no
184
+ * `.last-apply` at all).
185
+ */
186
+ runtimeSource: RuntimeSource;
187
+ /**
188
+ * The runtime the previous apply recorded in `.last-apply`, or inferred
189
+ * from its files map when it predates the field. Absent when there is
190
+ * none.
191
+ */
192
+ previousRuntime?: Runtime;
193
+ /**
194
+ * `unrecorded` only: the runtimes the record's files map names (adapter
195
+ * artefacts and policy-pack instructions.md `## Runtime` sections) when
196
+ * they do not settle on one. Absent when the map names none.
197
+ */
198
+ runtimeCandidates?: Runtime[];
155
199
  }
200
+ export type RuntimeSource = "explicit" | "target" | "last-apply" | "inferred" | "unrecorded" | "default";
201
+ /**
202
+ * One line naming the runtime whenever it did not come from an explicit
203
+ * `--runtime` matching the last apply: a reused or inferred runtime, the
204
+ * default used for a record without one, the runtime `--target` implies,
205
+ * or an explicit switch away from the previous one. `null` when there is
206
+ * nothing to say (first apply, or an explicit `--runtime` equal to the
207
+ * previous one).
208
+ */
209
+ export declare function formatRuntimeLine(result: Pick<ApplyResult, "runtime" | "runtimeSource" | "previousRuntime" | "runtimeCandidates">): string | null;
156
210
  declare const DRIFT_HINT_MESSAGE = "run \"harness adopt <file>\" to capture changes, or re-run with --overwrite-drift to discard them";
157
211
  export declare function apply(opts?: ApplyOptions): Promise<ApplyResult>;
158
212
  export { DRIFT_HINT_MESSAGE };