@lannguyensi/harness 0.58.2 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/cli/apply/apply.d.ts +56 -2
- package/dist/cli/apply/apply.js +227 -13
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/index.d.ts +1 -1
- package/dist/cli/apply/index.js +1 -1
- package/dist/cli/apply/index.js.map +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +15 -0
- package/dist/cli/apply/install-codex-config.js +119 -7
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/delegate/index.js +11 -5
- package/dist/cli/delegate/index.js.map +1 -1
- package/dist/cli/index.js +13 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +1 -0
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/interactive.js +14 -5
- package/dist/cli/init/interactive.js.map +1 -1
- package/dist/cli/init/profiles.js +1 -0
- package/dist/cli/init/profiles.js.map +1 -1
- package/dist/cli/init/templates.js +1 -0
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
- package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +8 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.js +16 -4
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +11 -1
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/remove.js +8 -6
- package/dist/cli/pack/remove.js.map +1 -1
- package/dist/cli/smoke/index.js +12 -1
- package/dist/cli/smoke/index.js.map +1 -1
- package/dist/io/last-apply.d.ts +1 -0
- package/dist/io/last-apply.js +8 -1
- package/dist/io/last-apply.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +7 -2
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +2 -1
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +2 -1
- package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +24 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +20 -2
- 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.d.ts +31 -9
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +29 -19
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +50 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +46 -6
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js +11 -7
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/runtime/approval-signing.d.ts +7 -0
- package/dist/runtime/approval-signing.js +31 -4
- package/dist/runtime/approval-signing.js.map +1 -1
- package/dist/runtime/task-providers/agent-tasks.d.ts +12 -1
- package/dist/runtime/task-providers/agent-tasks.js +12 -0
- package/dist/runtime/task-providers/agent-tasks.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ 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
|
+
|
|
10
29
|
## [0.58.2] - 2026-09-24
|
|
11
30
|
|
|
12
31
|
### 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.
|
|
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.
|
|
65
|
-
*
|
|
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 };
|
package/dist/cli/apply/apply.js
CHANGED
|
@@ -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
|
|
191
|
-
//
|
|
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(
|
|
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
|
-
|
|
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 &&
|
|
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,
|