@lannguyensi/harness 0.58.1 → 0.59.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/dist/cli/apply/apply.d.ts +56 -2
  4. package/dist/cli/apply/apply.js +227 -13
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/apply/index.d.ts +1 -1
  7. package/dist/cli/apply/index.js +1 -1
  8. package/dist/cli/apply/index.js.map +1 -1
  9. package/dist/cli/apply/install-codex-config.d.ts +25 -0
  10. package/dist/cli/apply/install-codex-config.js +150 -16
  11. package/dist/cli/apply/install-codex-config.js.map +1 -1
  12. package/dist/cli/apply/next-steps.d.ts +17 -0
  13. package/dist/cli/apply/next-steps.js +17 -0
  14. package/dist/cli/apply/next-steps.js.map +1 -1
  15. package/dist/cli/delegate/index.js +11 -5
  16. package/dist/cli/delegate/index.js.map +1 -1
  17. package/dist/cli/doctor/types.d.ts +1 -1
  18. package/dist/cli/index.js +28 -5
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/init/composer.js +1 -0
  21. package/dist/cli/init/composer.js.map +1 -1
  22. package/dist/cli/init/interactive.js +14 -5
  23. package/dist/cli/init/interactive.js.map +1 -1
  24. package/dist/cli/init/profiles.js +1 -0
  25. package/dist/cli/init/profiles.js.map +1 -1
  26. package/dist/cli/init/templates.js +1 -0
  27. package/dist/cli/init/templates.js.map +1 -1
  28. package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
  29. package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
  30. package/dist/cli/pack/hook-codex-pre-tool-use.js +8 -1
  31. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  32. package/dist/cli/pack/hook-post-tool-use.js +16 -4
  33. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  34. package/dist/cli/pack/hook-pre-tool-use.js +11 -1
  35. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  36. package/dist/cli/pack/hook-solution-acceptance.js +24 -2
  37. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  38. package/dist/cli/pack/hook-stay-in-scope.js +7 -5
  39. package/dist/cli/pack/hook-stay-in-scope.js.map +1 -1
  40. package/dist/cli/pack/remove.js +8 -6
  41. package/dist/cli/pack/remove.js.map +1 -1
  42. package/dist/cli/policy/intercept.js +2 -2
  43. package/dist/cli/smoke/index.js +12 -1
  44. package/dist/cli/smoke/index.js.map +1 -1
  45. package/dist/cli/validate/checks.js +3 -3
  46. package/dist/cli/validate/checks.js.map +1 -1
  47. package/dist/io/last-apply.d.ts +1 -0
  48. package/dist/io/last-apply.js +8 -1
  49. package/dist/io/last-apply.js.map +1 -1
  50. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +7 -2
  51. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -1
  52. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +2 -1
  53. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
  54. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
  55. package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
  56. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  57. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +2 -1
  58. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +1 -1
  59. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -1
  60. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +24 -1
  61. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +20 -2
  62. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  63. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +31 -9
  64. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +29 -19
  65. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  66. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +50 -0
  67. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +46 -6
  68. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
  69. package/dist/policy-packs/builtin/understanding-before-execution.js +11 -7
  70. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  71. package/dist/runtime/approval-signing.d.ts +7 -0
  72. package/dist/runtime/approval-signing.js +31 -4
  73. package/dist/runtime/approval-signing.js.map +1 -1
  74. package/dist/runtime/kubectl-target-parse.js +3 -3
  75. package/dist/runtime/kubectl-target-parse.js.map +1 -1
  76. package/dist/runtime/task-providers/agent-tasks.d.ts +15 -2
  77. package/dist/runtime/task-providers/agent-tasks.js +15 -1
  78. package/dist/runtime/task-providers/agent-tasks.js.map +1 -1
  79. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.59.0] - 2026-09-25
11
+
12
+ ### Upgrade notes
13
+
14
+ - Outside `approval_lifecycle: { mode: session }`, a live session approval written by an earlier release carries no task binding and is refused once: run `harness approve understanding` again after upgrading. From now on a session approval stops counting when the agent claims a different task (pre-approve a batch with `--task a b c`). Under `mode: session` nothing changes.
15
+ - Downgrading fails closed: an earlier release treats session and branch-protection markers written by 0.59.0 as forged; re-approve with the older binary after a downgrade.
16
+ - If you keep your own `expire_on_tool_match` list, add `mcp__agent-tasks__task_merge` to it so a review-then-`task_merge` path expires the approval.
17
+ - A plain `harness apply` now reuses the runtime the last apply used (for a record written by an earlier release it is inferred from the generated files and named in the output). If you alternate runtimes, pass `--runtime claude-code` (or `--target`) explicitly.
18
+
19
+ ### Changed
20
+
21
+ - **The understanding gate's approval now expires on the same boundary as the active-claim marker, and the session approval marker is bound to the task it was granted for** (task `5018c0c4`, operator decisions: align, then bind the session marker to the task). Expiry: the PostToolUse expiry hooks (`harness pack hook post-tool-use` and the Codex `codex-post-tool-use` sibling) share `matchPostToolUseBoundary`, which asks the one decider, `claimEffectForAgentTasksTool`, with the event's `tool_response` for every listed agent-tasks claim lifecycle verb (`ACTIVE_CLAIM_TOOL_NAMES`); a "claim kept" answer is not a boundary. `task_finish` to `review` keeps the session and task approval markers; `task_finish` to `done`, `task_abandon`, `task_merge` and `tasks_transition` to `done` expire them; an absent or unreadable `task_finish` result expires (the decider's fail-safe `release`). The former local `tasks_transition` status filter is replaced by the same decider (same outcome), and its skip diagnostic now reads `<tool> keeps the work claim per the active-claim decider`. `pull_requests_merge`, a listed `task_submit_pr` and a listed `task_start` still expire on list membership. Binding: every session marker `writeApprovalMarker` writes (the `harness approve understanding` CLI and the hook's auto-approval path) now records, as a signed `claimTaskId` field, the task id of the active claim at that moment (`null` when none is held); outside `approval_lifecycle: { mode: session }`, the PreToolUse gates (Claude and Codex), the subagent-start in-flight hand-off and `harness delegate`'s parent check accept the session marker only while the current active claim has that same id (`checkSessionApprovalMarker`). Under `mode: session` the binding is not applied (operator decision): the session marker, bound or unbound, counts for every task until `max_age`, as that mode documents; the field is still written, so leaving `mode: session` takes effect on the next check. A different task, or no claim where one was recorded (or the reverse), blocks exactly like a missing marker, with a diagnostic that says the approval belongs to another task (on stderr for the Claude hook, whose stdout deny reason stays the generic one; in the block reason for the Codex hook), and never reads as an expired approval. So, outside `mode: session`, an approval no longer carries over to the next task when the previous one was completed outside the session (merged in the UI or on GitHub, moved to `done` by a webhook), where no PostToolUse hook can expire anything. Resuming the same task id keeps the approval; the task-scoped markers (`--task`, the active-claim auto-resolve) are unchanged. `task_merge` is added to the explicit `expire_on_tool_match` list of every `harness init` scaffold (full, team, and the interactive composer) and of `docs/examples/full-manifest.yaml`: the runtime boundary list comes from that config alone, so without it the review-then-`task_merge` path would never expire the approval in-session. `task_merge` is also in `DEFAULT_BOUNDARY_TOOL_NAMES`, which only shapes the emitted PostToolUse matcher when no explicit list is configured. Pinned by `tests/cli/pack-hook-post-tool-use.test.ts`, `tests/cli/pack-hook-codex-post-tool-use.test.ts`, `tests/cli/pack-hook-session-approval-task-binding.test.ts` and `tests/cli/init-scaffold-approval-expiry.test.ts`, driven by the real content-block PostToolUse capture `tests/fixtures/track-active-claim/real-posttooluse-task-finish-2.1.280.json`. Upgrade notes: outside `mode: session`, a session marker written by an earlier release carries no binding and is not accepted, so a live session needs one fresh `harness approve understanding` after the upgrade, and a session approval stops counting when the agent claims a different task (pre-approve a batch with `--task a b c` to avoid a prompt per task); under `approval_lifecycle: { mode: session }` nothing changes, including the documented residual that an out-of-band completion followed by a `task_start` on another task keeps the approval until `max_age`; operators with their own `expire_on_tool_match` list add `mcp__agent-tasks__task_merge` to it if they merge through `task_merge`. A downgrade to an earlier release fails closed: its binary does not know the signed `claimTaskId` field, so it rejects every session and branch-protection marker written by this release as forged (not merely unapproved); re-approve with the older binary after a downgrade.
22
+
23
+ ### Fixed
24
+
25
+ - **`harness apply` without `--runtime` now reuses the runtime the last apply used instead of silently switching to `claude-code`** (task `b9e6d63c`). After `harness apply --runtime codex`, a plain `harness apply` used to regenerate the runtime-specific files (policy-pack `instructions.md`, `settings.json` instead of `codex/config.toml`) for `claude-code`, report `applied N file(s)`, and neither `--dry-run` nor the apply said the runtime had changed. `.last-apply` now records the runtime (`runtime` field), and apply without `--runtime` (including `--install`) reuses it and prints `runtime: codex (from last apply; pass --runtime to change)`. A `.last-apply` written by an older release has no such field: apply then reads the runtime off its files map (only `codex/config.toml` means codex, only `opencode/opencode.json` opencode, only `settings.json` claude-code) and prints `runtime: codex (inferred from the last apply's generated files; pass --runtime to change)`; when the map holds both `settings.json` and `codex/config.toml` (a machine that applied claude-code and later codex), the recorded policy-pack `instructions.md` entries decide when every one names the same runtime in its `## Runtime` section; when the map is still ambiguous it uses `claude-code` and prints `runtime: claude-code (default; the last apply did not record a runtime and generated files for claude-code and codex; pass --runtime codex to keep codex)`, or `runtime: claude-code (default; the last apply did not record a runtime, pass --runtime to choose)` when the map names no runtime. An explicit `--runtime` that differs from the previous one is honoured, and both `--dry-run` and the apply print `runtime: <old> -> <new> (switching from the last apply's runtime)`. `--target` without `--runtime` implies `claude-code` (it wires Claude Code's settings.json) and prints `runtime: claude-code (implied by --target)`, or `runtime: codex -> claude-code (--target implies claude-code)` after a codex apply; an explicit `--runtime codex` or `--runtime opencode` with `--target` is still refused. `harness smoke` and the `harness init --interactive` claude-code wire step pass `claude-code` explicitly. `harness smoke` keeps the runtime `.last-apply` records (new `apply()` option `preserveRecordedRuntime`): after a codex apply it prints `runtime: claude-code for smoke; the recorded runtime codex is kept, the next harness apply restores it`, and the next plain apply reuses codex and rewrites the codex files. `harness pack remove --force` keeps the recorded runtime when it prunes the pack's entries from `.last-apply` (it used to rebuild the record without it, so the next plain apply fell back to reading the files map). For an older record the `instructions.md` entries count only for the packs its own manifest snapshot lists (enabled ones), so an entry left over from a pack a later apply no longer had does not decide the runtime; without a readable snapshot every entry counts. An `--install` refusal for an inferred runtime says `inferred from the last apply's generated files` instead of `reused from the last apply`. With no `.last-apply` the default stays `claude-code`; an apply that changes nothing else stamps the runtime into a record that lacks it. A non-string `runtime` in `.last-apply` is ignored like an unknown value instead of failing every reader of the record. The `--json` result carries `runtime`, `runtimeSource`, `previousRuntime` and, for an ambiguous older record, `runtimeCandidates`. `harness diff --since-apply` is unchanged: it compares `.last-apply` against disk and never regenerates, so it has no runtime to select. Pinned by `tests/cli/apply/apply-runtime-reuse.test.ts` (with a fixture of the merged older record's shape), `tests/cli/smoke/smoke.test.ts` and `tests/cli/pack.test.ts`.
26
+
27
+ - **`harness apply --runtime codex --install` keeps a symlinked (dotfiles-managed) `~/.codex/config.toml` a symlink** (task `1637fbc8`). The install wrote through `atomicWriteFile`, whose temp-file rename replaced the link with a regular file and left the link's target (typically a dotfiles repo) unchanged, so the two silently diverged. `planCodexConfigInstall` now resolves a symlinked config path with `realpath` and plans against the resolved regular file: the atomic write's temp file is created in the target's own directory (so the rename stays on one filesystem), the target's file mode is preserved, and every link in the chain is left untouched. The backup holds the target's previous content with the target's mode but sits beside the link (for example `~/.codex/config.toml.harness-backup-<timestamp>`), never beside the target: the config holds secrets, and the target's directory is typically a dotfiles git work tree. The plan, the `--install` result and the `--json` outcome report the real file written as `configPath` (also shown by `--dry-run`) and the link as `linkPath` (absent for a regular file); the summary and the `harness init` wizard's install line add `(via symlink <link>)`, and a refusal that points at the `.harness-backup-*` files names the link's backup location. A dangling link (the refusal names the missing end of the chain), an unresolvable chain (a loop, a permission error) or a link to anything other than a regular file (such as a directory) is refused with a `CodexInstallRefusalError` naming the link, and nothing is written. `atomicWriteFile` itself and non-symlink config paths (including a regular config inside a symlinked `~/.codex` directory) are unchanged. Pinned by fixtures in `tests/cli/apply/apply-codex-runtime.test.ts` and `tests/cli/init-interactive.test.ts`.
28
+
29
+ ## [0.58.2] - 2026-09-24
30
+
31
+ ### Fixed
32
+
33
+ - **`scripts/check-changelog-coverage.mjs`, `check-no-only.mjs`, `check-readme-release-version.mjs`, `check-release-notes-size.mjs` and `check-ug-schema-drift.mjs` now each have a CLI-spawn smoke test pinning their direct-run guard and exit-code wiring** (task `3aa81802`). Each script already exported testable pure functions (and, for the first four, an exported `main(repoDir)` covered in-process), but importing a module from a test never sets `isDirectRun` true, so nothing exercised the top-level `if (isDirectRun) { main(...); }` guard or, for `check-ug-schema-drift.mjs`, the `.catch()` handler's `process.exit(2)` wiring: the same untested shape `check-shipped-unreleased-pointer.mjs` had before task `ba7958e1`'s fix. Each of the five scripts now has a real green and a real red spawn fixture (`node scripts/<name>.mjs` as a child process) asserting the process's own exit code and stdout/stderr, so a mutant disabling the guard or forcing the exit code to 0 fails a test instead of letting CI print nothing and pass silently. For the first four scripts these fixtures never depend on the live repo's own state or the network. `check-ug-schema-drift.mjs`'s fixtures cover the OK exit, the drift-detected exit, the zero-keys extraction-failure exit and the missing-mirror build hint through the top-level catch, and are hermetic against the network and the live repo: a fake `npm` binary on the spawned process's own PATH supplies the upstream tarball, so no registry is contacted, while the live comparison against the published package stays in CI's own `check:ug-schema-drift` step.
34
+
35
+ - **`harness apply --runtime codex --install` no longer creates and announces an empty backup file when no `~/.codex/config.toml` existed before the install** (task `461ec064`). `planCodexConfigInstall` now records whether the config path already held a file (`configExisted`, distinguishing "no prior file" from "an existing, possibly empty, prior file"), and `writeCodexConfigInstall` skips the `.harness-backup-*` write entirely when it did not, leaving `backupPath` unset on the result; the CLI's applied-output line and `formatNextSteps`'s next-steps lede were already gated on `backupPath` being present, so a fresh install now reports the write with no backup line. Installing over an existing config, including one that is empty, still backs it up unchanged (pinned by its own test), and the install refusal messages now say to restore from the most recent `.harness-backup-*` file only if one exists. Pinned by a fresh-install test in `tests/cli/apply/apply-codex-runtime.test.ts` for the library result (no `backupPath`, no `.harness-backup-*` sibling file) and for the CLI's applied output and next-steps text (no backup line), both failing at the pre-fix implementation and passing at head.
36
+
37
+ - **The solution-acceptance completion-gate's deny text no longer names `solution_evaluate` as the next action when an attempt for the same id is already live** (task `58c65bc9`). The converge list's step 2 previously read `mcp__grounding-mcp__solution_evaluate({ id: "<id>" })` for every null-verdict reading, including reading (2) (an attempt is live), sitting directly above the reconnect paragraph's own "do not call it again" / "Never re-call `solution_evaluate`" sentences appended right below it for that same reading, so the agent-facing deny disagreed with itself on the one action it named twice. `convergeStep2For` in `src/cli/pack/hook-solution-acceptance.ts` now renders step 2 as a function of the reading: readings (1) (never evaluated) and (3) (an unreadable marker), and every non-null-verdict deny, keep the unqualified `solution_evaluate` step (wording unchanged apart from a colon in place of a dash); reading (2) instead names `solution_evaluate_status` / `solution_evaluate_result` (the same poll tools the reconnect paragraph itself names) and points at the reconnect paragraph, with no `solution_evaluate(` call instruction anywhere in that reading's deny text. `solution-acceptance-reconnect.ts` and its rendered `instructions.md` "Reconnecting vs. retrying" section are unchanged. Pinned by a dedicated test asserting the reading-(2) deny carries no `solution_evaluate(` call instruction and that its step 2 itself names both poll tools, plus assertions on the reading-(1) and reading-(3) fixtures that step 2 still names `solution_evaluate` unqualified.
38
+
39
+ - `harness apply --runtime codex --install`'s split-block refusal now always names the actual foreign table header, including when the table is wedged directly between a `# harness hook:` comment and the `[[hooks.*]]` table that comment belongs to (previously printed `(unknown)` in that shape, because the owned-region scan had already backed the zone start off to only blank or comment lines preceding the found marker, so the pre-marker header lookup found no content line there; it now also looks just past the marker's own line when that happens). Covered for a table wedged after a lone `# harness hook:` comment and for a second hook's comment directly following the first hook's content line. Safety was unaffected in every case (refusal still fires, exit `EX_FAIL`, file left untouched, no secret value ever echoed, now also pinned for this wedged-after-comment shape in the no-echo matrix): only the naming in the message text was wrong. (task `25a4c53f`)
40
+
41
+ - **The stay-in-scope hook's audit row and reminder now carry the created task id under the real Claude Code MCP `tool_response` shape** (task `b5e65f5e`). `extractTaskId` in `src/cli/pack/hook-stay-in-scope.ts` used to read `tool_response.task.id` and `tool_response.id` as a plain object only, but Claude Code sends MCP `tool_response` as a content-block array (`[{ type: "text", text: "<json>" }]`); the watched tool is a create verb without a `taskId` input, so the audit row's `taskId` was always `null` and the reminder omitted `task=<id>`. It now reuses the shared unwrap already pinned for the active-claim tracker (`taskIdFromToolResponse`, `src/runtime/task-providers/agent-tasks.ts`) instead of a second, plain-object-only parser; the plain-object shape stays supported through the same unwrap. The bare top-level `tool_response.id` fallback (never a real Claude Code or Codex shape) is dropped: only `task.id` is read now. Pinned by a real-shape fixture (`tests/fixtures/pack-hook-stay-in-scope/real-posttooluse-task-create-2.1.280.json`, shaped like the genuine capture `tests/fixtures/track-active-claim/real-posttooluse-task-finish-2.1.280.json`) that fails at the pre-fix implementation and passes at head, asserting both the audit row and the reminder text; `agent-primitives probe --pre 'npm run build'` on the hook's test file kills both reverting to the plain-object-only read and computing the reminder note through that old read alone.
42
+
43
+ - **Four shipped source comments citing this changelog's now-emptied post-release-cut section pointed at nothing, once the entries they described shipped in 0.49.0** (task `ba7958e1`): `src/cli/policy/intercept.ts`, `src/runtime/kubectl-target-parse.ts`, `src/cli/doctor/types.ts` and `src/cli/validate/checks.ts` now cite the shift-proof `CHANGELOG.md:#0.49.0` anchor instead, matching the convention already used elsewhere (e.g. `src/cli/approve/understanding.ts`). A new `check:shipped-unreleased-pointer` gate (`scripts/check-shipped-unreleased-pointer.mjs`, wired into CI right after Changelog coverage) scans every shipped file (`src/`, README.md, `scripts/runtime-reality-docker-probe.mjs`) for a dangling pointer to that section, unconditionally, so a new one cannot land again; this changelog's own file is excluded from the scan (its history legitimately narrates past states in prose; see the script's own header for the measured false positive that drove that exclusion).
44
+
45
+ - **`harness apply --runtime codex --install`'s next-steps hint no longer says "Nothing is installed into Codex yet" right after a successful install** (task `dd7a3f23`). `formatNextSteps` (`src/cli/apply/next-steps.ts`) previously decided the Codex lede from `anyChanged` (whether the generated files changed), never from the install outcome itself, so the always-"nothing installed" text survived a real write. The CLI (`src/cli/index.ts`) now passes the `--install` result (`codexConfigInstall.configPath`, `.written`, `.backupPath`) through as a new optional `codexInstall` field on `NextStepsContext`; when present the hint reports what was installed, where, and the backup path on a write, or states the config already has the harness-managed block on a no-op install (config already current), and neither branch claims a write that did not happen. Without `--install` the text is unchanged. Pinned by formatter tests in `tests/cli/apply/next-steps.test.ts` and CLI-level tests in `tests/cli/apply/apply-codex-runtime.test.ts`. Mutation probe (`agent-primitives probe --pre 'npm run build'`) collapsing the install branch back to the plain generated text killed by the new tests.
46
+
10
47
  ## [0.58.1] - 2026-09-23
11
48
 
12
49
  ### Fixed
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.58.1`.
227
+ The current release is `v0.59.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
@@ -61,8 +61,11 @@ export interface ApplyOptions {
61
61
  /**
62
62
  * Phase 6 #6 — `--runtime <claude-code|codex>`. Selects which adapter
63
63
  * shape policy-pack hooks expand into and which artefacts apply
64
- * writes. Defaults to `claude-code` (settings.json output unchanged
65
- * from previous releases). When set to `codex`, settings.json is NOT
64
+ * writes. When omitted, `target` implies `claude-code`; otherwise the
65
+ * runtime recorded in `.last-apply` by the previous apply is reused
66
+ * (inferred from its files map for a record that predates the field);
67
+ * with no recorded or inferable runtime it defaults to `claude-code`
68
+ * (settings.json output unchanged from previous releases). When set to `codex`, settings.json is NOT
66
69
  * written; instead `harness.generated/codex/config.toml` carries the
67
70
  * Codex adapter configuration. The two runtimes are mutually
68
71
  * exclusive in a single apply for v1; cross-runtime applies are a
@@ -81,6 +84,18 @@ export interface ApplyOptions {
81
84
  now?: Date;
82
85
  /** Test-injectable confirmation prompt; defaults to a stdin readline. */
83
86
  prompt?: (message: string) => Promise<string>;
87
+ /**
88
+ * Keep the runtime `.last-apply` records instead of recording this
89
+ * apply's runtime. For a diagnostic apply (`harness smoke`) that must
90
+ * run under a fixed runtime without changing the operator's runtime
91
+ * selection: the files map is still updated, so the next plain apply
92
+ * reuses the kept runtime and rewrites that runtime's files back. A
93
+ * record without a runtime field gets the runtime inferred from it
94
+ * stamped instead (this apply's own files would otherwise change what
95
+ * the next apply infers); with nothing recorded or inferable the field
96
+ * stays absent.
97
+ */
98
+ preserveRecordedRuntime?: boolean;
84
99
  }
85
100
  export type ApplyOutcome = "no-changes" | "applied" | "drift-refuse" | "drift-discarded" | "would-apply" | "lock-drift-refuse" | "target-exists-refuse";
86
101
  export interface FileApplyOutcome {
@@ -93,7 +108,12 @@ export interface FileApplyOutcome {
93
108
  changed: boolean;
94
109
  }
95
110
  export interface CodexConfigInstallOutcome {
111
+ /** The regular file the install read and wrote; for a symlinked config,
112
+ * the link's resolved target (task 1637fbc8). */
96
113
  configPath: string;
114
+ /** The requested config path when it is a symlink, absent otherwise
115
+ * (task 1637fbc8). A backup of a symlinked config sits beside it. */
116
+ linkPath?: string;
97
117
  changed: boolean;
98
118
  written: boolean;
99
119
  summary: string;
@@ -152,7 +172,41 @@ export interface ApplyResult {
152
172
  targetMergeSummary?: string;
153
173
  /** Present when --runtime codex --install was requested. */
154
174
  codexConfigInstall?: CodexConfigInstallOutcome;
175
+ /** Adapter runtime this apply generated (or would generate) for. */
176
+ runtime: Runtime;
177
+ /**
178
+ * Where `runtime` came from: `explicit` (`--runtime` was passed),
179
+ * `target` (no `--runtime`, `--target` implies `claude-code`),
180
+ * `last-apply` (reused from the runtime `.last-apply` recorded),
181
+ * `inferred` (reused from a `.last-apply` that predates the field, read
182
+ * off its files map), `unrecorded` (such a record whose files map names
183
+ * no single runtime, so the default is used), or `default` (no
184
+ * `.last-apply` at all).
185
+ */
186
+ runtimeSource: RuntimeSource;
187
+ /**
188
+ * The runtime the previous apply recorded in `.last-apply`, or inferred
189
+ * from its files map when it predates the field. Absent when there is
190
+ * none.
191
+ */
192
+ previousRuntime?: Runtime;
193
+ /**
194
+ * `unrecorded` only: the runtimes the record's files map names (adapter
195
+ * artefacts and policy-pack instructions.md `## Runtime` sections) when
196
+ * they do not settle on one. Absent when the map names none.
197
+ */
198
+ runtimeCandidates?: Runtime[];
155
199
  }
200
+ export type RuntimeSource = "explicit" | "target" | "last-apply" | "inferred" | "unrecorded" | "default";
201
+ /**
202
+ * One line naming the runtime whenever it did not come from an explicit
203
+ * `--runtime` matching the last apply: a reused or inferred runtime, the
204
+ * default used for a record without one, the runtime `--target` implies,
205
+ * or an explicit switch away from the previous one. `null` when there is
206
+ * nothing to say (first apply, or an explicit `--runtime` equal to the
207
+ * previous one).
208
+ */
209
+ export declare function formatRuntimeLine(result: Pick<ApplyResult, "runtime" | "runtimeSource" | "previousRuntime" | "runtimeCandidates">): string | null;
156
210
  declare const DRIFT_HINT_MESSAGE = "run \"harness adopt <file>\" to capture changes, or re-run with --overwrite-drift to discard them";
157
211
  export declare function apply(opts?: ApplyOptions): Promise<ApplyResult>;
158
212
  export { DRIFT_HINT_MESSAGE };
@@ -37,7 +37,7 @@ import { unifiedDiff } from "../../io/patch.js";
37
37
  import { emitRestartHints } from "../../io/restart-hints.js";
38
38
  import { compare } from "../../io/three-state.js";
39
39
  import { reportsDirForManifest } from "../../policy-packs/builtin/understanding-before-execution-runtime.js";
40
- import { checkPolicyPackSources, expandPolicyPacks, DEFAULT_RUNTIME, } from "../../policy-packs/index.js";
40
+ import { checkPolicyPackSources, expandPolicyPacks, DEFAULT_RUNTIME, KNOWN_RUNTIMES, isRuntime, } from "../../policy-packs/index.js";
41
41
  import { parseManifest } from "../../schema/index.js";
42
42
  import { withDerivedPolicies, withoutDerivedPolicies } from "../../runtime/workflow-policies.js";
43
43
  import { checkPolicyGroundingMcp } from "../validate/checks.js";
@@ -57,6 +57,183 @@ export const MEMORY_BASENAME = "MEMORY.md";
57
57
  export const MANIFEST_BASENAME = "harness.yaml";
58
58
  export const CODEX_CONFIG_BASENAME = "codex/config.toml";
59
59
  export const OPENCODE_CONFIG_BASENAME = "opencode/opencode.json";
60
+ /**
61
+ * One line naming the runtime whenever it did not come from an explicit
62
+ * `--runtime` matching the last apply: a reused or inferred runtime, the
63
+ * default used for a record without one, the runtime `--target` implies,
64
+ * or an explicit switch away from the previous one. `null` when there is
65
+ * nothing to say (first apply, or an explicit `--runtime` equal to the
66
+ * previous one).
67
+ */
68
+ export function formatRuntimeLine(result) {
69
+ if (result.runtimeSource === "last-apply") {
70
+ return `runtime: ${result.runtime} (from last apply; pass --runtime to change)`;
71
+ }
72
+ if (result.runtimeSource === "inferred") {
73
+ return `runtime: ${result.runtime} (inferred from the last apply's generated files; pass --runtime to change)`;
74
+ }
75
+ if (result.runtimeSource === "unrecorded") {
76
+ const candidates = result.runtimeCandidates ?? [];
77
+ const others = candidates.filter((r) => r !== result.runtime);
78
+ if (others.length > 0) {
79
+ const keep = others.length === 1 ? others[0] : "one of them";
80
+ return `runtime: ${result.runtime} (default; the last apply did not record a runtime and generated files for ${joinRuntimes(candidates)}; pass --runtime ${others.join(" or --runtime ")} to keep ${keep})`;
81
+ }
82
+ return `runtime: ${result.runtime} (default; the last apply did not record a runtime, pass --runtime to choose)`;
83
+ }
84
+ if (result.runtimeSource === "target") {
85
+ if (result.previousRuntime !== undefined && result.previousRuntime !== result.runtime) {
86
+ return `runtime: ${result.previousRuntime} -> ${result.runtime} (--target implies claude-code)`;
87
+ }
88
+ return `runtime: ${result.runtime} (implied by --target)`;
89
+ }
90
+ if (result.runtimeSource === "explicit" &&
91
+ result.previousRuntime !== undefined &&
92
+ result.previousRuntime !== result.runtime) {
93
+ return `runtime: ${result.previousRuntime} -> ${result.runtime} (switching from the last apply's runtime)`;
94
+ }
95
+ return null;
96
+ }
97
+ function joinRuntimes(runtimes) {
98
+ if (runtimes.length <= 1)
99
+ return runtimes.join("");
100
+ return `${runtimes.slice(0, -1).join(", ")} and ${runtimes[runtimes.length - 1]}`;
101
+ }
102
+ const PACK_INSTRUCTIONS_KEY_RE = /^policy-packs\/([^/]+)\/instructions\.md$/;
103
+ // Every builtin pack's instructions.md opens its body with this section and
104
+ // names the runtime it was generated for as the first word, e.g.
105
+ // "## Runtime\n\ncodex" or "## Runtime\n\ncodex (UNSUPPORTED ...)".
106
+ const RUNTIME_SECTION_RE = /^## Runtime\n\n([a-z-]+)/m;
107
+ // The policy packs the last apply generated: the enabled `policy_packs[]`
108
+ // names in the record's own manifest snapshot. `undefined` when the record
109
+ // has no snapshot or it does not parse; the caller then counts every
110
+ // recorded pack entry.
111
+ function snapshotPackNames(record) {
112
+ const content = record.manifest?.content;
113
+ if (content === undefined)
114
+ return undefined;
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(content);
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ const packs = parsed?.policy_packs;
123
+ if (!Array.isArray(packs))
124
+ return undefined;
125
+ const names = new Set();
126
+ for (const pack of packs) {
127
+ if (pack === null || typeof pack !== "object")
128
+ continue;
129
+ const { name, enabled } = pack;
130
+ if (typeof name === "string" && enabled !== false)
131
+ names.add(name);
132
+ }
133
+ return names;
134
+ }
135
+ // What the recorded policy-pack instructions.md entries name in their
136
+ // `## Runtime` section. With a readable manifest snapshot only the packs
137
+ // it lists count: the map keeps entries of packs a later apply no longer
138
+ // had, and those still name the runtime of an older apply.
139
+ function packInstructionsRuntimes(record) {
140
+ const listed = snapshotPackNames(record);
141
+ const named = new Set();
142
+ let unreadable = false;
143
+ for (const [key, entry] of Object.entries(record.files)) {
144
+ const pack = PACK_INSTRUCTIONS_KEY_RE.exec(key)?.[1];
145
+ if (pack === undefined)
146
+ continue;
147
+ if (listed !== undefined && !listed.has(pack))
148
+ continue;
149
+ const runtime = RUNTIME_SECTION_RE.exec(entry.content)?.[1];
150
+ if (isRuntime(runtime))
151
+ named.add(runtime);
152
+ else
153
+ unreadable = true;
154
+ }
155
+ return { named, unreadable };
156
+ }
157
+ // The runtime every recorded pack instructions.md entry agrees on, or
158
+ // `undefined` when there is none, one is unreadable, or two disagree.
159
+ function agreedPackRuntime(packs) {
160
+ if (packs.unreadable || packs.named.size !== 1)
161
+ return undefined;
162
+ return [...packs.named][0];
163
+ }
164
+ function soleAdapterRuntime(found) {
165
+ return found.length === 1 ? found[0] : undefined;
166
+ }
167
+ // A `.last-apply` written before the `runtime` field existed still names
168
+ // the runtime through its files map: each runtime writes its own adapter
169
+ // artefact (`settings.json`, `codex/config.toml`, `opencode/opencode.json`).
170
+ // Exactly one of them present means that runtime. The map keeps entries
171
+ // from earlier applies, so a machine that applied claude-code and later
172
+ // codex carries both adapter artefacts; the recorded policy-pack
173
+ // instructions.md entries then settle it, since each apply rewrites them
174
+ // with the runtime it generated for (only the packs the record's manifest
175
+ // snapshot lists, when it has a readable one). They count only when every
176
+ // entry names the same runtime and that runtime is one of the adapter
177
+ // artefacts' (or there are none); anything else is ambiguous.
178
+ function inferRuntimeFromFiles(record) {
179
+ const { files } = record;
180
+ const has = (key) => Object.prototype.hasOwnProperty.call(files, key);
181
+ const found = [];
182
+ if (has(SETTINGS_BASENAME))
183
+ found.push("claude-code");
184
+ if (has(CODEX_CONFIG_BASENAME))
185
+ found.push("codex");
186
+ if (has(OPENCODE_CONFIG_BASENAME))
187
+ found.push("opencode");
188
+ const packs = packInstructionsRuntimes(record);
189
+ const candidates = KNOWN_RUNTIMES.filter((r) => found.includes(r) || packs.named.has(r));
190
+ const fromAdapter = soleAdapterRuntime(found);
191
+ if (fromAdapter !== undefined)
192
+ return { runtime: fromAdapter, candidates };
193
+ const fromPacks = agreedPackRuntime(packs);
194
+ if (fromPacks !== undefined && (found.length === 0 || found.includes(fromPacks))) {
195
+ return { runtime: fromPacks, candidates };
196
+ }
197
+ return { candidates };
198
+ }
199
+ // `--runtime` omitted: reuse the runtime the previous apply recorded, so a
200
+ // plain `harness apply` after `harness apply --runtime codex` keeps
201
+ // generating the Codex variant instead of silently switching every
202
+ // runtime-specific file back to the default (agent-tasks b9e6d63c).
203
+ // `--target` wires Claude Code's settings.json, so it implies claude-code
204
+ // instead of reusing another runtime. A record without the field (or with
205
+ // an unknown value) falls back to inferring from its files map, then to
206
+ // the default; the caller names every non-explicit choice in its output.
207
+ function selectRuntime(explicit, lastApply, hasTarget) {
208
+ const recorded = lastApply?.runtime;
209
+ const recordedRuntime = isRuntime(recorded) ? recorded : undefined;
210
+ const inference = recordedRuntime === undefined && lastApply !== null
211
+ ? inferRuntimeFromFiles(lastApply)
212
+ : undefined;
213
+ const inferredRuntime = inference?.runtime;
214
+ const previousRuntime = recordedRuntime ?? inferredRuntime;
215
+ const base = previousRuntime !== undefined ? { previousRuntime } : {};
216
+ if (explicit !== undefined) {
217
+ return { runtime: explicit, runtimeSource: "explicit", ...base };
218
+ }
219
+ if (hasTarget) {
220
+ return { runtime: "claude-code", runtimeSource: "target", ...base };
221
+ }
222
+ if (inferredRuntime !== undefined) {
223
+ return { runtime: inferredRuntime, runtimeSource: "inferred", ...base };
224
+ }
225
+ if (previousRuntime !== undefined) {
226
+ return { runtime: previousRuntime, runtimeSource: "last-apply", ...base };
227
+ }
228
+ if (lastApply !== null) {
229
+ const runtimeCandidates = inference?.candidates ?? [];
230
+ if (runtimeCandidates.length > 0) {
231
+ return { runtime: DEFAULT_RUNTIME, runtimeSource: "unrecorded", runtimeCandidates };
232
+ }
233
+ return { runtime: DEFAULT_RUNTIME, runtimeSource: "unrecorded" };
234
+ }
235
+ return { runtime: DEFAULT_RUNTIME, runtimeSource: "default" };
236
+ }
60
237
  const DRIFT_HINT_MESSAGE = 'run "harness adopt <file>" to capture changes, or re-run with --overwrite-drift to discard them';
61
238
  // Pinned to the same literal the Codex generator emits as line 1.
62
239
  // Single source of truth lives in generate-codex-config.ts so a future
@@ -110,6 +287,7 @@ function serializeJson(obj) {
110
287
  function codexInstallOutcome(result) {
111
288
  return {
112
289
  configPath: result.configPath,
290
+ ...(result.linkPath !== undefined ? { linkPath: result.linkPath } : {}),
113
291
  changed: result.changed,
114
292
  written: "written" in result ? result.written : false,
115
293
  summary: result.summary,
@@ -187,8 +365,10 @@ function buildExpectedFiles(manifest, opts, manifestPath, generatedDir) {
187
365
  if (runtime === "codex") {
188
366
  // Codex apply: emit Codex config artefact instead of settings.json.
189
367
  // settings.json is Claude Code's contract and meaningless to Codex.
190
- // MEMORY.md and pack instructions.md are runtime-agnostic and ship
191
- // unchanged.
368
+ // MEMORY.md is runtime-agnostic and ships unchanged. Pack
369
+ // instructions.md ships too, but its content differs per runtime
370
+ // (builtin packs' buildInstructions() branch on the runtime), which is
371
+ // why a re-apply under another runtime rewrites it.
192
372
  const codexConfig = generateCodexConfig(augmentedManifest);
193
373
  const codexWarnings = [...codexConfig.warnings];
194
374
  if (packExpansion.permissions) {
@@ -320,7 +500,7 @@ function recoverMissingLastApplyContent(basename, onDisk) {
320
500
  return null;
321
501
  return onDisk.startsWith(CODEX_GENERATED_HEADER) ? onDisk : null;
322
502
  }
323
- function buildMergedLastApplyRecord(expected, previous, manifest, memoryDirSnapshots) {
503
+ function buildMergedLastApplyRecord(expected, previous, manifest, memoryDirSnapshots, runtime) {
324
504
  const next = buildLastApply(Object.fromEntries(expected.map((f) => [f.basename, f.content])));
325
505
  if (previous !== null) {
326
506
  for (const [relPath, entry] of Object.entries(previous.files)) {
@@ -340,10 +520,13 @@ function buildMergedLastApplyRecord(expected, previous, manifest, memoryDirSnaps
340
520
  if (Object.keys(memoryDirSnapshots).length > 0) {
341
521
  next.memoryDirs = memoryDirSnapshots;
342
522
  }
523
+ // Absent only for `preserveRecordedRuntime` over a record with no
524
+ // recorded or inferable runtime: the field then stays absent.
525
+ if (runtime !== undefined)
526
+ next.runtime = runtime;
343
527
  return next;
344
528
  }
345
529
  export async function apply(opts = {}) {
346
- const runtime = opts.runtime ?? DEFAULT_RUNTIME;
347
530
  const manifestPath = resolveManifestPath(opts);
348
531
  if (!fs.existsSync(manifestPath)) {
349
532
  throw new HarnessExitError(`harness manifest not found at ${manifestPath}; run \`harness init\` first`, EX_NOINPUT);
@@ -388,10 +571,28 @@ export async function apply(opts = {}) {
388
571
  `policy, set the workflow's spawn to "optional" or "skip" instead). Run \`harness ` +
389
572
  "validate` for the full report.", EX_FAIL);
390
573
  }
391
- const { files: expected, warnings } = buildExpectedFiles(manifest, opts, manifestPath, generatedDir);
392
574
  const lastApply = readLastApply(generatedDir);
575
+ const runtimeInfo = selectRuntime(opts.runtime, lastApply, Boolean(opts.target));
576
+ const { runtime } = runtimeInfo;
577
+ const { files: expected, warnings } = buildExpectedFiles(manifest, { ...opts, runtime }, manifestPath, generatedDir);
578
+ // The runtime this apply records: its own, or with
579
+ // `preserveRecordedRuntime` the previous one (recorded or inferred, else
580
+ // whatever the field held), so a diagnostic apply leaves the operator's
581
+ // runtime selection alone.
582
+ const recordRuntime = opts.preserveRecordedRuntime
583
+ ? (runtimeInfo.previousRuntime ?? lastApply?.runtime)
584
+ : runtime;
585
+ // The recorded runtime is missing or differs from the one to record:
586
+ // persist it on the next .last-apply write even when no generated file
587
+ // changed, so the next plain apply reuses it.
588
+ const runtimeRecordStale = lastApply !== null && recordRuntime !== undefined && lastApply.runtime !== recordRuntime;
589
+ const reusedHint = runtimeInfo.runtimeSource === "last-apply" || runtimeInfo.runtimeSource === "inferred"
590
+ ? ` (runtime ${runtime} ${runtimeInfo.runtimeSource === "inferred" ? "inferred from the last apply's generated files" : "reused from the last apply"}; pass --runtime codex to change)`
591
+ : runtimeInfo.runtimeSource === "target"
592
+ ? " (--target implies claude-code)"
593
+ : "";
393
594
  if (opts.installCodex && runtime !== "codex") {
394
- throw new HarnessExitError("--install requires --runtime codex", EX_NOINPUT);
595
+ throw new HarnessExitError(`--install requires --runtime codex${reusedHint}`, EX_NOINPUT);
395
596
  }
396
597
  // Asset-content drift detection (Phase 3 #6): if a previous apply wrote
397
598
  // harness.lock, re-hash every locked asset / memory-dir Merkle and report
@@ -419,6 +620,7 @@ export async function apply(opts = {}) {
419
620
  return {
420
621
  manifestPath,
421
622
  generatedDir,
623
+ ...runtimeInfo,
422
624
  files: [],
423
625
  warnings,
424
626
  restartHints: [],
@@ -440,15 +642,16 @@ export async function apply(opts = {}) {
440
642
  // Phase 6 #6: --target wires the generated settings.json into a
441
643
  // Claude Code path. The runtime=codex branch does not produce
442
644
  // settings.json at all, so the combination is incoherent. Reject
443
- // early instead of writing a half-broken state.
444
- if (targetPath && opts.runtime === "codex") {
645
+ // early instead of writing a half-broken state. Only an explicit
646
+ // --runtime reaches this: without one, --target implies claude-code.
647
+ if (targetPath && runtime === "codex") {
445
648
  throw new HarnessExitError("--target is incompatible with --runtime codex (target wires Claude Code's settings.json)", EX_NOINPUT);
446
649
  }
447
650
  // Batch 18 / task f34eb233: same incoherence for --runtime opencode --
448
651
  // see generate-opencode-config.ts's header for why it does not produce
449
652
  // settings.json (or write into any operator-owned opencode config at
450
653
  // all). Reject symmetrically to the codex branch above.
451
- if (targetPath && opts.runtime === "opencode") {
654
+ if (targetPath && runtime === "opencode") {
452
655
  throw new HarnessExitError("--target is incompatible with --runtime opencode (target wires Claude Code's settings.json)", EX_NOINPUT);
453
656
  }
454
657
  let targetContent;
@@ -519,6 +722,7 @@ export async function apply(opts = {}) {
519
722
  return {
520
723
  manifestPath,
521
724
  generatedDir,
725
+ ...runtimeInfo,
522
726
  files: [],
523
727
  warnings,
524
728
  restartHints: [],
@@ -603,6 +807,7 @@ export async function apply(opts = {}) {
603
807
  const result = {
604
808
  manifestPath,
605
809
  generatedDir,
810
+ ...runtimeInfo,
606
811
  files: fileOutcomes,
607
812
  warnings,
608
813
  restartHints: [],
@@ -637,6 +842,7 @@ export async function apply(opts = {}) {
637
842
  const result = {
638
843
  manifestPath,
639
844
  generatedDir,
845
+ ...runtimeInfo,
640
846
  files: fileOutcomes,
641
847
  warnings,
642
848
  restartHints: [],
@@ -662,6 +868,7 @@ export async function apply(opts = {}) {
662
868
  const result = {
663
869
  manifestPath,
664
870
  generatedDir,
871
+ ...runtimeInfo,
665
872
  files: fileOutcomes,
666
873
  warnings,
667
874
  restartHints,
@@ -704,11 +911,17 @@ export async function apply(opts = {}) {
704
911
  ...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
705
912
  ...(opts.project !== undefined ? { projectName: opts.project } : {}),
706
913
  }));
707
- writeLastApply(generatedDir, buildMergedLastApplyRecord(expected, lastApply, manifest, refreshedMemoryDirs));
914
+ writeLastApply(generatedDir, buildMergedLastApplyRecord(expected, lastApply, manifest, refreshedMemoryDirs, recordRuntime));
915
+ }
916
+ else if (runtimeRecordStale) {
917
+ // Nothing else to refresh: only stamp the runtime so the next plain
918
+ // apply reuses it.
919
+ writeLastApply(generatedDir, { ...lastApply, runtime: recordRuntime });
708
920
  }
709
921
  const result = {
710
922
  manifestPath,
711
923
  generatedDir,
924
+ ...runtimeInfo,
712
925
  files: fileOutcomes,
713
926
  warnings,
714
927
  restartHints,
@@ -772,14 +985,15 @@ export async function apply(opts = {}) {
772
985
  codexInstallResult = { ...codexInstallPlan, written: false };
773
986
  }
774
987
  const lockEntriesWithTarget = appendTargetEntry(lockEntries, targetPath, targetContent, previousLock);
775
- if (anyChanged || recoveredMissingLastApply) {
988
+ if (anyChanged || recoveredMissingLastApply || runtimeRecordStale) {
776
989
  const memoryDirSnapshots = collectMemoryDirSnapshots(lockEntries);
777
- writeLastApply(generatedDir, buildMergedLastApplyRecord(expected, lastApply, manifest, memoryDirSnapshots));
990
+ writeLastApply(generatedDir, buildMergedLastApplyRecord(expected, lastApply, manifest, memoryDirSnapshots, recordRuntime));
778
991
  }
779
992
  writeLock(lockPath, lockEntriesWithTarget);
780
993
  const result = {
781
994
  manifestPath,
782
995
  generatedDir,
996
+ ...runtimeInfo,
783
997
  files: fileOutcomes,
784
998
  warnings,
785
999
  restartHints,