@lannguyensi/harness 0.56.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -0
- package/README.md +1 -1
- package/dist/cli/apply/apply.js +4 -3
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/generate-codex-config.js +3 -3
- package/dist/cli/apply/generate-codex-config.js.map +1 -1
- package/dist/cli/apply/generate-memory-index.js +20 -10
- package/dist/cli/apply/generate-memory-index.js.map +1 -1
- package/dist/cli/apply/generate-settings.d.ts +7 -0
- package/dist/cli/apply/generate-settings.js +10 -1
- package/dist/cli/apply/generate-settings.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +4 -4
- package/dist/cli/doctor/format.js +68 -2
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.d.ts +6 -0
- package/dist/cli/doctor/index.js +214 -28
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/session-start-preflight-setup-version.d.ts +83 -0
- package/dist/cli/doctor/session-start-preflight-setup-version.js +114 -0
- package/dist/cli/doctor/session-start-preflight-setup-version.js.map +1 -0
- package/dist/cli/doctor/types.d.ts +35 -4
- package/dist/cli/doctor/types.js +2 -1
- package/dist/cli/doctor/types.js.map +1 -1
- package/dist/cli/doctor/ug-delegations.js +9 -0
- package/dist/cli/doctor/ug-delegations.js.map +1 -1
- package/dist/cli/explain-policy.d.ts +67 -0
- package/dist/cli/explain-policy.js +140 -3
- package/dist/cli/explain-policy.js.map +1 -1
- package/dist/cli/gc/index.js +28 -14
- package/dist/cli/gc/index.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +10 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +4 -3
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/dependencies.js +15 -9
- package/dist/cli/init/dependencies.js.map +1 -1
- package/dist/cli/init/templates.d.ts +1 -0
- package/dist/cli/init/templates.js +30 -4
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/list.js +19 -0
- package/dist/cli/list.js.map +1 -1
- package/dist/cli/loader.js +16 -1
- package/dist/cli/loader.js.map +1 -1
- package/dist/cli/pack/hook-bootstrap.js +2 -1
- package/dist/cli/pack/hook-bootstrap.js.map +1 -1
- package/dist/cli/pack/hook-codex-user-prompt-submit.d.ts +0 -32
- package/dist/cli/pack/hook-codex-user-prompt-submit.js +11 -118
- package/dist/cli/pack/hook-codex-user-prompt-submit.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +34 -7
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.d.ts +55 -0
- package/dist/cli/pack/hook-solution-acceptance.js +222 -16
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/hook-stay-in-scope.d.ts +5 -26
- package/dist/cli/pack/hook-stay-in-scope.js +62 -274
- package/dist/cli/pack/hook-stay-in-scope.js.map +1 -1
- package/dist/cli/pack/hook-track-active-claim.d.ts +1 -0
- package/dist/cli/pack/hook-track-active-claim.js +107 -43
- package/dist/cli/pack/hook-track-active-claim.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +32 -1
- package/dist/cli/session-start/index.js +142 -12
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +0 -2
- package/dist/cli/validate/checks.js +30 -30
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/io/harness-lock.js +11 -1
- package/dist/io/harness-lock.js.map +1 -1
- package/dist/io/lock.d.ts +26 -0
- package/dist/io/lock.js +25 -0
- package/dist/io/lock.js.map +1 -1
- package/dist/io/project-name.d.ts +115 -0
- package/dist/io/project-name.js +158 -0
- package/dist/io/project-name.js.map +1 -0
- package/dist/io/read-json-dir-entries.js +10 -0
- package/dist/io/read-json-dir-entries.js.map +1 -1
- package/dist/io/version-compare.d.ts +59 -0
- package/dist/io/version-compare.js +67 -0
- package/dist/io/version-compare.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.d.ts +50 -0
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.js +151 -0
- package/dist/policy-packs/builtin/solution-acceptance-reconnect.js.map +1 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +43 -4
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +43 -39
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance.js +4 -2
- package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +15 -0
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +5 -7
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +23 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +8 -17
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.d.ts +129 -0
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js +166 -0
- package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +129 -0
- package/dist/policy-packs/builtin/understanding-before-execution.js +44 -57
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/policy-packs/version-check.d.ts +8 -1
- package/dist/policy-packs/version-check.js +10 -6
- package/dist/policy-packs/version-check.js.map +1 -1
- package/dist/probes/memory.d.ts +34 -0
- package/dist/probes/memory.js +87 -14
- package/dist/probes/memory.js.map +1 -1
- package/dist/runtime/git-context.d.ts +125 -0
- package/dist/runtime/git-context.js +164 -1
- package/dist/runtime/git-context.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/task-providers/agent-tasks.d.ts +90 -0
- package/dist/runtime/task-providers/agent-tasks.js +231 -0
- package/dist/runtime/task-providers/agent-tasks.js.map +1 -0
- package/dist/runtime/workflow-policies.d.ts +1 -0
- package/dist/runtime/workflow-policies.js +8 -8
- package/dist/runtime/workflow-policies.js.map +1 -1
- package/dist/schema/index.d.ts +19 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/session-start-preflight.d.ts +10 -0
- package/dist/schema/session-start-preflight.js +106 -0
- package/dist/schema/session-start-preflight.js.map +1 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,192 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.58.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Corrected the 0.57.0 CLI-floor claim: `harness validate` does not execute
|
|
15
|
+
manifest version commands and now refers `tools.cli[].min_version` checks
|
|
16
|
+
to `harness doctor`, while callers supplying `versionProbe` retain comparison
|
|
17
|
+
(task `b32fdea2`).
|
|
18
|
+
|
|
19
|
+
- **Codex `UserPromptSubmit` now injects whenever Codex invokes the hook**
|
|
20
|
+
(task `f062b9f6`). The adapter no longer guesses from stdin prompt-field aliases
|
|
21
|
+
that a blank value represents an inter-agent notification. It still consumes
|
|
22
|
+
stdin, but event dispatch is the injection boundary. Pause remains first;
|
|
23
|
+
disabled-pack and manifest-load failures remain fail-open. A redacted genuine
|
|
24
|
+
Codex 0.156.1 operator fixture backs injection, and blank-prompt input is an
|
|
25
|
+
explicitly synthetic regression case that must still inject.
|
|
26
|
+
|
|
27
|
+
- **`harness.generated/active-claim` no longer clears on a `task_finish` that lands the task in `review`, and `task_merge` is now a tracked, dispatched verb end to end** (task `c86e3c4a`). `claimEffectForAgentTasksTool` (`src/runtime/task-providers/agent-tasks.ts`), the SINGLE decider for this classification, used to treat every `task_finish` as a release, disagreeing with agent-tasks's own documented v2 semantics (the work claim is cleared going to `done`, kept going to `review`); the two disagreeing left a session wedged (`task_start` on another task returns `already_claimed`, `task_abandon` on the review-state task returns `bad_state`, and the completion gate refuses every push/`task_merge` with "no active-claim task id recorded"). It now takes the tool RESULT as an optional third parameter and reads `task_finish`'s resulting `task.status` (via the new exported `taskFinishResultingStatus`, reading the mcp-server v1 receipt `{ ok, task: { id, status }, deviations? }`): only a resulting status of `review` returns `"none"` (keep); `done`, any other resolvable status, and an unreadable result all return `"release"`. `task_abandon` and `task_merge` both always release. `hook-track-active-claim.ts` no longer re-derives any of this locally (the round-1 fix's own local override is gone): it calls the classifier with `event.tool_response` and only presents the returned effect, one decider, no duplicate logic. `task_merge` is also now a member of `ACTIVE_CLAIM_TOOL_NAMES`, which feeds the generated PostToolUse matcher (`understanding-before-execution.ts`'s `TRACK_ACTIVE_CLAIM_MATCH` / `_CODEX`, both Claude and Codex variants) -- previously `task_merge` was untracked there too, so a real `task_merge` tool call never dispatched to this hook at all; every consumer of `ACTIVE_CLAIM_TOOL_NAMES` was enumerated (`rg`) and only these two matcher constants and this hook's own re-export consume it, none of them treating membership as "claim-acquiring" (acquire vs. release is a separate decision made by the classifier), so widening the set changes only which tools dispatch to this hook. The recovery path (`task_start` on the SAME review-state task) still needs no code change: `writeActiveClaim` already overwrites unconditionally. Pinned by: `tests/cli/pack-hook-track-active-claim.test.ts` (finish-to-done clears, finish-to-review keeps, finish-with-no-result and finish-with-malformed-status both fail safe, `task_merge` clears, review-then-restart recovery); `tests/runtime/task-providers/agent-tasks.test.ts` (the classifier's result-aware decision directly, `taskFinishResultingStatus`'s extraction, `task_merge` in `ACTIVE_CLAIM_TOOL_NAMES`); `tests/policy-packs/expand.test.ts` and `tests/cli/apply/generate-codex-config.test.ts` (the generated Claude and Codex matchers both now match `task_merge`, both the exact string and a live regex test, plus a negative control). Mutation probes (`agent-primitives probe --pre 'npm run build'`) reverting to clear-on-every-finish, keeping the marker on `done`, and removing `task_merge` from the tracked set all killed by these fixtures. Upgrade: the review-keep fix is in the hook itself and needs no re-apply; to dispatch `task_merge` to the hook, re-run `harness apply` (and re-merge the generated settings into your live Claude Code settings or Codex config) so the widened PostToolUse matcher is installed.
|
|
28
|
+
|
|
29
|
+
- **Round-2 review fix (commit `8dfba1b`): the round-1 fix was INERT in production -- Claude Code's real MCP `tool_response` is a content-block array, not the plain receipt object round 1 read** (task `c86e3c4a`, HIGH). Live capture, claude 2.1.280: a PostToolUse event for an MCP tool call carries `tool_response` shaped `[{ "type": "text", "text": "<json>" }]`, not `{ ok, task: { status } }` directly; `taskFinishResultingStatus` read only the latter, so `taskFinishResultingStatus(toolResponse)` always answered `null` against the real payload and `claimEffectForAgentTasksTool` always fell to its fail-safe `"release"` branch, exactly reproducing the pre-fix wedge the round-1 fix claimed to close. `taskFinishResultingStatus` (and the new `taskIdFromToolResponse`) now unwrap, recursively: (a) the plain receipt object itself (unchanged, also covers the `include:["task"]` full-task variant); (b) the measured content-block array, first `type: "text"` block JSON-parsed; (c) an MCP `CallToolResult` object, `{ content: [...] }` (plausible on Codex, **UNMEASURED** here); (d) a bare JSON string. Verbatim redacted live capture committed at `tests/fixtures/track-active-claim/real-posttooluse-task-finish-2.1.280.json`; driven end to end through `runPackHookTrackActiveClaimCli` for both the finish-to-review (marker kept) and finish-to-done (marker cleared, only the status changed from the same capture) outcomes, plus an `include:["task"]` full-object receipt inside the same content-block envelope. Also corrected: the fail-safe rationale in `claimEffectForAgentTasksTool`'s doc comment, this entry, and the pack doc previously said releasing-on-unreadable "costs only" an "ergonomic shortcut" -- the marker actually feeds the FAIL-CLOSED completion gate (`hook-solution-acceptance.ts`'s `readActiveClaim`), so releasing is chosen because a KEPT marker for an already-finished task would misroute that gate, at the cost of a possible wedge, not because the marker is low-stakes. An unreadable result's release now also logs a distinct diagnostic naming the shape it fell back from (`unreadable tool_response, shape=array|string|object|absent`), pinned in tests, so it no longer looks identical to an intentional `done` release in the operator's stderr trail. Separately, `task_abandon` / `task_merge` / a non-review `task_finish` now clear the marker only when it already names the SAME task id as the call acted on (input `taskId`, falling back to the result's `task.id`); a mismatch keeps the marker and logs the near-miss (`kept active-claim <B>: <verb> on <A>`) -- round 1 cleared unconditionally, so a caller holding a work-claim marker for task B lost it to an unrelated `task_merge` (or finish/abandon) on task A, including finishing a REVIEW claim on A while B's work claim was live. Class closure: every `tool_response` consumer under `src/cli/pack/` and `src/runtime/` was enumerated (`rg tool_response`); `hook-stay-in-scope.ts`'s `extractTaskId` reads `tool_response.task.id` / `tool_response.id` through the same plain-object-only accessor this hook had before this fix, so it likely has the identical content-block blind spot against a real Claude Code MCP payload -- reported here as a risk/follow-up, not fixed (out of this task's scope). `hook-post-merge-gate-record.ts`'s `tool_response` reads are for the Bash tool (its own separately-measured `{ stdout, stderr, interrupted }` shape, not an MCP content-block array) and are not the same class. New tests: a golden-fixture describe block (real payload assertion, finish-to-review, finish-to-done, `include:["task"]` full-object variant), a defensive-shapes describe block (`CallToolResult` object, bare JSON string), an unreadable-diagnostic describe block (all four shapes plus a negative control), and an id-equality describe block (task_merge / task_abandon on an unrelated id, same-id clear, review-claim-of-another-task, result-`task.id` fallback, no-marker fail-safe). Mutation probes (`agent-primitives probe --pre 'npm run build'`): the content-block/CallToolResult/string unwrap reverted to plain-object-only (killed by the golden fixture), the id-equality check removed (killed by the marker-B fixture), and the unreadable-shape diagnostic removed (killed) -- plus the three round-1 probes replayed at their moved anchor, still killed.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`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.
|
|
34
|
+
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
- **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.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Two release-hygiene CI gates: README's release sentence must match `package.json`, and the tagged CHANGELOG section must stay under a size ceiling** (task `3a910716`). `npm run check:readme-release-version` (`scripts/check-readme-release-version.mjs`) fails when README.md's "The current release is `` `vX.Y.Z` ``." sentence names a different version than `package.json`, wired into `ci.yml` next to `check:changelog-coverage`. `npm run check:release-notes-size` (`scripts/check-release-notes-size.mjs`) measures the CHANGELOG.md section for `package.json`'s version the same way `release.yml`'s awk extraction does and fails above a 115,000-character ceiling (the `0.57.0` section measured 110,880 characters when this gate was written; GitHub's release/tag body limit is roughly 125,000); `release.yml` runs the same measurement again at tag time, next to its existing empty-notes guard, so an oversized section is caught both before the tag and at release time. CONTRIBUTING.md's Releasing section now lists the release-prep steps in order (`npm version --no-git-tag-version`, CHANGELOG heading insertion, README sentence, bundle re-verification, the checks above, PR, merge, read the merge commit's CI, annotated tag). Also fixed two stale line-number citations of `CHANGELOG.md` (a `:141` in `docs/okf/log.md`, an `:18` in `src/cli/approve/understanding.ts`) into the repo's shift-proof `` `CHANGELOG.md:#x.y.z` `` heading-anchor form.
|
|
42
|
+
|
|
43
|
+
## [0.57.0] - 2026-09-21
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **`buildLockEntries` (`src/io/harness-lock.ts`) now applies the exported `isValidProjectName` guard at its own `{project}` sink (task `b5e6ccb0`), the follow-up task `e904f25a`'s CHANGELOG entry above filed.** An `opts.projectName` failing `isValidProjectName` (`".."`, a name containing a path separator, or a control character) now skips the templated memory directory's lock entry, exactly the way an absent `opts.projectName` already did, instead of the raw value being interpolated into the path this function stats, hashes and writes. Closes the gap the round-2 review of task `e904f25a` reproduced: a crafted `--project` through `harness apply` used to land its raw value in `harness.lock`, so a LATER `harness diff --since-apply` invocation that passed no `--project` at all would render it in the drift list. Of the four `{project}` interpolation sinks (`resolvePaths`, `substituteProject`, `buildLockEntries`, `generate-memory-index.ts`'s own substitution), three are now guarded; only `generate-memory-index.ts` remains open, still reserved for a separate Codex slice; until that slice lands, `--project ..` now writes no `harness.lock` entry while `generate-memory-index.ts` still indexes the raw, unvalidated `<home>/projects/../memory` path with no operator signal, a recorded interim divergence between the two sinks rather than a regression. `docs/CLI.md`'s SINK GUARD sentence and inventory and the `isValidProjectName` doc comment (now `src/io/project-name.ts`) are updated to name the new state. `isValidProjectName` and `sanitizeProjectForDisplay` moved from `src/runtime/git-context.ts` to the new `src/io/project-name.ts`, since `buildLockEntries` (`src/io/harness-lock.ts`) is itself one of the guarded sinks and `io/` may not import from `runtime/` (`io-no-upward-imports`, `.dependency-cruiser.cjs`); `src/runtime/git-context.ts` re-exports both names so existing consumers keep working unchanged. Pinned by three new `tests/io/harness-lock.test.ts` cases (`".."`, `"a/b"` and a control-bearing name, each also asserting the raw rejected value never appears in any returned entry), a new apply-level test in `tests/cli/apply/apply.test.ts` pinning the `apply.ts` wiring end to end, and an import assertion pinning the `git-context.ts` re-export; a mutation probe removing the new guard (`agent-primitives probe --pre 'npm run build'`) killed.
|
|
48
|
+
|
|
49
|
+
- **Round-2 review fixes** (task `b5e6ccb0`; one HIGH maintainability, three MEDIUM docs/correctness, one LOW tests). The round-1 `src/io/project-name.ts` regex literal (`PROJECT_NAME_CONTROL_CHARS`) was typed with RAW control bytes (a NUL among them) instead of the escaped backslash-u-0000 to backslash-u-001f / backslash-u-007f to backslash-u-009f form the base had, which made git treat the file as binary and every plain-text search for `isValidProjectName` in it come up empty; it is now escaped exactly as the base was. A new `scripts/check-no-nul-bytes.mjs` (`npm run check:no-nul-bytes`, wired into `.github/workflows/ci.yml`) scans every file under `src/` and `tests/` for a raw NUL byte and fails the build on one, with a narrow, documented allowlist for the one pre-existing, intentional NUL delimiter in `src/cli/apply/generate-settings.ts`'s command fingerprint (PR #438; out of this task's scope to change). `CHANGELOG.md`, `docs/CLI.md` (two sites) and `docs/okf/log.md` now name `src/io/project-name.ts`, not `src/runtime/git-context.ts`, as the definition site for `isValidProjectName`/`sanitizeProjectForDisplay`. `substituteProject`'s doc comment (`src/probes/memory.ts`) now names only `generate-memory-index.ts` as unguarded, not `buildLockEntries`. An unreachable per-entry loop after `expect(entries).toEqual([])` in `tests/io/harness-lock.test.ts` is now a single `not.toContain` assertion.
|
|
50
|
+
- **Round-3 review fixes** (task `b5e6ccb0`; the round-2 raw-control-byte class recurred, so this round fixes the CLASS, not the instance). `tests/io/harness-lock.test.ts`'s `"a control character"` case carried a literal BEL byte (the same defect class round 2's gate was built to catch, just outside that gate's NUL-only scope); it now reads the escaped `"a\u001b[31mb"`, matching the sibling `tests/runtime/git-context.test.ts` idiom. The gate itself is renamed `scripts/check-no-raw-control-bytes.mjs` (`npm run check:no-raw-control-bytes`) and widened from NUL-only to the full raw C0/C1/DEL control-byte class (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F, and the C1 range U+0080-U+009F as its two-byte UTF-8 encoding; tab/LF/CR stay allowed) and from `src/`+`tests/` to also cover `scripts/`, `docs/`, `.github/` and the repo root's own `*.md`/`*.yml`/`*.yaml`/`*.json` files (a small binary-extension denylist skips any binary asset under a scanned directory), closing the gap that let the round-2 fix itself land a raw NUL in a `CHANGELOG.md`/`ci.yml` edit undetected. The allowlist now pins the exact occurrence COUNT expected per file (`{ path, count, reason }`), not only file membership: two extra NULs silently appended to the allowlisted `src/cli/apply/generate-settings.ts` used to pass unnoticed (the file was allowlisted at all, regardless of how many raw bytes it held); they now fail as a count mismatch, caught by a new `tests/scripts/check-no-raw-control-bytes.test.ts` (allowlisted file at its pinned count passes, one extra byte fails, a raw BEL in a scratch file fails, tab/LF/CR pass, a C1 byte fails). `docs/okf/log.md`'s newest entry no longer claims no citation moved; it now names the re-pointed citations, enumerated from the commit range instead of counted by hand (`git diff <base>..HEAD -U0 -- docs`, then a per-file multiset diff of every `path:N[-M]` token): `src/runtime/git-context.ts:392` to `:393` (`docs/okf/log.md`, both occurrences, for the new `io/project-name.ts` import); `src/probes/memory.ts:277` to `:276` (`docs/okf/log.md` and `docs/decisions/2026-09-08-preflight-floors.md`, for the narrowed `substituteProject` comment); and the `sha256: string;` range in `src/io/harness-lock.ts`, from lines 48-57 to `src/io/harness-lock.ts:49-58` (`docs/decisions/2026-08-27-ug-auto-mode-approval.md`, shifted by the `isValidProjectName` import added at `src/io/harness-lock.ts:34`), the one this sub-entry originally omitted while stating its list was complete. Follow-up filed as harness task `0b747433-c697-48bc-adc4-f3a24cc4fa37`, not yet done: replace `generate-settings.ts`'s NUL template-literal delimiter with an escape-safe form, which would drop the allowlist entry entirely; changing the delimiter is out of this task's scope.
|
|
51
|
+
- **Round-4 review fixes** (task `b5e6ccb0`; two MEDIUM, three LOW, all in the round-3 gate and its surrounding docs, no `src/` change). The gate `scripts/check-no-raw-control-bytes.mjs` resolved its scan root from `process.cwd()` and wrapped `readdirSync` in a catch that returned an empty list, so a renamed or missing scan directory, or a run from any other directory, printed `OK, scanned 0 file(s)` and exited 0: a green gate that had checked nothing. It now resolves the repo root from its own module URL (`fileURLToPath(import.meta.url)`, one level up from `scripts/`), verifies every `SCAN_DIRS` entry exists and is a directory before scanning, and exits 2, a distinct IO/scope code kept separate from 1 for a real violation, naming the offending path for a missing or non-directory scan entry, an unreadable directory, or an unreadable file; nothing is caught silently. `collectScanFiles` now skips `node_modules`/`dist`/`coverage`/`.git` by directory NAME at any depth, which is what makes the header's "never descends into" sentence true: it held only for the non-recursive root sweep, while a nested `tests/<fixture>/node_modules/` inside a scanned tree was still walked and flagged. The root sweep now also covers `*.ts`/`*.cjs`/`*.mjs` (`vitest.config.ts` and `.dependency-cruiser.cjs` today), so a control byte appended to a root config file no longer passes. What sits deliberately outside the gate is documented in its header with the reason: `dogfood/` (captured terminal transcripts carry real control bytes as data, not as a defect), `.ai/` (orchestrator run state: `.ai/runs/` and the `.ai/run` pointer are gitignored, and what is tracked under `.ai/` is kit-installed workflow state rather than hand-edited source), the generated and vendored directories above, and symlinks, which are followed in neither direction because `Dirent.isFile()` is false for them. `evaluateFile`'s JSDoc no longer documents an "allowed" status the function cannot return. The allowlist comment and the round-3 sub-entry above now name the `generate-settings.ts` delimiter follow-up by its tracker id (harness task `0b747433-c697-48bc-adc4-f3a24cc4fa37`) instead of pointing at a clause that no longer existed anywhere. Pinned by new `tests/scripts/check-no-raw-control-bytes.test.ts` cases: a missing scan directory and a scan entry that is a FILE each exit 2 naming it; an unreadable directory inside a scanned tree exits 2 instead of scanning short; a nested `node_modules`/`dist`/`coverage` is skipped; a BEL in a root `.ts` fails; the allowlisted file with its pinned byte REMOVED fails as `expected 1 ... found 0`, the direction a `>` comparison would have passed; and two real runs of the script, one from the repo root and one from an unrelated cwd, agree on the scanned file count. Mutation probes for each of those three behaviours (the silent catch restored, `!==` weakened to `>`, root `*.ts` dropped from the sweep) killed.
|
|
52
|
+
|
|
53
|
+
- **`src/cli/apply/generate-settings.ts`'s hook-dedupe fingerprint delimiter is no longer a raw NUL byte** (task `0b747433`, the follow-up filed by task `b5e6ccb0`'s round 3). `buildGroups`'s `(command, timeout)` dedupe key now joins with the `\u0000` escape instead of a literal `0x00` byte, byte-identical at runtime (the same character, just written so git and `rg`/`grep` see the file as text instead of binary); the fingerprint construction is factored out into a new exported `computeHookFingerprint(command, timeout)`, same formula, now independently testable. The extraction moves `hookTimeoutSeconds` down the file, so `docs/okf/codex-adapter-parity-gaps.md`'s citation is re-pointed, now in the anchored form the citation guard checks, and the doc re-stamped. `src/cli/apply/generate-settings.ts`'s entry, the allowlist's only one, is removed from `ALLOWED_CONTROL_BYTE_FILES` in `scripts/check-no-raw-control-bytes.mjs` along with the comment naming this task as pending; the allowlist is now empty. `tests/scripts/check-no-raw-control-bytes.test.ts`'s count-mismatch and stale-entry coverage, previously leaning on that live entry, now injects and removes its own temporary allowlist entry per test instead. A new `tests/cli/apply/generate-settings.test.ts` case pins the delimiter's collision safety directly against `computeHookFingerprint`: a command containing a literal space plus an empty timeout does not collide with a command/timeout split at that space. That pair is reachable only with a timeout cast past its `number` type (a numeric timeout never renders a trailing space), so the test pins collision-proofness for arbitrary string inputs, not a defect that well-typed manifests could hit; a second case pins the NUL-joined wire format itself.
|
|
54
|
+
- **`substituteProject` (`src/probes/memory.ts`) now applies the exported `isValidProjectName` guard, one of the sinks task `1c4eb3ea` left unguarded (agent-tasks `e904f25a`).** An invalid `--project` value (`".."`, a name containing a path separator, etc.) degrades to the "no project supplied" branch instead of being interpolated into the `{project}` placeholder: the literal survives substitution and is reported as an `unresolved: true` pattern (informational, not "missing"), never a concrete path resolved outside the directory the placeholder is meant to occupy. Two of the four sinks remain unguarded after this change, `generate-memory-index.ts`'s own `{project}` substitution and `buildLockEntries` (`src/io/harness-lock.ts`): the former is reserved for a Codex slice per the pandora handoffs (so the split, this sink guarded now, that one left for Codex, is recorded rather than worked around), the latter is a follow-up to file. Both are reached only through an explicit `--project` flag, never through a cwd-derived name. `docs/CLI.md`'s SINK GUARD sentence and the `isValidProjectName` doc comment (`src/runtime/git-context.ts`) are updated to name the new state. Pinned by a new test in `tests/probes/memory.test.ts` ("rejects an invalid opts.project" with a `".."` value); a mutation probe removing the new guard (`agent-primitives probe --pre 'npm run build'`) killed.
|
|
55
|
+
|
|
56
|
+
- **Review round 1 fixes** (task `e904f25a`, round 2; one MEDIUM correctness, two LOWs docs, two LOWs UX/placement). Round 1's own review found: `substituteProject` still used `String.prototype.replace` with a regex, so a project value like `$'` or `` $` `` is interpreted as a `replace`-special reference instead of literal text, and substitution ran BEFORE `expandHome`, so a project value of `~` could re-anchor a relative pattern to the home directory; the CHANGELOG's own round-1 entry above (and `docs/CLI.md`, and the `git-context.ts` doc comment) named only a two-sink split when a third `{project}` sink (`buildLockEntries`, `src/io/harness-lock.ts:289-291`, fed from `opts.project` in `src/cli/apply/apply.ts`) exists unguarded and un-reserved; `docs/okf/log.md` got no entry; a rejected `--project` value produced no diagnostic at all, `doctor` printed the same "resolved per-project at runtime" note an ordinary unresolved pattern gets; and the source comments in `memory.ts` and `git-context.ts` mixed the technical fact ("left unguarded") with the org-specific reservation rationale ("per the pandora handoffs"), which belongs only here. This entry closes all six.
|
|
57
|
+
- **`substituteProject` now substitutes AFTER `expandHome`, and with `String.prototype.split`/`join` instead of `replace`+regex** (`src/probes/memory.ts`), matching the literal idiom `buildLockEntries` (`src/io/harness-lock.ts`) and `generate-memory-index.ts`'s `resolveDirPath` already use for the SAME placeholder. A project value of `$'` or `` $` `` is now inserted as literal text (previously interpreted by `replace`'s special replacement-pattern syntax, silently rewriting the path); a project value of `~` can no longer re-anchor a `{project}`-leading relative pattern to the home directory, since the manifest's own leading `~` is expanded before the placeholder is ever substituted. Pinned by three new `tests/probes/memory.test.ts` cases (`$'`/`` $` `` resolving literally, `~` not re-anchoring a relative pattern, and `a/b` rejected the same way `..` already was); two new mutation probes reverting each fix (`agent-primitives probe --pre 'npm run build'`) killed.
|
|
58
|
+
- **The sink inventory corrected from two to three.** `buildLockEntries` (`src/io/harness-lock.ts:289-291`, fed from `opts.project` at `src/cli/apply/apply.ts:970,982,1035`) also interpolates `{project}` unguarded, and is NOT Codex-reserved the way `generate-memory-index.ts` is; guarding it is left as a follow-up for the orchestrator to file, out of this task's scope. `docs/CLI.md`'s SINK GUARD sentence, the `isValidProjectName` doc comment (`src/runtime/git-context.ts`), and this file's own round-1 entry above (marked superseded) now name all three.
|
|
59
|
+
- **`docs/okf/log.md` gains a newest-first entry** recording `substituteProject` as guarded and `generate-memory-index.ts`/`buildLockEntries` as the two sinks still open.
|
|
60
|
+
- **A rejected `--project` now produces its own diagnostic instead of the ordinary "unresolved pattern" note.** `MemoryReport` gains `projectRejected: string | null` (`src/probes/memory.ts`), set when `opts.project` was supplied but failed `isValidProjectName`; `harness doctor` (`src/cli/doctor/format.ts`) renders a warn line (`--project <name> rejected as an unsafe path segment; treating the directory as an unresolved pattern`) instead of the informational note, counted in `warningCount`; `harness list memories` (`src/cli/list.ts`) carries the same value as a `project_rejected` row field. No new exit code. Pinned by new `format()`/`list()` tests.
|
|
61
|
+
- **The `memory.ts`/`git-context.ts` source comments now state the technical fact only** ("left unguarded ...; see CHANGELOG.md for the reservation/follow-up on each"); the Codex-reservation rationale for `generate-memory-index.ts` and the harness-lock.ts follow-up both stay recorded here, not duplicated into source.
|
|
62
|
+
- **Round-2 review fixes** (task `e904f25a`; one MEDIUM correctness, three LOWs correctness/tests, one LOW maintainability, one LOW architecture/docs). `docs/CLI.md`'s SINK GUARD passage, previously one accreting ~9000-character paragraph, is now a short inventory (one lead sentence, a four-item sink list, one sentence on the rejected-case behaviour); this CHANGELOG's own superseded clause above now states plainly that three `{project}` sinks were unguarded as of task `1c4eb3ea` and two remain after this task. `harness doctor`'s rejection warn line (and its `warningCount` increment) is now gated on at least one memory directory actually carrying the `{project}` placeholder (`projectRejectionWarns`, exported from `src/probes/memory.ts`, shared by `format.ts` and `doctor/index.ts` instead of two hand-written copies); a rejected `--project` on a manifest with no `{project}`-templated directory now stays silent, and several such directories each keep their own per-directory note rather than being collapsed into the single warn line. `MemoryReport.projectRejected` now agrees with `substituteProject`'s own falsy check: an empty-string `--project` is "not supplied" in both places, not "supplied and rejected" in one and "not supplied" in the other. The `warningCount` increment for a rejected `--project` is now pinned by an exact-delta test in `tests/cli/doctor.test.ts` (a mutant deleting the increment previously survived). The `memory.ts`/`list.ts` source comments no longer name this review's own internal finding label, only the task id. `harness list <category>`'s conditionally-present `project_rejected` row field and the fact that only `doctor`, never `list`, ever warns are now both documented next to `docs/CLI.md`'s `harness list memories` notes and in `list.ts`'s own comment. Pinned by new `tests/probes/memory.test.ts` and `tests/cli/doctor.test.ts` cases; probes removing the placeholder gate and the empty-string agreement (`agent-primitives probe --pre 'npm run build'`) killed.
|
|
63
|
+
- **A `--project` value can no longer carry a control character into `harness doctor` or `harness list memories` output, and the REJECTED value's echo is stripped.** `isValidProjectName` (`src/runtime/git-context.ts`) screened only for the shapes that escape a path segment (empty, `.`, `..`, `/`, `\`, NUL), so a name carrying a newline or an ANSI escape was ACCEPTED. `harness doctor` rendered it verbatim on its header line and `harness list memories` in its `project_rejected` row field; and because an accepted name is SUBSTITUTED into the memory directory path, doctor's own `memory directory missing` line and `list memories`' `path` row field rendered it verbatim too. A crafted value therefore forged an extra, diagnostic-looking line and wrote raw terminal escapes into the operator's terminal. Sanitizing at each rendering site did not close this: the substituted-path surfaces do not render the NAME, so enumerating rendering sites is open-ended by construction. The screen moved to the SOURCE instead. `isValidProjectName` now also rejects every C0 control character (`U+0000` to `U+001F`, NUL among them, which is why the separate NUL clause is gone), DEL (`U+007F`) and every C1 control character (`U+0080` to `U+009F`), so an accepted name, and any path it was substituted into, is a plain single-line string on every surface without that surface having to know. `sanitizeProjectForDisplay` (same module) stays for the one class the source screen cannot cover: a rejected value is by definition unvalidated and is still echoed back so the operator can see WHICH value was refused, on doctor's rejection warning, its per-directory note, its header `project:` clause and `list memories`' `project_rejected` field. The `session_start_preflight.setup` finding's `(project: X)` suffix keeps the same wrap as defense in depth rather than as a live echo, since `doctor()` sets that finding's `projectName` only when `resolvePaths` resolved a project layer FILE, which the validator already gates. Report DATA (`DoctorReport.project`, `MemoryReport.projectRejected`) keeps the raw value, carried over `--json` by JSON's own escaping rather than by a raw control byte. **Consequences.** `deriveProjectName` validates its own return value at every exit, so a checkout directory whose basename carries a control character now takes the same path a `..` basename already took: `null`, no project override layer resolves for that repository, the base/machine value applies, and the operator sees no error and no warning (pinned by a `deriveProjectName` case in `tests/runtime/git-context.test.ts`, beside the existing path-separator one: a real on-disk checkout directory whose basename carries a newline derives `null`) (doctor's setup-version finding then carries `projectName: null` and renders no `(project: X)` suffix). `resolvePaths` (`src/cli/loader.ts`) and `substituteProject` (`src/probes/memory.ts`) both refuse such a name at their own sinks. The two `{project}` sinks that remain unguarded, `generate-memory-index.ts`'s own substitution and `buildLockEntries` (`src/io/harness-lock.ts`), call the validator NOWHERE and therefore inherit nothing from this: both still interpolate an explicit `--project` value unvalidated, for path escapes and for control characters alike. Pinned by `tests/runtime/git-context.test.ts` (a newline-, ESC-, DEL- and NEL-bearing name rejected; `myproj`, `with space` and `ümläut` accepted; the path-escape shapes still rejected; plus a non-statefulness case, since the validator's `test` regex and the stripper's `g`-flagged one are deliberately two objects built from one source), `tests/cli/doctor.test.ts` and `tests/cli/list.test.ts` (a control-bearing name with NO path separator, end to end: rejected, echoed sanitized on exactly the lines that echo it, no forged line or row anywhere in the rendered report or the table, the substituted path still carrying the literal `{project}`, and `--json` parseable with no raw control byte on any line), and `tests/cli/doctor-session-start-preflight-setup-version.test.ts` (the `(project: X)` suffix rendered sanitized). A second `doctor` case pins a mixed manifest (one `{project}`-templated directory plus one concrete missing directory): both warnings appear and only the rejection is the extra one in `warningCount`. `format.ts`'s dead `projectRejected === null` re-test is a plain `else` carrying the reason. Two comments that referred to the display helper by a name it never shipped under now name `sanitizeProjectForDisplay` (`src/cli/list.ts`, `src/cli/doctor/format.ts`). `docs/CLI.md`'s SINK GUARD sentence and its rejected-`--project` paragraph state the two-rule invariant instead of a list of rendering sites.
|
|
64
|
+
- **`docs/CLI.md`'s sink inventory says why the two open sinks are accepted, and renders as one list again.** The lead sentence now records the residual-acceptance reason (both open sinks are reached only through an explicit `--project` flag, never through a cwd-derived name, which `deriveProjectName` validates itself), the reservation rationale is a pointer to this file instead of an org-specific aside, and both continuation paragraphs under PER-REPO SCOPING are indented into that list item with the blank line before the next top-level bullet restored (checked by rendering the section with `markdown-it`: one list whose SECURITY bullet is a sibling item, instead of a list split in two by flush-left paragraphs).
|
|
65
|
+
|
|
66
|
+
- **`vitest`/`@vitest/mocker` bumped from 4.1.8 to 4.1.11, closing `GHSA-82fw-gwwq-j7x9` (CVE sweep 2026-09-11).** Within-major lockfile bump only, via the single `devDependencies.vitest` range floor; `@vitest/mocker` stays a nested transitive of `vitest` in the lockfile, resolved to the same 4.1.11 without a direct manifest entry. `npm audit --audit-level=moderate` no longer reports the advisory; `npm ci --dry-run` passes on a clean extract under the CI-pinned npm 10.9.8.
|
|
67
|
+
|
|
68
|
+
- **`tests/scripts/memory-frontmatter-contract.test.ts`'s "uses the script location and keeps sync transactional through recovery faults" test now carries an explicit 20000ms per-test timeout** (task `1353a96e`). The test drives roughly 90+ synchronous `execFileSync("git", ...)`/`spawnSync(node, ...)` calls across its pinnedUpdatedFixture() passes, CLI invocations and fault-injection runs; under concurrent worker or machine load that exceeds vitest's default 5000ms `testTimeout`. First observed from a detached git worktree under `os.tmpdir()` (the environment `agent-primitives probe`'s default isolation copy uses; implementer: 2.8-4.1s in the checkout, up to ~6.2s there, 1/10 repeats timing out), but the review reproduced it from the ordinary checkout as well (2/13 full-suite runs at base timed out; head file from the checkout 5989ms and 5917ms in 10 runs under load; 25/25 green with the raised budget, worst 6372ms), so the variable is load, not the checkout location. This is the cold-subprocess-spawn-latency-vs-fixed-budget race already documented in `vitest.config.ts`, not the `os.tmpdir()`-exemption-swallow mechanism task `9a4a417b` (PR #527) fixed in `tests/runtime/hermetic-spawn-allowlist.test.ts`: this test derives no expected path from the script's own location or from `os.tmpdir()`, so there was no location assumption to correct. Fixed the same way `tests/cli/smoke/smoke.test.ts`'s "escalates to SIGKILL" test already is: a generous explicit timeout, no assertion weakened or removed. The body is synchronous, so the value is a reporting ceiling, not an abort guard.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **The PR #522 migration note's SYMLINK and CASING rules for `session_start_preflight`'s per-repo project layer are now pinned by automated tests, not only by the hand-run CLI matrix behind that note's CHANGELOG entry and `docs/CLI.md`'s PER-REPO SCOPING notes** (task `6c8c1bae`). A loader-level fixture in `tests/cli/loader-project-layer.test.ts` drives `explainPolicy` (its own exported `session_start_preflight.source` field, the field the hand-run matrix read) with a scratch checkout shaped like a git work tree (a `.git` directory carrying only `HEAD`) reached through a symlink: a project layer directory keyed on the symlink's own name never resolves (`source: "base"`), the one keyed on the real directory's basename resolves (`source: "project"`), from the identical symlink cwd both times, matching the SYMLINK rule. A producer-level fixture at the end of `tests/cli/session-start/preflight.test.ts` drives `runSessionStartPreflight` itself with an `event.cwd` that is a symlink path, asserting the `setup` value the producer feeds to its `preflight` child comes from the project layer named after the real, on-disk basename.
|
|
73
|
+
- **Round 2 (task `6c8c1bae`, review round 1 findings).** The CASING half was corrected and re-scoped after measurement on a real case-sensitive APFS volume (`hdiutil`, reviewer round 1): the producer's original single-layer CASING fixture ran unguarded, and on a case-sensitive filesystem it FAILED, not merely "stayed untested" as the round-1 entry claimed: `findGitEntry` (`src/runtime/git-context.ts`) never finds `.git` under a miscased access path there, so the producer short-circuits at its "cwd is not inside a git work tree" early return (`src/cli/session-start/index.ts`) with `result.wrote: false`, not the `setup: false` the round-1 comment predicted. Both the producer CASING test and a new loader-level CASING test now self-skip on a case-sensitive filesystem, mirroring `tests/runtime/git-context.test.ts`'s own case-differing test's `ctx.skip` guard. The round-1 producer CASING fixture also did not discriminate a producer that stops deriving a project name at all (`opts.project ?? repo`): in that single-repo shape `repo` and the correctly-derived name were the identical string, both folding onto the one on-disk layer on a case-insensitive `HARNESS_HOME`. The producer CASING test now reaches its differently-cased `event.cwd` through a LINKED WORKTREE of the main checkout, so `repo` (the worktree's own checkout basename) and the derived name (the main checkout's basename, reached via the worktree's `commondir` file) are genuinely different values; a decoy project layer keyed on `repo`'s value carries the opposite `setup`, so a producer that stopped deriving now fails the assertion instead of passing it by coincidence. A separate new loader-level fixture in `tests/cli/loader-project-layer.test.ts` pins the LAYER-DIRECTORY half of docs/CLI.md's PER-REPO SCOPING MIGRATION note's CASING rule directly (a project layer directory spelled in a different case than the derived project name still resolves on a case-insensitive `HARNESS_HOME`), which the producer fixture, once rewritten around the worktree shape, no longer exercises on its own. `deriveProjectName`'s `fs.realpathSync` step does NOT canonicalize a plain (non-symlink) differently-cased path segment (the same finding `tests/runtime/git-context.test.ts`'s own case-differing test already pins); what resolves the loader-level layer directory is a second, independent case-insensitivity in the layer FILE lookup itself (`fs.existsSync`/`fs.readFileSync`, `src/cli/loader.ts`).
|
|
74
|
+
- **Round 3 (task `6c8c1bae`, review round 2 findings).** The producer CASING test's differently-cased `event.cwd` was decorative: with the linked-worktree fixture the derived name comes from the `commondir` file's own correctly-cased content, never from case-folding the access path, so the assertions held identically once the miscasing was replaced with the real, on-disk spelling. That test is renamed to what it pins (linked-worktree derivation plus raw-`event.cwd` passthrough, still using the decoy layer that discriminates the P2 mutant) and no longer varies casing or self-skips; the casing claim now lives in the loader-level CASING test alone. That loader-level CASING test no longer self-skips on a case-sensitive `HARNESS_HOME` either: it measures the fixture home's own case sensitivity with the shared probe and asserts both halves of the MIGRATION note's CASING sentence (`source: "project"` on a case-insensitive home, `source: "base"` on a case-sensitive one), so CI (ext4, case-sensitive) now runs and pins the case-sensitive half instead of skipping it. The inode-comparison probe, previously copied three times (`tests/runtime/git-context.test.ts` inline, the producer test, the loader test), is now exported once as `isCaseInsensitiveFilesystem` from `tests/_helpers/case-sensitivity.ts` and used by the loader test (`tests/runtime/git-context.test.ts` keeps its own inline copy). The SYMLINK and CASING describe blocks in `tests/cli/loader-project-layer.test.ts` also share their fixture builders (manifest, project layer, event, seams) now, parameterised by policy name/description and layer setup value, removing roughly 90 lines of duplication.
|
|
75
|
+
|
|
76
|
+
- **`tests/decisions-citations-resolve.test.ts` now blocks on a BARE `path:N[-M]` line citation into a source file in `docs/okf`, requiring an anchor** (task `898f9925`). Previously only ANCHORED docs/okf line citations were resolved against the current tree; a bare citation had no check at all, so a cited line could drift with nothing to catch it (batch-44: `quote-model-divergence.md` cited `src/cli/init/templates.ts:928` while the sentence had moved to line 940, caught only by a reviewer's manual sweep). The line-resolution checks used by the existing `docs/decisions citations resolve` describe block are now factored into a shared `checkLineCitation` helper (mirroring `checkHeadingCitation`'s existing pattern), reused by a new docs/okf-shaped fixture pair pinning that a shifted-line citation fails and the corrected form passes, and by a new ratchet asserting zero bare line citations into non-Markdown sources across `docs/okf` outside `log.md` (whose historical bare count is reported only in a computed test title, never asserted). All 48 live bare citations into non-Markdown sources outside `log.md` were converted to the anchored form in this same change (citation-only; one genuine drift re-pointed: `codex-adapter-parity-gaps.md`'s bare-basename `generate-codex-config.ts:151` corrected to `src/cli/apply/generate-codex-config.ts:151#"hookTimeoutSeconds"`), measured 2026-09-09 across 8 docs: `manifest-validation-scope.md` 13, `pause-vs-gate-kill-switch.md` 18, `policy-engine-producer-wiring.md` 5, `quote-model-divergence.md` 4, `understanding-gate-auto-mode-signals.md` 3, `understanding-gate-lockout-recovery.md` 3, `codex-adapter-parity-gaps.md` 1, `gate-fail-posture-matrix.md` 1; `docs/okf/index.md`'s Maintenance section names the rule, these counts are recorded here rather than there so the doc's own coverage paragraph does not go stale as citations shift. **Round 2 (reviewer re-derivation of all 48 conversions) found: eight of the anchors were punctuation-only** (a bare closing delimiter such as `#"}"`, `#");"`, `#"};"` on a bare-delimiter end line), passing checks (d)-(f) by accident while pinning nothing against a line shift; each re-anchored on a token its doc's own sentence names, narrowing the range end past the delimiter for six of them (`manifest-validation-scope.md:21`, `pause-vs-gate-kill-switch.md:50,87,92,95`, `policy-engine-producer-wiring.md:25`); two, `pause-vs-gate-kill-switch.md:48` (`src/cli/pause/index.ts:269-274#"is the friction"`) and `:53` (`226-232#"non-TTY stdin"`), narrowed past real message lines too, corrected in round 3 (task `898f9925` round 3, decision D-031) by widening both back to their last real content line before the closing `EX_USAGE` argument, each unique in its own range: `269-279#"EX_USAGE"` and `226-241#"EX_USAGE"`. A ninth range, `pause-vs-gate-kill-switch.md:46`'s `src/cli/index.ts:3330-3419`, had been narrowed from the original `3330-3429` by dropping the resume verb's ledger-audit branch (real code, not a trailing delimiter); its round-2 replacement, a single `3330-3421` anchor disambiguated only by ten leading spaces of indentation, is unique in the range through whitespace alone (the trimmed text occurs twice), so round 3 (task `898f9925` round 3, decision D-031) split it into two citations instead, one per verb, each ending on its own `stdout(` call: `3330-3391#"stdout("` (`pause`) and `3395-3427#"stdout("` (`resume`). Also added in round 2: the ratchet's collection loop is now `collectBareNonMd(dir, labelPrefix)`, fixtured directly over a scratch docs/okf-shaped temp directory (a planted non-log bare citation is collected, a planted log.md one is not), closing a gap where widening the log.md exemption to every doc previously survived the whole suite; and a new anchor-quality guard asserts every anchored docs/okf citation's anchor contains at least one word character, so a punctuation-only anchor fails outright regardless of what class of citation introduces one next. **Round 3 (task `898f9925` round 3, decision D-031)** also extends the anchor-quality guard's `hasWordCharacterAnchor` predicate over `docs/decisions` (0 offenders there, measured 2026-09-09), adds the guard to the test file's header comment, and corrects `docs/okf/index.md`'s Maintenance section, which had described the bare-citation ratchet's test title as reporting "a computed count" in general when only `log.md`'s title interpolates a number (the outside-`log.md` count is asserted at zero, not reported).
|
|
77
|
+
|
|
78
|
+
- **`tests/decisions-citations-resolve.test.ts` now also blocks on a whitespace-padded docs/okf anchor** (task `98025e15`). Task `898f9925`'s round 2 anchor-quality guard (`hasWordCharacterAnchor`) accepted `pause-vs-gate-kill-switch.md:37`'s `docs/for-humans.md:395-397#" diff-able, source-controlled."` anchor, whose uniqueness rested on two leading spaces rather than on a word the doc's own sentence names: a reformat of `for-humans.md` could silently trim that indentation and turn a routine reflow into a confusing guard failure instead of a clear re-anchoring prompt. Re-anchored on `#"source-controlled"`, unique on the cited end line without depending on whitespace; the historical narration at `docs/okf/log.md:1602` is left as-is. A new `hasWhitespacePaddedAnchor` predicate (mirroring `hasWordCharacterAnchor`'s pattern) and describe block, at the end of the test file, assert no anchored docs/okf citation's anchor starts or ends with whitespace, fixtured directly (a padded-anchor fixture fails, an unpadded one passes) and over a planted scratch docs/okf directory, so a neutralised predicate is caught independent of the live bundle's anchors. `docs/okf/index.md`'s Maintenance section names the rule as point (4).
|
|
79
|
+
|
|
80
|
+
- **`tests/decisions-citations-resolve.test.ts` now blocks on `docs/okf`'s heading-section (`path.md:#heading`) citations, not just its `path:N[-M]` ones** (task `ee494719`). Tasks `419ecfad` and `4f0abbc8` replaced `CHANGELOG.md` frontmatter `sources:` entries on five bundle docs with inline `` `CHANGELOG.md:#0.x.y` `` heading-section citations pointing at the `[0.24.0]`, `[0.35.0]`, `[0.39.0]`, `[0.43.0]`, `[0.44.0]`, `[0.45.0]`, and `[0.48.0]` release sections, resolved only by okf-kit's warn-only `citations-resolve` rule (the `okf-staleness` CI job), so a renamed or removed `## [x.y.z]` release section only warned, never blocked. A new guard, mirroring okf-kit@0.10.0's heading-form semantics (`src/rules/citations-resolve.ts`'s `HEADING_SECTION_CITATION_RE` / `findHeadingSection` / `checkHeadingSectionTarget`, the version this repo's `okf-staleness` CI job pins), extracts every `path.md:#heading` citation in `docs/okf`, resolves it against the target's own Markdown heading structure (level <= 2, fence-aware), and, for a `CHANGELOG.md` target specifically, additionally requires the matching heading to take this repo's exact `## [x.y.z]` bracket form. It also extracts and checks the optional content-anchor form, `path.md:#heading#"text"`, requiring the quoted text to occur on exactly one line inside the resolved heading's section. `docs/okf/index.md`'s Maintenance section names this coverage and its one deliberate scope cut from okf-kit: path resolution here is repo-root-relative only, not okf-kit's fuller sources/doc-relative/ancestor/repo-wide resolution chain.
|
|
81
|
+
- Pinned by fixture tests (isolated temp `CHANGELOG.md`, not the real one): a citation to a version with no heading at all (only present in prose) fails; a citation to a heading naming the version but not in the exact `## [x.y.z]` form fails; a citation whose only matching heading sits inside a fenced code block fails (discriminates a mutant that stops excluding fenced lines from heading matching); a citation to a version present only as a level-3+ heading fails (discriminates a mutant that raises the level-2 cap); a citation matching two level-2 headings fails as ambiguous (discriminates a mutant that drops the ambiguity check); a content-anchored citation resolves when its quoted text occurs once in the section and fails when it does not (discriminates a mutant that skips the content-anchor check); and a `citedPath` containing a `..` segment is rejected outright without resolution. A sibling negative-grammar fixture pins `HEADING_CITATION_RE` itself against loosening (an unbackticked heading-shaped path in prose, a Markdown link fragment, and a non-`.md` path must all extract zero citations).
|
|
82
|
+
- **Follow-up (task `9fec3839`): the guard now also mirrors okf-kit's `heading-section-empty` check.** A resolved heading whose section body (from the line right after the heading up to, but not including, the next heading at or above the same level, or EOF) has no non-blank content previously resolved cleanly here even though okf-kit's warn-only `citations-resolve` rule would flag it; the guard's `checkHeadingCitation` now runs the same emptiness check (`isHeadingSectionEmpty`, mirroring okf-kit's `isSectionEmpty`) before the content-anchor check, in the same order upstream does. Pinned by a fixture pair (an empty `## [1.2.9]` section that fails with "no non-blank content", and a non-empty `## [1.2.10]` control that resolves cleanly), both directions probed via `agent-primitives probe`.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **`harness doctor`'s second, project-scoped load and the `session_start_preflight` producer now share one `resolveScopedProjectName` helper instead of two independently written copies of `opts.project ?? deriveProjectName(cwd) ?? fallback`** (task `f1eb1c5c`). Nothing pinned that the two copies stayed identical: `doctor` (`src/cli/doctor/index.ts`) used `?? null`, the producer (`src/cli/session-start/index.ts`) used `?? repo`. Reproducing the three real-git shapes (a normal repo, a linked worktree, a cwd outside any git work tree) confirmed the two surfaces already agreed on the first two terms (an explicit `--project`, then the cwd-derived name) in every case both surfaces actually reach; the fallback is the only place they differ, and outside a git work tree the producer never even evaluates the expression (it returns earlier, once `resolveGitContext(cwd).repo === ""`). `resolveScopedProjectName<T>({ project, cwd, fallback: T })` (`src/runtime/git-context.ts`, exported from `src/runtime/index.js`) now names that fallback as an explicit, visible argument at each call site rather than an accident of copy-paste drift; no observable behaviour change at either call site other than doctor's `cwd` expression (`opts.cwd ?? process.cwd()`) now being evaluated eagerly as an argument, instead of lazily inside the old inline `??` chain. `harness explain-policy` (`src/cli/explain-policy.ts`) has its own third copy of the same expression and is intentionally left alone here (out of this task's scope).
|
|
87
|
+
- Pinned by new tests at the end of `tests/runtime/git-context.test.ts` ("resolveScopedProjectName"): both surfaces' fallback resolve to the same derived name in a normal repo and in a linked worktree, an explicit `project` wins outright without deriving anything, and each surface's own `fallback` value comes through unmodified (and differs by design) outside a git work tree.
|
|
88
|
+
- **Follow-up (task `2dadec9d`, batch-46 follow-up): the round-2 divergence pin's `vi.mock("node:fs", ...)` is now scoped to its own file.** That pin (added in `tests/cli/doctor-session-start-preflight-setup-version.test.ts` to spy on `fs.existsSync`'s call history) needed a file-wide mock, since a plain `vi.spyOn(fs, "existsSync")` fails under ESM; `vi.mock` is hoisted and file-scoped in vitest, so it cannot be cleared per-test with `afterEach`, and it was leaking into every OTHER case in that shared file. The pin, and a new negative control asserting doctor DOES probe `fs.existsSync` under `<home>/projects/` for a genuinely valid cwd-derived project name, now live in a new sibling file, `tests/cli/doctor-session-start-preflight-setup-version-divergence.test.ts`; the original file no longer mocks `node:fs` at all. Both the relocated pin's fixture and the new negative control's fixture now assert their own preconditions explicitly (`findGitEntry`/`deriveProjectName` from `src/runtime/git-context.ts`) before exercising `doctor()`, rather than relying on the fixture's directory shape alone. Probed via `agent-primitives probe` with the mutant `fallback: null` -> `fallback: "drifted-fallback"` at doctor's `resolveScopedProjectName` call site (`src/cli/doctor/index.ts`; doctor's pre-`f1eb1c5c` fallback was `?? null`, the `?? repo` form was the producer's), both before and after the split: the relocated pin still fails under that mutant in both layouts. The review additionally probed the literal `?? repo` shape at the shared resolver itself (`src/runtime/git-context.ts`); the relocated pin kills it too.
|
|
89
|
+
|
|
90
|
+
- **All five previously prerelease-blind `min_version` floor checks now reject a prerelease at an equal-numeric floor, closing the boundary task `65952a0c` left open** (task `db44ab46`, decision `docs/decisions/2026-09-08-preflight-floors.md`'s "Update" section). `tools.cli[]` and `tools.mcp[]` in `harness doctor` (`checkCli`/`checkMcpVersions`, `src/cli/doctor/index.ts`), `tools.cli[]` in `harness validate` (`src/cli/validate/checks.ts`), `memory.router`'s version floor (`src/probes/memory.ts`), and policy-pack-level floors (`src/policy-packs/version-check.ts`) now all use `parseProbedVersion`/`compareVersionFloor` from `src/io/version-compare.ts`, the same pair `checkHookVersion` and `checkSessionStartPreflightSetupVersion` already used: a probed `1.2.3-rc.1` reads `below_floor` against an equal `1.2.3` floor on every `min_version` check in this codebase now, not only `hooks[]`. Who pays: an operator running a release candidate of any of these five tools now sees a below-floor diagnostic until the release ships, and severity differs by surface: `tools.cli[]` in both `harness doctor` (`checkCli`) and `harness validate` pushes `error` (counted into that command's `errorCount`, a hard failure), `tools.mcp[]` in `harness doctor` and `memory.router`'s version probe push `warn`, and the policy-pack floor's `below_floor` gap is counted into `harness doctor`'s `warningCount` the same way, the same footing as `tools.mcp[]` and `memory.router`, not a hard failure; this is correct under semver precedence, not a regression; maintainers keep one comparator. The now-dead `compareVersions` alias and its `compareNumericVersions` import are removed from `src/cli/doctor/index.ts`; the unused `SEMVER_RE` regex is removed from `src/cli/validate/checks.ts`. `docs/CLI.md`'s VERSION CAVEAT prerelease-handling and accepted-cost sentences are generalised from `hooks[]`-only to every `min_version` floor check.
|
|
91
|
+
- Pinned by new tests, one per surface (dotted `-rc.1` and dotless `-beta` prerelease cases, a real-release no-regression case, a higher-version-prerelease case where the numeric comparison is not a tie, and git-describe and platform-suffix cases at an equal-numeric floor): `tests/cli/doctor.test.ts` (two new describe blocks, `tools.cli[]` and `tools.mcp[]`), `tests/cli/validate.test.ts`, `tests/probes/memory.test.ts`, `tests/policy-packs/version-check.test.ts`, plus a direct unit-level `tests/io/version-compare.test.ts` for `parseProbedVersion`/`compareVersionFloor` themselves.
|
|
92
|
+
- **Two residuals from that entry's round-3 review, closed by task `62d9778c`.** First, `docs/CLI.md`'s claim that a `+build` metadata suffix is not a prerelease under this rule was covered only by the `parseProbedVersion` unit test in `tests/io/version-compare.test.ts`; a surface-level regression (a comparator bypassing `parseProbedVersion`) would not have been caught. A `+build` case asserting `ok` at an equal-numeric floor is now pinned at the end of each surface's own test file too: `tests/cli/doctor.test.ts` (`tools.cli[]` and `tools.mcp[]`), `tests/cli/validate.test.ts` (`tools.cli[]`), `tests/probes/memory.test.ts` (`memory.router`), `tests/policy-packs/version-check.test.ts` (the pack-level floor). Second, the "who pays" claim above that a pack-level `below_floor` gap is counted into `harness doctor`'s `warningCount` and leaves `errorCount` untouched rested on prose alone (`countDiagnostics`, `src/cli/doctor/index.ts`); a new report-level test in `tests/cli/doctor.test.ts` ("pack-level below_floor gap report-level counts") compares a floor met against a floor missed, pinning that the gap count and `warningCount` move together by the same amount while `errorCount` does not move, so a mutant reclassifying the gap into `errorCount` fails it. No behaviour change; test-only.
|
|
93
|
+
|
|
94
|
+
- **Split the shared `agent-preflight` version floor into a hook floor and a setup floor; a probed prerelease now fails the floor** (task `65952a0c`, residual of task `6993d9b5`'s round-2 review; decision `docs/decisions/2026-09-08-preflight-floors.md`). Through task `6993d9b5`, ONE constant (`SESSION_START_PREFLIGHT_SETUP_BUILD_MIN_VERSION`) fed both `harness doctor`'s `session_start_preflight.setup` advisory and `FULL_TEMPLATE`'s `git-preflight` hook `min_version`, so a future bump to either would move the other, even for an operator who never enables `setup`. Now two independent constants: `GIT_PREFLIGHT_HOOK_MIN_VERSION` (`src/cli/init/templates.ts`, the hook floor; also read by `src/cli/init/dependencies.ts`'s wizard-facing `preflight` dependency table) and `SESSION_START_PREFLIGHT_SETUP_BUILD_MIN_VERSION` (`src/schema/session-start-preflight.ts`, the setup floor). Both are `"0.6.0"` today, so a freshly generated manifest warns exactly as before; each carries its own bump rule in the ADR and is pinned to its consumer by a SOURCE-identity test (not only a value comparison, since the two constants share a value today and a value-only assertion cannot discriminate a swap between them).
|
|
95
|
+
- **Prerelease handling.** Both floor checks (`checkHookVersion`, generic across every `hooks[]` entry declaring `min_version`, and `checkSessionStartPreflightSetupVersion`) previously extracted a probed version with `/(\d+(?:\.\d+){0,3})/`, matching only the leading numeric run: `preflight 0.6.0-rc.1` parsed to `0.6.0` and silently passed a `0.6.0` floor, even though 0.6.0-rc.1 does not carry the build step the floor exists to guarantee. A new shared pair in `src/io/version-compare.ts`, `parseProbedVersion` (also captures whether a `-` prerelease/build suffix followed the numeric run) and `compareVersionFloor` (treats a prerelease `actual` as strictly below an equal-numeric floor, matching semver precedence), fixes this ONCE for both consumers. Scope note: this also changes behaviour for every OTHER `hooks[]` entry with `min_version` (`checkHookVersion` is shared code), an intentional, documented cross-consumer effect; the boundary is `hooks[]` only, for now: it does NOT extend to the other five prerelease-blind `min_version` floor checks in this codebase, which all keep their pre-existing raw-regex extraction and `compareNumericVersions` comparison unchanged: `tools.cli[]` and `tools.mcp[]` in `harness doctor` (`src/cli/doctor/index.ts`), `tools.cli[]` in `harness validate` (`src/cli/validate/checks.ts`), `memory.router`'s version floor (`src/probes/memory.ts`), and policy-pack-level floors (`src/policy-packs/version-check.ts`). See the ADR's scope note for the accepted-cost consequence (one `harness doctor` run can reject a probed prerelease for a `hooks[]` entry while accepting the identical string for `tools.mcp[]`, `memory.router`, or a `policy_packs[]` floor). Superseded by task `db44ab46`: see the entry above, the boundary is now closed.
|
|
96
|
+
- Pinned by new tests: `tests/cli/init-full-template-pins.test.ts` and `tests/cli/init-dependencies.test.ts` gain source-identity assertions (reading the actual `templates.ts`/`dependencies.ts` source text) that fail if either consumer is wired to the wrong constant; `tests/cli/doctor-session-start-preflight-setup-version.test.ts` gains the same for the setup check's `const required =` line, plus a `0.6.0-rc.1` below_floor case; `tests/cli/doctor.test.ts` gains a `0.6.0-rc.1` below_floor case for the generic hooks[] check.
|
|
97
|
+
|
|
98
|
+
- **`session_start_preflight.setup` is now scoped per repository** (task `c88461c1`), the follow-up docs/CLI.md named against task 30183330. Previously the key was resolved from the base manifest plus machine-override layers only: the generated `git-preflight` SessionStart hook invokes `harness session-start preflight` with no `--project`, so `src/cli/loader.ts`'s `resolvePaths` never consulted a project layer on that path (pinned by `tests/cli/loader-project-layer.test.ts`, from task 30183330's review round 3), and a hardcoded `--project` in the hook would have named one project for every repository. The producer (`src/cli/session-start/index.ts`) now derives a project name from its own cwd and feeds it through the loader's EXISTING `LoaderOptions.project` mechanism, the same seam every other command's own `--project <name>` flag already uses; an explicit `--project` still wins outright, and a repo with no matching `<home>/projects/<name>/harness.overrides.yaml` on disk keeps the host-wide value unchanged (no error, no warning). `harness explain-policy` now also names which resolved layer (`base`, `machine`, or `project`) decided the value for `preflight-before-*` policies. `docs/CLI.md`'s Notes and `src/schema/session-start-preflight.ts`'s header are rewritten to describe the new rule; the loader's own project-layer contract (`resolvePaths`/`loadManifest` require an explicit `LoaderOptions.project`) is unchanged and still guards every other command that flag serves.
|
|
99
|
+
- Pinned by new tests in `tests/cli/session-start/preflight.test.ts` ("per-repo scoping via cwd-derived project name"): a project layer flips `setup` in either direction against an opposite base value, an explicit `--project` overrides the cwd-derived name, and a cwd with no matching project layer keeps the base value. `tests/cli/loader-project-layer.test.ts`'s existing loader-level assertions (no project layer resolves without `LoaderOptions.project`) are updated in comment only, not deleted: they still pin the shared primitive the new cwd derivation feeds into. `tests/cli/explain-policy.test.ts` gains coverage for `session_start_preflight.source` across all three layers.
|
|
100
|
+
- **Review round 2 fixes** (task `c88461c1`). Round 1 shipped two HIGHs, one MEDIUM, and one LOW.
|
|
101
|
+
- **HIGH, decision D-021a: repository identity is the MAIN checkout, not the checkout directory.** Round 1 derived the project name from the cwd's own work-tree basename (`resolveGitContext(cwd).repo`), which differs per linked worktree of the same repository, two worktrees of one repo resolved two different project layers. `deriveProjectName` (new, `src/runtime/git-context.ts`) instead resolves the basename of the directory CONTAINING the repository's shared git common dir (reusing the existing `resolveCommonDir`), so every linked worktree of one repository shares the identical project layer; a bare repository (no `.git` wrapper) applies its own directory's basename directly, with no extra step up. The `preflight:${REPO}` ledger tag keeps using `resolveGitContext`'s `repo` unchanged (an unrelated, out-of-scope design choice). Pinned by new tests in `tests/runtime/git-context.test.ts` (`deriveProjectName`: main checkout, linked worktree resolving the main checkout's name and not its own, a linked worktree of a bare repository) and `tests/cli/session-start/preflight.test.ts` (a linked-worktree fixture driving the producer end to end, both merge directions, plus the "not named after the worktree's own directory" negative case).
|
|
102
|
+
- **HIGH, decision D-021b: `explain-policy` and `doctor` now derive the same project name the producer does.** Round 1 wired the cwd-derivation into the producer only; `harness explain-policy` and `harness doctor` still read `LoaderOptions.project` from an explicit `--project` alone, so both could report the base/machine value for a cwd where the producer was actually reading a project layer, reproduced during round-2 review (`explain-policy` printed `source: base` / `setup: false` in the same cwd the producer read `setup: true` from a project layer; `doctor`'s version-floor check warned/stayed-silent against the pre-layer value). Both verbs now call the shared `deriveProjectName` helper the same way the producer does, feeding `opts.cwd ?? process.cwd()` through when `opts.project` is absent. Pinned by new tests in `tests/cli/explain-policy.test.ts` ("source:project from a cwd-derived project name, with no explicit --project") and `tests/cli/doctor-session-start-preflight-setup-version.test.ts` ("session_start_preflight per-repo effective value"), both directions.
|
|
103
|
+
- **MEDIUM: three stale docs/tests still asserted the pre-c88461c1 "HOST-WIDE, no per-repo scoping" claim** task 30183330 shipped, now superseded by the entry above: `tests/cli/init-preflight-hook-project-scope.test.ts`'s header comment, `tests/cli/loader-project-layer.test.ts`'s inline "documented host-wide scope claim" comment, and this CHANGELOG's own task-`30183330` bullets below (now cross-referenced with a "superseded" pointer rather than left silently contradicting the entry above).
|
|
104
|
+
- **LOW: a tombstone (`session_start_preflight: null` or `{setup: null}`) was attributed to the wrong layer in `explain-policy`'s `source`.** `layerDeclaresSetup` only recognized a literal boolean; a layer that instead deletes a lower layer's value (honoured by `mergeValue`, `src/overrides/merge.ts`, as "fall back to the schema default") is just as much a declaration by THAT layer, so attributing it to whichever lower layer happens to also set a boolean named the wrong layer as the decision-maker. Now "declared" means key presence (the `session_start_preflight` key present at all, or `setup` present under it, regardless of value). Pinned by a new test in `tests/cli/explain-policy.test.ts` ("attributes a project layer's tombstone... as source:project, not the machine layer underneath it").
|
|
105
|
+
- **Review round 3 fixes** (task `c88461c1`). Round 2 shipped one HIGH, one MEDIUM (security), one MEDIUM (docs), and two LOWs.
|
|
106
|
+
- **HIGH, decision D-028: the derived project layer is now scoped to `session_start_preflight.setup` alone, everywhere it is read.** Round 2's `explain-policy`/`doctor` fix (decision D-021b, above) fed the cwd-derived project into their FULL `loadManifest` call, so every OTHER key (policies, triggers, hooks, risk) was also resolved through that project layer, while the enforcement path (`harness policy intercept`, `src/cli/policy/intercept.ts`; `harness dry-run`, `src/cli/dry-run.ts`) still read the manifest plain. Reproduced: a project layer overriding a policy trigger's `bash_match` flipped `explain-policy`'s `matched` verdict while `policy intercept` in the same cwd reported no match, and `doctor --shallow --json`'s exit code could diverge from a base-manifest-only run for reasons unrelated to `session_start_preflight`. Both verbs now do TWO loads: a PLAIN `loadManifest(opts)` (explicit `--project` only, as before round 2) feeds everything else they check, and a SECOND, project-scoped load feeds only `session_start_preflight.setup` (and, for `explain-policy`, `source`); a config/parse failure on the second load degrades to the plain load's own value (the round-3 reviewer noted this is not the producer's own fallback, whose `setupEnabled` catch degrades to `setup: false`; aligning the two and pinning it with a test is filed as a follow-up). `docs/CLI.md`'s "cannot silently disagree" sentence is rewritten to name the boundary: the three producers agree only on `session_start_preflight.setup`'s per-repo effective value, never on the full manifest a policy is evaluated against. Pinned by new tests in `tests/cli/explain-policy.test.ts` ("the derived project layer never reaches policy evaluation") and `tests/cli/doctor-session-start-preflight-setup-version.test.ts` ("the derived project layer never reaches any OTHER check"), each driving a project layer that changes a non-setup key and asserting the enforcement-relevant verdict/report is unaffected; a machine-layer-precedence-beneath-a-non-declaring-project-layer test is added alongside the existing per-layer `source` coverage.
|
|
107
|
+
- **MEDIUM (security): an unvalidated derived name could reach `resolvePaths`' `path.join` with a directory-traversal shape.** `resolveCommonDir` (`src/runtime/git-context.ts`) returned an absolute `commondir` value verbatim, unresolved `..` segments and all; a crafted `.git` FILE plus a private gitdir whose `commondir` file held such a value could make `deriveProjectName`'s final `path.basename` call textually return `".."` instead of the intended ancestor directory's real name. `resolveCommonDir` now `path.normalize`s its absolute branch (the relative branch already normalized via `path.resolve`), and `deriveProjectName` additionally rejects (returns `null` for) the empty string, `"."`, `".."`, and any name containing a forward slash, a backslash, or a NUL byte, on both its main resolution path and its unreadable-`.git`-FILE fallback. Pinned by new tests in `tests/runtime/git-context.test.ts`: a hand-built crafted-`commondir` fixture (pre-fix would have derived `".."`; post-fix derives the real main-checkout name) and a POSIX directory literally named with an embedded backslash (rejected, `null`).
|
|
108
|
+
- **MEDIUM (docs, repeated from round 1): two more stale docs/tests still described the pre-round-2 derivation.** `tests/cli/session-start/preflight.test.ts`'s "per-repo scoping" test comment still said the producer derives its project name "via `resolveGitContext`"; it names `deriveProjectName` (the MAIN-checkout rule) instead. `tests/cli/loader-project-layer.test.ts`'s describe title still read as a host-wide "no scoping without `--project`" claim, now superseded by three producers deriving `opts.project` themselves; reworded to name that this file pins the loader primitive only, not whether a caller derives into it.
|
|
109
|
+
- **LOW (docs): two more real-git shapes pinned.** `deriveProjectName`'s doc comment and `docs/CLI.md`'s REPOSITORY IDENTITY paragraph now name that a submodule checkout (`git submodule add`) derives the submodule's OWN name (its private gitdir has no `commondir` file, that mechanism is linked-worktree-only) and a `git init --separate-git-dir=<dir>` checkout derives `<dir>`'s own basename, never the work tree's, for the same reason. Pinned by two new tests in `tests/runtime/git-context.test.ts` driving real `git submodule add -c protocol.file.allow=always` and `git init --separate-git-dir=` fixtures.
|
|
110
|
+
- **LOW: a stray space before a comma in two comments** (`src/cli/session-start/index.ts`, `tests/cli/explain-policy.test.ts`), left over from the orchestrator's em-dash sweep. Fixed.
|
|
111
|
+
- **MIGRATION NOTE (tracker `52c0036f`): rename a project layer directory that no longer resolves because its name is not the canonical basename of the repository's real directory; the two cases below say exactly when.** Both situations below were measured against the built CLI with `harness explain-policy <a preflight-before-* policy> --event <event.json> --json`, reading `session_start_preflight.source` for one layer directory at a time against a repository directory `MyRepo`, a symlink `linkrepo -> MyRepo`, and a differently-cased path to that same directory, each read from all three of those access paths.
|
|
112
|
+
- **SYMLINK: rename a layer directory keyed on a symlink's own name to the real directory's basename.** After the rename, `harness explain-policy <a preflight-before-* policy> --event <event.json> --json` reports `session_start_preflight.source: "project"` for that repository. Why: the layer is keyed on `deriveProjectName`'s output, the basename of the realpath-resolved directory holding the repository's shared git common dir (the symlink-resolution entry below), so a layer at `<home>/projects/linkrepo/harness.overrides.yaml` never matches. Measured: with that layer in place `source` read `"base"` from the real path, from the symlink and from a differently-cased path alike; with `<home>/projects/MyRepo` instead it read `"project"` from all three.
|
|
113
|
+
- **CASING: rename a differently-cased LAYER DIRECTORY to the canonical on-disk basename when `HARNESS_HOME` sits on a case-sensitive filesystem; on a case-insensitive one it keeps resolving and needs no change. A differently-cased ACCESS PATH carries no rename instruction at all.** Why: `deriveProjectName` does not correct casing (Node's default `fs.realpathSync`; unchanged by this release), so the layer lookup is exactly as forgiving as the filesystem `HARNESS_HOME` itself lives on. Measured with a layer directory `myrepo` against the derived name `MyRepo`: on a case-insensitive home `source` read `"project"` from all three access paths; on a case-sensitive home the same layer read `"base"` from all three, and the fix there is the same rename as the symlink case. The access path is a separate axis and reaches almost nothing: `process.cwd()` was the canonical `.../MyRepo` from the real path, the symlink and the differently-cased path alike, so every CLI verb derives from the canonical spelling, and only the SessionStart hook's raw event `cwd` can still carry the spelling a caller typed.
|
|
114
|
+
|
|
115
|
+
- **`check:duplication` pin lowered 115 -> 114** (task `ff34bde0`): the
|
|
116
|
+
check reported 114 clones against the pin of 115, one entry of slack;
|
|
117
|
+
the pin follows the measured count so the next clone is caught.
|
|
118
|
+
|
|
119
|
+
- **Memory index routing now follows the versioned `memory-frontmatter/v1` contract.** (commit `dd8ee788a852a76dab73ed2177802999fa84ca3d`). It falls back from a falsy top-level `type` to `metadata.type`, then includes only `user`, `feedback`, `project`, or `reference` memories. Refresh the pinned contract with `npm run sync:memory-frontmatter-contract` and verify it with `npm run check:memory-frontmatter-contract`.
|
|
120
|
+
|
|
121
|
+
- **Stay-in-scope reminders are now an explicit optional pack integration.** (commit `ad33ff1f3f9eba2dac738d08c429e9ca8a9d6fbc`). `config.stay_in_scope` is default-off and requires complete, validated tool names, literal detectors, optional parent patterns, and message text when enabled. Both runtime generators and the live hook read the same resolver; a stale generated hook no-ops after configuration is absent, disabled, or invalid. Existing generated configuration must be refreshed with `harness apply` after opting in or changing tools.
|
|
122
|
+
|
|
123
|
+
- **Operator-only policies no longer require the evidence provider at deployment.** (commit `8e6e35d2a15838b05f29cae520255f659e0acf9e`). `harness validate` and `harness apply` require `grounding-mcp` only when at least one policy declares `requires:`. A manifest containing only `operator_only: true` policies now validates and applies without that MCP, while retaining generated hooks; mixed manifests and workflow-derived evidence policies remain refused or warned when the provider is absent. Runtime evaluation is unchanged: operator-only actions still deny without an evidence-ledger query.
|
|
124
|
+
|
|
125
|
+
- **`solution-acceptance` policy pack teaches reconnect-vs-retry for `solution_evaluate`** (task `529e85ea-c491-43e9-b871-eb6a51d42e8b`), per grounding-mcp's shipped attempt lifecycle (grounding-mcp >= 0.11.0). The pack's emitted operator instructions (audit copy, `policy-packs/solution-acceptance/instructions.md`, built by `src/policy-packs/builtin/solution-acceptance.ts`) and its human-facing doc (`docs/policy-packs/solution-acceptance.md`) now describe: a `{status:"running", attemptId, id, pollAfterMs}` handle (or a request that timed out with nothing) is polled with `mcp__grounding-mcp__solution_evaluate_status` / `mcp__grounding-mcp__solution_evaluate_result` for the SAME `id` (`attemptId` omitted resolves the latest attempt for that id); `running-unconfirmed` still means keep polling; a new attempt is started only once the previous one is terminal AND the id's lock is free again; `solution_evaluate` is never re-called as a stall workaround, and a human is never escalated to before the advertised `pollAfterMs` has elapsed. The agent-facing channel for this guidance today is grounding-mcp's own tool descriptions (`solution_evaluate_status` / `solution_evaluate_result`, installed 0.11.0); this pack's instructions.md is an operator audit copy, not injected into the solving agent's own prompt. No claim is made that grounding-mcp's separate progress-notification pings solve any specific client's own request timeout; the tool-call lifecycle above is the only mechanism this pack documents as guaranteed.
|
|
126
|
+
- Pinned by a new test in `tests/policy-packs/solution-acceptance-expand.test.ts` ("teaches reconnect-vs-retry..."): asserts both new tool names appear in the emitted `instructions.md` and that the "Never re-call `solution_evaluate` as a stall workaround" sentence is present, so a mutant dropping either the poll sentence or its "Never" fails this test.
|
|
127
|
+
- No gate semantics changed: this is prompt/doc text only, `solution-acceptance-runtime.ts` is untouched.
|
|
128
|
+
|
|
129
|
+
- **`solution-acceptance`'s reconnect-vs-retry guidance now reaches the agent, not only the operator's audit copy** (task `5c9cad05-bc13-49ad-80e5-8273742f2789`). `instructions.md` (above) is rendered once and never re-read by the agent mid-session, so nothing surfaced the guidance at the moment it is actually needed: the completion-gate denying a completion verb with no ready verdict on record. A pack MEMORY.md contribution was considered and rejected: `PackContribution` (`src/policy-packs/types.ts`) carries `hooks`, `files` and an optional `permissions` contribution, none of which reaches the generated MEMORY.md, and `generate-memory-index.ts` builds `MEMORY.md` solely from user-authored files under `manifest.memory.directories[]`, packs have no contribution path into it today, and adding one is out of scope here. The completion-gate's deny text (`harness pack hook solution-acceptance`, `blockJson` in `src/cli/pack/hook-solution-acceptance.ts`) is the surface instead: it fires exactly when the agent needs this. The guidance is gated on `gate.verdict === null` (`evaluateGate`'s "no solution-acceptance verdict recorded" branch): this hook does not read the documented attempt-lock anchor (scope decision, see the follow-up below), so it names all three readings the condition covers, "never evaluated", "an attempt is still running in the background", and "a marker exists but could not be read or parsed", and the same deny carries the facts an agent needs either way: reconnect with `solution_evaluate_status` / `solution_evaluate_result` by `attemptId` (or the latest attempt when omitted); never retry `solution_evaluate` while the id's lock is held (a second call for a live attempt just joins it and returns its `attemptId`, never starting a second `preflight` run; only `forceNewAttempt` is refused while that lock holds); the poll interval and retention bounds verified against the released grounding-mcp v0.11.0 README (`pollAfterMs` advertised as `5000`, retention 24h by default and always at least 100x `pollAfterMs`, a pruned terminal attempt reads `expired`). The guidance does not appear on a not-ready or stale verdict deny (both mean a run already completed, no in-flight ambiguity there), when no verdict id resolved at all, on the manifest-load-failure failsafe deny, or when an operator's custom `ux` block replaces the default deny text. `instructions.md` stays the audit copy (see "Review round 3 redesign" below for how it now renders). grounding-mcp's README also documents an attempt-lock anchor (`<verdict dir>/<id>.attempt-lock`, beside the `<id>.attempt-lock.lock` directory `proper-lockfile` manages) the hook could read to narrow this to just the in-flight case; reading it is a follow-up, not this change, since it is a second cross-repo coupling to the producer's lock-file layout with its own stale-lock semantics to absorb. See `docs/policy-packs/solution-acceptance.md`, "Agent-facing surface for the in-flight case (decision, harness/5c9cad05)", for the full decision record.
|
|
130
|
+
- **Review round 1 fixes** (task `5c9cad05`, the branch's round-2 commits; the commit messages name the task id, so this entry covers them across rebases). The deny text had wrongly claimed a live attempt's lock "refuses a second `solution_evaluate` call"; grounding-mcp's actual semantics are that a second call for a live attempt JOINS it (only `forceNewAttempt` is refused), corrected in both the deny text and this doc. The code comment and doc subsection had also overclaimed that no in-flight signal exists at all; both now say the hook does not read the documented attempt-lock anchor (scope decision, see the follow-up), naming all three `gate.verdict === null` readings instead. "No verdict marker on record" is now "no readable verdict marker" throughout, since `gate.verdict === null` also covers a marker that exists but failed to parse.
|
|
131
|
+
- **Review round 3 redesign, fixing the round-2 findings** (task `5c9cad05`; docs/okf/log.md uses the same round numbering). The recurring review class across rounds 1-2 was hand-written deny text asserting producer semantics that drift from `instructions.md` and the source, so this round moves the reconnect-vs-retry facts into one shared module, `src/policy-packs/builtin/solution-acceptance-reconnect.ts`: both the deny paragraph (`renderReconnectDenyParagraph`) and the `instructions.md` section (`renderReconnectInstructionsSection`) now render from the SAME exported fact constants, qualified throughout with `RECONNECT_VERSION_QUALIFIER` ("With grounding-mcp >= 0.11.0:") rather than asserted as if the pack's own (older) producer floor (>= 0.3.2) guaranteed it. `tests/policy-packs/solution-acceptance-reconnect.test.ts` asserts every fact constant appears verbatim in both rendered surfaces, so a future edit to one surface that forgets the other fails that test. `docs/policy-packs/solution-acceptance.md` says `PackContribution` carries "hooks, files and an optional permissions contribution, none of which reaches the generated MEMORY.md" (it has three fields, not two, `src/policy-packs/types.ts`).
|
|
132
|
+
- Pinned by `tests/cli/pack-hook-solution-acceptance.test.ts`: the no-verdict deny test ("the no-verdict deny carries the reconnect-vs-retry facts...") asserts the tool names, the `attemptId`/no-retry/`joins`/`forceNewAttempt` sentence, and the `pollAfterMs`/`5000ms`/`24h`/`100x pollAfterMs` numbers; the not-ready, stale, no-verdict-id, and (new, review round 2) manifest-load-failure deny tests all assert the guidance is absent there.
|
|
133
|
+
- **Rendering polish** (task `04189542-266d-4607-a764-9c2e4f752dfb`, commits `1f9335e` and `3fccde4`, PR #533): shared facts now own the backticked identifiers and explicit verdict-id placeholder, while each renderer owns its lead-in. The focused parity test pins both rendered surfaces, the resolved verdict id, the single retention-floor identifier, and line wrapping.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **`harness session-start preflight` (and its `harness preflight` alias) can pass `--setup` through to the `preflight run` invocation it spawns** (task `30183330`). New opt-in manifest key `session_start_preflight.setup` (default `false`, `.strict()`; `src/schema/session-start-preflight.ts`), where an absent `session_start_preflight:` block and an explicit `setup: false` block behave identically, matching every other SessionStart companion's opt-in convention (`toolchain_parity`, `stale_base_check`). Motivation: agent-preflight's build-required test classification (agent-preflight PR #72) exists only because this companion ran `preflight run --json <cwd>` with no `--setup`, so a fresh npm-workspaces monorepo checkout (dependencies installed, nothing built yet) always hit the named "build required before test" skip on its root `npm test`; the producer now stops manufacturing that failure once an operator opts in. The build step itself is on the UNRELEASED agent-preflight build this schema was authored against (commit `f339b062381c0c06b29f5381e9aaf2f78d135151`, npm-linked locally); the released `@lannguyensi/agent-preflight@0.5.0`'s `--setup` is dependency-install only, no build step, so this knob has no visible effect against that release beyond the (harmless) install. The whole `preflight run` child, with or without `--setup`, stays bounded by the SAME `--timeout` / `preflightTimeoutMs` this producer already accepts (`DEFAULT_PREFLIGHT_TIMEOUT_MS`, 60000ms, `src/cli/session-start/index.ts`), enabling `setup` on a workspace whose install+build routinely runs long should come with an explicit `--timeout` raise; neither the schema nor the producer ever raises it automatically; `harness explain-policy` now shows the resolved `session_start_preflight.setup` value for `preflight-before-*` policies. The HEAD-sha resolution `preflight-before-push`'s `at_head` gate relies on is unchanged: it is still captured before `runPreflight` is invoked, pinned by a new regression test that mutates the loose ref DURING an injected `runPreflight` call and asserts the recorded `head:<sha>` is the value from before that call, not after.
|
|
138
|
+
- Measured wall time: against agent-preflight's own zero-dependency `tests/fixtures/monorepo-build-required` fixture (an npm-workspaces repo with one unbuilt workspace and a `ci.yml` build-before-test step), the root `npm-test` check goes from the named `skip` (no `--setup`, ~0.43s total wall time) to `pass` (`--setup`, ~0.60s total wall time). Neither `agent-ops-dashboard` nor `project-pilot` (the two candidate real fleet monorepos) actually reproduces the named skip: `project-pilot`'s root `package.json` has no top-level `test` script (only per-workspace `test:coverage`), so agent-preflight's Node test check never runs there at all; `agent-ops-dashboard`'s workspace tests already pass without a build (they do not import their own `dist/`). Run against a fully fresh `agent-ops-dashboard` worktree (no `node_modules` at all) instead, for a realistic order-of-magnitude on `--setup`'s added cost on a real monorepo: ~0.85s total without `--setup` (the Node test check is skipped outright there too, for the DIFFERENT reason that no test runner is installed yet) versus ~17.9s total with `--setup` (`npm ci` plus a conditional `npm run build`); `--setup`'s own added wall time is dependency-install-and-build bound and scales with the target workspace, not with this companion's own logic.
|
|
139
|
+
- **Review round 2 finding (HIGH): on the very fixture this measurement cites, `--setup` SUPPRESSES the tag it exists to enable, unless the target repo gitignores its build output.** `packages/needs-build/dist/` (the build artifact `npm run build` writes) is not gitignored in agent-preflight's `tests/fixtures/monorepo-build-required` fixture. Re-measured 3 runs per cell, git-initialized copies of that fixture, `preflight run [--setup] --json .`, on the fixture as-is and on a variant with `node_modules/`, `dist/`, and `packages/*/dist/` added to `.gitignore`:
|
|
140
|
+
- Fixture as-is, no `--setup`: `npm-test` -> `skip` (build-required), `clean-worktree` -> `pass`, overall `ready` -> `true`; ~0.46-0.51s wall time (3/3 runs).
|
|
141
|
+
- Fixture as-is, `--setup`: `npm-test` -> `pass` (the build ran), but `clean-worktree` -> `fail` ("Repository has uncommitted changes", the new `dist/index.js` is untracked), overall `ready` -> **`false`**; ~0.65-0.72s wall time (3/3 runs). `runSessionStartPreflight` only writes the ledger fact on `ready === true`, so on this exact fixture `setup: true` makes the tag DISAPPEAR instead of appearing.
|
|
142
|
+
- Gitignored variant (`node_modules/`, `dist/`, `packages/*/dist/` added), no `--setup`: `npm-test` -> `skip`, `clean-worktree` -> `pass`, overall `ready` -> `true`; ~0.46-0.52s wall time (3/3 runs), unchanged from the ungitignored case, as expected (nothing to gitignore yet without `--setup`).
|
|
143
|
+
- Gitignored variant, `--setup`: `npm-test` -> `pass`, `clean-worktree` -> `pass` (the build artifact is gitignored, so it does not register as an uncommitted change), overall `ready` -> `true`; ~0.66-0.67s wall time (3/3 runs). This is the shape the knob is meant to produce.
|
|
144
|
+
- Remedy, now stated in `docs/CLI.md`'s row and `src/schema/session-start-preflight.ts`'s header comment: `--setup` writes install/build output into the target repo's own worktree, and any such artifact the repo does not already gitignore flips agent-preflight's `clean-worktree` check and suppresses the tag entirely: gitignore the build/install output (e.g. `dist/`, `node_modules/`) in the target repo BEFORE enabling `session_start_preflight.setup`. A companion regression test now pins the ready:false-from-setup shape directly (see "Pinned by" below) so a future change to the not-ready branch cannot silently start writing the tag on that shape.
|
|
145
|
+
- **Review round 2 finding (HIGH, security/docs): the trust and scoping surface was undocumented.** `docs/CLI.md`'s row and `src/schema/session-start-preflight.ts`'s header comment now mirror agent-preflight's own README trust wording (its `--setup` / `setup.enabled` section): under `--setup`, a `run:` line in the TARGET repo's own `.github/workflows/ci.yml` decides whether that repo's `build` script executes on the operator's machine, and `npm ci` runs that repo's own dependency lifecycle scripts; both are attacker-controlled repository content, so `setup: true` belongs only on repositories the operator already trusts to run. Both surfaces also now say the key is HOST-GLOBAL: it is read from the base manifest plus whatever machine-override layers apply (`src/cli/loader.ts`'s `resolvePaths`/`applyLayers`), and the generated `git-preflight` SessionStart hook invokes `harness session-start preflight` with no `--project` (`src/cli/init/templates.ts`), so one `setup: true` applies to every repo the operator opens a Claude Code session in from this host. This round's wording that "a project override layer turning it back off is the only scoping available today" was WRONG and is corrected in round 3 below. Neither the default (still `false`) nor per-repo scoping changed in this round.
|
|
146
|
+
- **Review round 2 finding (MEDIUM, correctness/docs): the timeout budget and process-isolation mismatch was undocumented.** agent-preflight's own build step gets a SEPARATE, larger wall-clock budget on its side, `DEFAULT_SETUP_BUILD_TIMEOUT_MS` = 300000ms (agent-preflight's `src/checks/shared.ts`) plus its own `npm ci` step, against this companion's `DEFAULT_PREFLIGHT_TIMEOUT_MS` = 60000ms default `--timeout`. `execFile`'s `timeout` option kills only the `preflight` process itself, not a process group, so an in-flight `npm ci` / build can keep writing into the target repo's worktree after this hook has already exited 0 on a timeout. Recommendation, now stated next to the existing timeout-interplay text in both `docs/CLI.md` and this entry: raise `--timeout` here AND set a matching `setup.buildTimeoutMs` in the target repo's own `.preflight.json` together when enabling `setup` on a workspace whose install+build is slow. No process-group change in this round.
|
|
147
|
+
- **Review round 3 fixes** (task `30183330`, the branch's round-3 commit). Rounds 1 and 2 each shipped documentation asserting behaviour the code does not provide, so this round traces every behavioural claim back to its code path and pins the scope claims with tests.
|
|
148
|
+
- **HIGH (security/docs), corrected: the round-2 sentence "a project override layer setting `setup: false` back is the only scoping available today" was FALSE.** `resolvePaths` (`src/cli/loader.ts`) resolves a project override layer only when `LoaderOptions.project` is set; `--project <name>` is the only source of that value; and the generated `git-preflight` SessionStart hook (`src/cli/init/templates.ts`) invokes `harness session-start preflight` with no `--project`, so no project layer is ever consulted on the hook path. Hardcoding a `--project <name>` into that hook would not scope anything either, since the hook command is one static string and would name the same single project for every repository. `docs/CLI.md` and `src/schema/session-start-preflight.ts` now state the truth instead: NO per-repo scoping exists today, the key is a host-wide on/off resolved from the base manifest plus whatever machine-override layers apply, and making the producer derive a project name from its own cwd would be the follow-up that turns it into a per-repo knob. Both halves of that claim are now pinned by tests (see "Pinned by, review round 3" below), and the machine-override layer, which IS the real scoping surface, is driven end to end by a producer test. **Superseded in this release by task `c88461c1` (see above): the producer now DOES derive a project name from its own cwd, so this "NO per-repo scoping exists today" claim describes the pre-`c88461c1` state only.**
|
|
149
|
+
- **MEDIUM (tests): the round-2 "ready:false from `--setup`" regression test never drove the feature.** It set no `session_start_preflight.setup` at all, so it exercised the plain not-ready branch and no `--setup` mutant could kill it. It is replaced by two tests that do drive it: one asserts inside the injected runner that `setup === true` arrived before returning the clean-worktree failure, and one spawns a fake `preflight` binary on `PATH` that emits the degraded `ready:false` payload ONLY when `--setup` is in its own argv, so the whole chain (manifest key, `setupEnabled`, `buildPreflightArgv`, the `execFile` argv) is load-bearing for the assertion.
|
|
150
|
+
- **MEDIUM (maintainability): the ledger-writer's own `loadManifest(opts)` call no longer reads `opts.manifest`.** Round 1 had widened it to `opts.manifest ?? loadManifest(opts).manifest`, which nothing in this change needs and which contradicted the `manifest` option's own documented scope. It is back to the plain `loadManifest(opts).manifest`, and the option's JSDoc plus the comment at that call site now describe exactly that: the injected manifest reaches the `setupEnabled` resolution and nothing else, so it can never suppress the ledger writer's run-aborting load failure.
|
|
151
|
+
- **Documentation placement.** `docs/CLI.md`'s table cell is now a one-line pointer plus the security sentence; the HOST-WIDE, SECURITY, KNOWN DEGRADATION and TIMEOUT INTERPLAY detail moved into a Notes subsection under the table, following the pattern the file already uses for toolchain-parity. The agent-preflight fixture path behind the degradation measurement is cited here in the CHANGELOG only, not in the user-facing doc. The Notes also carry the version caveat: the conditional build step exists only on the UNRELEASED agent-preflight build, `--setup` on the published `@lannguyensi/agent-preflight@0.5.0` is dependency-install only, and the `git-preflight` hook template's `min_version` therefore stays `0.2.0` until the agent-preflight release that ships the build step. **Superseded in this release by task `c88461c1` (see above): the Notes subsection's HOST-WIDE label now describes the PER-REPO SCOPING bullet's precedence baseline (base manifest + machine layers), not the absence of a project layer.**
|
|
152
|
+
- Both commander descriptions (`harness preflight` and `harness session-start preflight`, `src/cli/index.ts`) now name the opt-in key, say it is default off, and point at `docs/CLI.md` for the trust and scope caveats.
|
|
153
|
+
- Pinned by, review round 3: `tests/cli/init-preflight-hook-project-scope.test.ts` (the rendered `git-preflight` hook command carries no `--project`, checked on `FULL_TEMPLATE` and across every template `harness init` can write); `tests/cli/loader-project-layer.test.ts` (`resolvePaths` returns `projectLayer: null` without `opts.project` even with a project layer on disk, and `loadManifest` keeps the base manifest's `setup: true` rather than the layer's `setup: false`; both invert when `project` is passed, so the mechanism is pinned as present-but-unreachable-on-the-hook-path rather than absent); `tests/cli/session-start/preflight.test.ts` (the machine-override layer flipping `setup` back off end to end through the producer, and the two rewritten `ready:false from --setup` tests described above); and `tests/cli/explain-policy.test.ts` (name-prefix boundary: a custom policy that consumes `preflight:` evidence but is not named `preflight-before-*` gets no `session_start_preflight` field, while a bare `preflight-before-` prefixed policy does).
|
|
154
|
+
- Pinned by `tests/cli/session-start/preflight.test.ts` (`buildPreflightArgv`'s argv-pinning tests with and without `setup`, the `SessionStartPreflightSchema` manifest-parsing tests (default off, `.strict()` unknown-key rejection, wrong-type rejection), the manifest-wiring tests proving `setup` flows from an injected manifest through to the `runPreflight` call, the "degrades to false rather than aborting the whole run" test for a manifest load failure, the HEAD-binding order regression test, a new test asserting a `ready:false` result whose `clean-worktree` check failed leaves the ledger tag unwritten and surfaces the failing check in the reason/log the same way every other not-ready result does, a new test proving `setup: true` reaches `runPreflight` via the REAL `loadManifest` path off a temp `configPath` manifest (not only the injected-`manifest` seam), and a new test pinning that the real `spawnPreflight` execFile call receives `buildPreflightArgv(cwd, setup)`'s argv for both `setup` states against a fake `preflight` binary on `PATH`) and `tests/cli/explain-policy.test.ts` (the new `session_start_preflight` field: shown false by default, true when enabled, absent for a non-`preflight-before-*` policy).
|
|
155
|
+
- **Review round 3 residual fixes** (task `c88461c1`, tracker `1c4eb3ea`, batch-44 follow-up). Round 3 closed `accept_with_notes` with four LOWs drafted as follow-ups plus one gap named only in review: a derived layer that fails to load produced no diagnostic anywhere. This entry closes all five.
|
|
156
|
+
- **Scoped-load failure now degrades to `setup: false`, not the plain load's value, in both `explain-policy` and `doctor`.** A round-3 comment claimed the two already matched the producer's own `setupEnabled` catch; they did not; both kept the PLAIN load's `setup` value on a scoped-load throw. `explain-policy` now reports a new `source: "unresolvable"` value alongside `setup: false` (not `"base"`, which would misattribute the result to a layer it never actually re-read on this path); `doctor` goes silent (no finding), the same as a genuinely off `setup`. Pinned by a malformed-project-layer test in each of `tests/cli/explain-policy.test.ts` and `tests/cli/doctor-session-start-preflight-setup-version.test.ts`. Superseded by the "Review round 1 fixes" entry below: `doctor` no longer goes silent here, it reports a new `kind: "layer_unresolvable"` warning instead.
|
|
157
|
+
- **The producer's own `setupEnabled` catch now names the failed layer path on stderr.** Previously silent: nothing anywhere said WHY a scoped-load failure degraded to `setup: false`, or which layer failed. `src/cli/session-start/index.ts` now emits one line via the existing `note()` helper naming the project layer path and the underlying error (best-effort; only when the layer path itself is resolvable, and only for a real `loadManifest` failure, not an injected `opts.manifest`). Pinned by a new test in `tests/cli/session-start/preflight.test.ts`.
|
|
158
|
+
- **`isValidProjectName` (`src/runtime/git-context.ts`) is exported and applied at `resolvePaths`' own `path.join` sink** (`src/cli/loader.ts`), defense in depth: `deriveProjectName` already validated the name at every one of its own exits, but `resolvePaths` had no equivalent guard for an `opts.project` reaching it from anywhere else. An invalid name (`".."`, a name containing a path separator, etc.) now resolves `projectLayer: null`, the same as "no layer on disk", instead of being joined unvalidated. Pinned by new tests in `tests/cli/loader-project-layer.test.ts` (a `".."` `opts.project` resolves no layer even when a file sits one level up, and `loadManifest` keeps the base value; a valid name is unaffected).
|
|
159
|
+
- **`doctor`'s `sessionStartPreflightSetupVersion` finding now carries the cwd-derived project name.** Previously the report's own top-level `project` field reflected only an EXPLICIT `--project` (`opts.project ?? null`), so a `setup: true` verdict decided by a per-repo project layer looked identical to one decided by the base/machine value. `checkSessionStartPreflightSetupVersion` (`src/cli/doctor/session-start-preflight-setup-version.ts`) gains an OPTIONAL third `projectName` argument, carried onto the finding as `projectName` (omitted, not merely `undefined`, when the caller does not pass it, so this pure function's own pre-existing unit tests are unchanged); `doctor()` always passes the same name it derives for its own scoped load, and `format.ts` renders it as a `(project: X)` suffix on the warning line. `docs/CLI.md`'s VERSION CAVEAT paragraph names the new field. Pinned by new tests in `tests/cli/doctor-session-start-preflight-setup-version.test.ts` (three pre-existing end-to-end assertions also gained a `projectName` expectation, since `doctor()` now always passes one). Superseded by the "Review round 1 fixes" entry below: `doctor()` no longer always passes this name, only when the scoped load actually RESOLVED a project layer file.
|
|
160
|
+
- **`explain-policy`'s scoped SECOND load now runs only for a `preflight-before-*` policy**, moved behind the same name-prefix check that already gates whether the resulting block is even rendered (previously it ran unconditionally, before the policy was even looked up, so explaining any OTHER policy paid for a manifest load whose result it could never see). Pinned by a call-counting spy test in `tests/cli/explain-policy.test.ts` (`vi.mock` + `importOriginal` over `loadManifest`, the ESM-named-export idiom `tests/cli/manifest-view-parity.test.ts` already uses).
|
|
161
|
+
- **A checkout reached through a symlink now derives the REAL directory's name, matching decision D-021a's "repository identity is the common dir" rule.** `deriveProjectName` (`src/runtime/git-context.ts`) resolves the common dir through `fs.realpathSync` before taking its basename (best-effort: a dangling symlink or unreadable target falls back to the un-resolved value, never throws). Pinned by a new symlinked-checkout test and a realpath-failure-fallback test in `tests/runtime/git-context.test.ts`.
|
|
162
|
+
- **`layerDeclaresSetup`'s WHOLE-BLOCK tombstone branch (`session_start_preflight: null`, distinct from the per-key `{setup: null}` form) is now tested.** Pinned by a new test in `tests/cli/explain-policy.test.ts`.
|
|
163
|
+
|
|
164
|
+
- **Review round 1 fixes** (task `1c4eb3ea`, round 2 of the batch-44 follow-up; three MEDIUMs, five LOWs). Round 1's own review found: the `(project: X)` suffix docs/CLI.md claimed distinguished a layer-decided warning from a base/machine-decided one was FALSE, `projectName` carried the ATTEMPTED derivation, present for every cwd inside a git work tree, layer or no layer; the `format.ts` rendering of that suffix was untested (a probe forcing the condition to `false` survived); a derived layer that fails to load still produced NO diagnostic in `doctor`'s own report, only the producer's stderr line; the producer's diagnostic could misattribute a base- or machine-layer parse failure to a perfectly fine project layer; the "one line" diagnostic claim was false against a genuinely multi-line YAML error; about a dozen bare `D-006` code-comment citations collided with a DIFFERENT, unrelated `D-006` decision from the batch-43 run; `docs/okf/log.md` got no entry, leaving its newest entry describing the behavior this round REVERSES as current; and the SINK GUARD docs sentence read as broader defense-in-depth than `isValidProjectName` actually provides. This entry closes all eight.
|
|
165
|
+
- **`doctor`'s `sessionStartPreflightProjectName` (and the finding's `projectName`) is now set only when the scoped load actually RESOLVED a project layer file**, not merely whenever a name was derivable for the cwd (`resolvePaths(...).projectLayer !== null`, the same signal the producer's own diagnostic already used to decide whether to emit its stderr line). `docs/CLI.md`'s VERSION CAVEAT paragraph rewritten to say so. Pinned by a new end-to-end `doctor()` test asserting `projectName: null` (and no `(project: X)` suffix in `format()`'s output) when a name was attempted but no layer exists on disk, alongside the pre-existing positive case.
|
|
166
|
+
- **`format.ts`'s `(project: X)` suffix rendering is now asserted end to end**, both the positive case above and the negative control; a probe forcing the suffix condition to `false` (the round-1 reviewer's survivor) is now killed.
|
|
167
|
+
- **`doctor` reports a new `kind: "layer_unresolvable"` warning instead of going silent when a project layer FILE exists but fails to load.** `SessionStartPreflightSetupVersionFinding` gains this kind (`actualVersion: null`, `projectName` set, `message` naming the layer path and the parse error's FIRST LINE), counted in `warningCount`, rendered by `format.ts` as one warning line, built directly by `doctor()` since `checkSessionStartPreflightSetupVersion` never produces ANY finding once `setup` has degraded to `false`. A scoped-load failure with no project layer on disk for the attempted name (the throw came from the base/machine layers) still stays silent, unchanged: `doctor()`'s own PLAIN top-level `loadManifest(opts)` call already surfaces a genuine base/machine parse failure by throwing before this point is reached. `docs/CLI.md`'s "goes silent" sentence rewritten. Pinned by the pre-existing malformed-project-layer `doctor` test, now asserting the new finding shape and a `format()` assertion instead of `toBeUndefined()`.
|
|
168
|
+
- **The producer's own diagnostic no longer blames "the project layer" for a base- or machine-layer parse failure.** The lead-in now describes the SCOPED LOAD itself failing (`the project-scoped manifest load for project <name> failed`), with the layer path kept only as context, never as the claimed culprit; the underlying (possibly multi-line) YAML parse error is collapsed to its FIRST LINE in both this diagnostic and the new `doctor` finding above, so the "one line" claim actually holds for a genuinely multi-line error. Pinned by a new machine-layer-failure test in `tests/cli/session-start/preflight.test.ts` (a VALID project layer, a malformed machine layer, asserting the message never claims the project layer failed) and a one-line assertion added to the pre-existing malformed-project-layer test.
|
|
169
|
+
- **About a dozen bare `D-006` code-comment citations (in `src/cli/loader.ts`, `src/cli/doctor/{index,format,session-start-preflight-setup-version}.ts`, `src/cli/explain-policy.ts`, `src/cli/session-start/index.ts`, `src/runtime/git-context.ts`, and `docs/CLI.md`) replaced by the tracker id `1c4eb3ea`.** They all cited THIS run's own D-006 pre-decision (`.ai/runs/2026-09-08-open-pool-batch44/03-decisions.md`, about this task's item 5), but a bare `D-006` collides with an UNRELATED D-006 decision from the batch-43 run; the batch-43 citations (`D-021a`, `D-021b`, `D-028`) are left exactly as they were, since those remain unambiguous.
|
|
170
|
+
- **`docs/CLI.md`'s and `src/runtime/git-context.ts`'s SINK GUARD sentences narrowed to name `resolvePaths` as the ONE guarded sink.** `isValidProjectName` does not guard `substituteProject` (`src/probes/memory.ts:62`) or `generate-memory-index.ts`'s own `{project}` substitution (`src/cli/apply/generate-memory-index.ts:71`), both of which still interpolate an operator-supplied `--project` value into a path unvalidated (reachable only via the explicit `--project` flag, never this module's cwd-derived value, so the exposure is narrow); guarding or explicitly accepting those two sinks is a follow-up, out of this task's scope. **Superseded by task `e904f25a`** below for `substituteProject` specifically: once `buildLockEntries` (`src/io/harness-lock.ts:289-291`, fed from `opts.project` in `src/cli/apply/apply.ts`) was found to interpolate the same placeholder unguarded and un-reserved, THREE `{project}` sinks (`substituteProject`, `generate-memory-index.ts`, `buildLockEntries`) were unguarded as of task `1c4eb3ea`'s own release; task `e904f25a` guards `substituteProject`, so TWO remain unguarded now (`generate-memory-index.ts`, Codex-reserved; `buildLockEntries`, a follow-up). The stale `src/probes/memory.ts:62` line reference above is superseded along with the claim it supported.
|
|
171
|
+
- **`docs/okf/log.md` gains a top entry** recording this round's reversal to `setup: false`, the new `unresolvable`/`layer_unresolvable` values, the narrowed `projectName`/sink-guard docs, and the re-anchored/re-stamped bundle docs (see the entry itself).
|
|
172
|
+
|
|
173
|
+
- **Review round 2 fixes** (task `1c4eb3ea`, round 3 of the batch-44 follow-up; two MEDIUMs, five LOWs, one missing-test finding). Merged `origin/master` (task `65952a0c`) first; see `docs/okf/log.md`'s top entry for the conflict-by-conflict resolution. Round 2's own review found: `docs/CLI.md`'s VERSION CAVEAT claimed the `layer_unresolvable` degrade affects "the manifest every OTHER check in this report reads," which is false (it degrades only the LOCAL copy this one check judges, unconsumed on this path at all); two `CHANGELOG.md` round-1 sub-bullets described behaviour in the present tense that round 2 had already reversed; the `KNOWN UNREACHABLE TODAY` code comment labeled the whole scoped-load catch when only one sub-path and one assignment are actually unreachable/unobservable; the `layer_unresolvable`-counted-in-`warningCount` claim was pinned only with `toBeGreaterThanOrEqual(1)`, satisfiable by unrelated environment warnings; the fourteen remaining bare `D-006` citations the round-1 fix missed all lived in test files, not the src files that fix covered; `docs/CLI.md` did not say what `--project` against a malformed layer actually does (a hard fail at the plain load, never `layer_unresolvable`); `docs/CLI.md` said `projectName` is "absent," when every doctor finding actually carries `projectName: null`; and the machine-layer-failure test pinned only the negative half of its own claim. This entry closes all eight (docs fixes 1, 2, 6, 7 below; the code-comment fix 3; the test fixes 4, 8; the fourteen-occurrence sweep 5).
|
|
174
|
+
- **`docs/CLI.md`'s VERSION CAVEAT corrected: the `layer_unresolvable` degrade is a LOCAL copy, unconsumed on this path.** The degraded manifest built in `doctor()`'s scoped-load catch is fed only to `checkSessionStartPreflightSetupVersion`; on the `layer_unresolvable` path it is never even read, since the check reports the load failure itself instead of any setup verdict; every OTHER doctor check reads the untouched plain base/machine manifest, unaffected.
|
|
175
|
+
- **Two round-1 `CHANGELOG.md` sub-bullets ("doctor goes silent" and "`doctor()` always passes the same name") now carry a superseded-by clause** pointing at the round-1-fixes sub-bullets above that reverse both.
|
|
176
|
+
- **`src/cli/doctor/index.ts`'s scoped-load catch comment narrowed to what is actually unreachable/unobservable.** The catch itself IS reached in every shipped run with a malformed project layer; only the `unresolvableLayerPath === null` sub-path is unreachable today, and only the manifest-degrade assignment right below the comment is unobservable (its value is always discarded by the `??` that prefers `sessionStartPreflightLayerUnresolvable`, in every real invocation).
|
|
177
|
+
- **`warningCount`'s `layer_unresolvable` contribution is now pinned by a delta, not a floor.** A new test runs the same fixture once with and once without the malformed project layer (`setup: false` at the base in both, so no other setup-related finding can fire) and asserts the two `warningCount`s differ by exactly one.
|
|
178
|
+
- **The fourteen bare `D-006` citations left in five test files (`tests/runtime/git-context.test.ts`, `tests/cli/loader-project-layer.test.ts`, `tests/cli/explain-policy.test.ts`, `tests/cli/doctor-session-start-preflight-setup-version.test.ts`, `tests/cli/session-start/preflight.test.ts`, including four user-visible `describe()` titles) replaced by the tracker id `1c4eb3ea`.** The round-1 fix's lead-in named only `src` files and `docs/CLI.md`, which it did cover completely; these test-file occurrences were a separate, missed set, not an overclaim in that entry.
|
|
179
|
+
- **`docs/CLI.md` now says what an explicit `--project` against a malformed project layer for that name does: hard-fails at doctor's plain top-level `loadManifest(opts)`, never `layer_unresolvable`.** That kind covers only the cwd-derived path; the explicit-flag path was previously undocumented and read as if it produced the same warning.
|
|
180
|
+
- **`docs/CLI.md`'s `projectName` description corrected from "absent" to `null`.** Every doctor-produced finding carries `projectName: null` on the base/machine-decided path (the field is only OMITTED, not `null`, for a direct unit-test call to `checkSessionStartPreflightSetupVersion` that never passes the argument at all); the separate sentence about those direct callers is unchanged.
|
|
181
|
+
- **The machine-layer-failure test in `tests/cli/session-start/preflight.test.ts` now also pins the positive half of its own claim**: the failing MACHINE layer's own path appears in the producer's diagnostic (`readYamlFile`'s error embeds the path it failed to parse, carried through verbatim), not just that the fine project layer's path is not blamed.
|
|
182
|
+
|
|
183
|
+
- **`harness doctor` warns when `session_start_preflight.setup` runs against a `preflight` binary below the build-capable release** (task `6993d9b5`). `--setup`'s conditional build step (see the `30183330` entry above) shipped in `@lannguyensi/agent-preflight@0.6.0` (tag `v0.6.0`, merge commit `2062831`); on `0.5.0`, `--setup` was dependency-install only, so an operator who enabled the knob against that release paid `npm ci` time and the `--setup` trust exposure (`docs/CLI.md`'s SECURITY note) for zero build benefit, with nothing telling them so. The new check (`src/cli/doctor/session-start-preflight-setup-version.ts`) fires only when `session_start_preflight.setup: true` is set, probing the `preflight` binary on `PATH` (`preflight --version`, the same command the `git-preflight` hook template already probes) and comparing it against a hardcoded `0.6.0` floor, INDEPENDENT of the manifest's own `git-preflight` hook `min_version` (below): an existing, previously generated manifest still carries the old `0.2.0` floor until re-applied, so relying on the generic `hooks[]` version check alone would miss exactly the operators this warning targets. Silent when `setup` is off/absent, or when the installed version already meets `0.6.0`; a probe that fails to run or fails to parse a version still warns (fail-loud, not silent), naming the reason. Both this check's floor and the template bump below read one exported constant, `SESSION_START_PREFLIGHT_SETUP_BUILD_MIN_VERSION` (`src/schema/session-start-preflight.ts`), pinned by a test asserting the constant's value so the two cannot drift apart. Superseded by task `65952a0c` (entry above): the shared constant was split into two independent constants, and the value-equality pin was replaced by source-identity pins that discriminate a swap between them.
|
|
184
|
+
- Pinned by `tests/cli/doctor-session-start-preflight-setup-version.test.ts`: the constant pin; silent with `setup` absent/false even against an ancient probed version; warns naming both the installed and required version when `setup: true` and the probe returns `0.5.0`; silent at exactly `0.6.0` and above it (`0.7.1`); `probe_failed`/`parse_failed` warn (not silent) on a probe returning nothing or unparseable output; and end-to-end `doctor()`/`format()` coverage with a fake `preflight` binary on `PATH` for both the warning and the silent cases.
|
|
185
|
+
- Mutation probe: dropping the `compareNumericVersions(actual, required) < 0` comparison (always treating the probed bin as sufficient) makes the "warns naming both versions when setup is true and preflight is below the floor" test fail, since the finding this test asserts is never produced.
|
|
186
|
+
|
|
187
|
+
- **`git-preflight` hook template `min_version` raised `0.2.0` -> `0.6.0`** (task `6993d9b5`, second commit, gated on the agent-preflight `0.6.0` release above). A freshly generated manifest (`harness init` on `FULL_TEMPLATE`; `harness apply` only projects an already-written manifest OUT to `settings.json`, it never generates `harness.yaml`) now floors the `git-preflight` hook's OWN generic `hooks[]` version check (`checkHookVersion`, unrelated to the doctor check above) at `0.6.0` instead of `0.2.0`, so a stale preflight install is flagged on `harness doctor` even for an operator who never sets `session_start_preflight.setup` at all. Existing, previously generated manifests keep their own on-disk `min_version` until regenerated; this bump only changes what a NEW `harness init` writes. The wizard's own `preflight` dependency floor (`src/cli/init/dependencies.ts`, shown in the `harness init` dependency table) now shares the same `SESSION_START_PREFLIGHT_SETUP_BUILD_MIN_VERSION` constant, so the table never advertises `0.2.0+` while the manifest it writes declares `0.6.0`.
|
|
188
|
+
- Pinned by `tests/cli/init-full-template-pins.test.ts` ("git-preflight (agent-preflight) floors at 0.6.0..."), updated from the prior `0.2.0` assertion.
|
|
189
|
+
|
|
190
|
+
### Fixed
|
|
191
|
+
|
|
192
|
+
- **`tests/runtime/hermetic-spawn-allowlist.test.ts`'s three cwd-sensitive D3/F5/F6 probes now skip instead of false-failing inside any checkout whose own root sits under the OS temp directory** (task `9a4a417b`). Those probes need a fixture location outside the D3 `os.tmpdir()` exemption to prove a real, non-exempt spawn is still caught; `agent-primitives probe`'s default isolation copy is the common way to hit this, since it lives under the OS temp directory itself, so a fixture created under the copy's own `process.cwd()` fell under that same exemption and the assertions could never fire there; a Linux `git worktree add /tmp/...` or a CI checkout under `$TMPDIR` skip these three tests under a plain `npm test` the same way. Pass `-l/--log-dir` pointing outside the OS temp directory (or `-i inplace`) to `agent-primitives probe` to keep them running there too. The three probes now detect the missing precondition with the exact `isUnderTmp` helper the guard itself uses and skip, with a named reason, only when it is unmet; unchanged in the real checkout and in a plain `git worktree add --detach` copy, where all tests in the file still run exactly as before. Round 2 (same task): the skip predicate is now pinned by an always-running assertion so a mutant hardcoding it cannot survive; F5/F6a/F6b each also get a location-independent companion assertion that calls the fixed resolver directly (no spawn, so the `os.tmpdir()` exemption never applies to it) and keeps their coverage everywhere, including inside the default isolation copy; the F5 test title's em dash was changed to a colon to match this repo's no-em-dash convention.
|
|
193
|
+
|
|
194
|
+
- **Authority-directory containment is enforced for understanding-gate markers and delegations** (task `6cb8aa2e-083c-4996-bc7e-26f8841b7acf`). Approval, delegation, and adoption-ledger readers now refuse a symlinked or non-directory authority root; `harness doctor` and `harness gc` use the same root rule and do not follow linked `.approvals/`, `.delegations/`, or `.delegation-adoptions/` directories.
|
|
195
|
+
|
|
10
196
|
## [0.56.0] - 2026-09-05
|
|
11
197
|
|
|
12
198
|
### Added
|
package/README.md
CHANGED
|
@@ -224,7 +224,7 @@ the `&`-as-command-boundary trigger fix in `v0.43.0`, per-policy
|
|
|
224
224
|
cross-repository attribution for `${REPO}`/`${BRANCH}` preflight
|
|
225
225
|
facts in `v0.44.0`, and `apply --runtime codex|opencode` next-steps
|
|
226
226
|
hints in `v0.46.0`.
|
|
227
|
-
The current release is `v0.
|
|
227
|
+
The current release is `v0.58.0`.
|
|
228
228
|
|
|
229
229
|
The phase-by-phase plan with acceptance criteria lives in
|
|
230
230
|
[`docs/ROADMAP.md`](docs/ROADMAP.md); what shipped in each version is
|
package/dist/cli/apply/apply.js
CHANGED
|
@@ -366,9 +366,10 @@ export async function apply(opts = {}) {
|
|
|
366
366
|
const lines = packSourceIssues.map((i) => `policy_packs[${i.packIndex}] (${i.packName}).${i.field}: ${i.message}`);
|
|
367
367
|
throw new HarnessExitError(`harness apply: ${lines.length} policy pack issue${lines.length === 1 ? "" : "s"}; run \`harness validate\` for the full report\n${lines.join("\n")}`, EX_FAIL);
|
|
368
368
|
}
|
|
369
|
-
// grounding-mcp policy-degradation gate
|
|
369
|
+
// grounding-mcp policy-degradation gate: a manifest with evidence-consuming
|
|
370
370
|
// policies but no grounding-mcp under tools.mcp deploys policies whose
|
|
371
|
-
//
|
|
371
|
+
// requires evaluation degrades at runtime. operator_only policies never
|
|
372
|
+
// query the ledger and do not need this provider. Since task f1aea826 that means
|
|
372
373
|
// warn policies degrade non-blocking while block/require_approval
|
|
373
374
|
// policies DENY every matching event (deny-degraded) — no longer the
|
|
374
375
|
// pre-0.45 allow-everything footgun, but a config that hard-blocks
|
|
@@ -381,7 +382,7 @@ export async function apply(opts = {}) {
|
|
|
381
382
|
.map((d) => d.message)
|
|
382
383
|
.join("; ")}. This count includes policies derived from workflows[] (a review_subagent step ` +
|
|
383
384
|
`with spawn: "required" before a merge step) as well as hand-authored ones under ` +
|
|
384
|
-
`policies:. Wire grounding-mcp under tools.mcp, or remove the policies (for a derived ` +
|
|
385
|
+
`policies:. Wire grounding-mcp under tools.mcp, or remove the evidence-consuming policies (for a derived ` +
|
|
385
386
|
`policy, set the workflow's spawn to "optional" or "skip" instead). Run \`harness ` +
|
|
386
387
|
"validate` for the full report.", EX_FAIL);
|
|
387
388
|
}
|