diff --git a/.changeset/applying-a-configuration-turns-act-off.md b/.changeset/applying-a-configuration-turns-act-off.md
new file mode 100644
index 00000000..39fc40fb
--- /dev/null
+++ b/.changeset/applying-a-configuration-turns-act-off.md
@@ -0,0 +1,8 @@
+---
+"@openspec-ui/core": patch
+"@openspec-ui/webui": patch
+"@openspec-ui/server": patch
+"openspec-ui-vscode": patch
+---
+
+Applying a named configuration to a change under the supervisor's Act no longer fails (applying-a-configuration-turns-act-off). Every named configuration sets Semi-autonomous, and Act acts only under Autonomous, so applying one - or saving a change's Harness Settings with another autonomy level - turns Act off instead of refusing the write, keeps the fallback agents and allowances, and says so where it says what was applied or saved. A file written by hand that sets both Act and another level is not run, but it now opens in the change's Harness Settings, which put it right on Save.
diff --git a/HARNESS.md b/HARNESS.md
index c67e2af7..1cc3c0c1 100644
--- a/HARNESS.md
+++ b/HARNESS.md
@@ -348,6 +348,18 @@ the change has one, and shows the fallback fields and the two allowances
with a note on cost and providers. `openspec-ui-cli run` prints the move,
and the reason under the next attempt's heading.
+**An edit that lowers the level turns Act off**
+(applying-a-configuration-turns-act-off). Applying a named configuration
+to a change - every one sets Semi-autonomous - or saving a change's
+Harness Settings with an autonomy level other than Autonomous removes
+`supervisor.mode` where it was `act`, so the change follows the
+workspace's mode, and says so where it says what was applied or saved.
+The fallback agents and the two allowances stay in the file: choose
+Autonomous and Act again to bring it back as it was. Only a file written
+by hand that says both `act` and another level is not run and is not
+written, since nothing chose between them; it still opens in the change's
+Harness Settings, which say Act turns off and put it right on Save.
+
### `branches`
`{ "rebaseWhenBehind"?: , "followMain"?: }`. Optional;
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml b/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml
new file mode 100644
index 00000000..b681b9ac
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml
@@ -0,0 +1,4 @@
+schema: spec-driven
+created: 2026-10-05
+follows:
+ - the-supervisor-changes-agents
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/design.md b/openspec/changes/applying-a-configuration-turns-act-off/design.md
new file mode 100644
index 00000000..7a32518a
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/design.md
@@ -0,0 +1,49 @@
+## Context
+
+`the-supervisor-changes-agents` accepts `supervisor.mode: "act"` only in a
+change's own file that also sets `autonomyLevel: "autonomous"`
+(ADR 0018: what widens authority is per-change only). The rule is checked
+when a file is written and when it resolves. Applying a named
+configuration lays the configuration over the change's file and keeps
+every other key (applying-a-template-keeps-the-rest); every named
+configuration sets `semi-autonomous`; so the result kept Act and lost
+Autonomous, and was refused.
+
+## Decisions
+
+1. **An edit that lowers the level turns Act off.** One function in core,
+ `withoutActItCannotUse(config)`, removes `supervisor.mode` where it is
+ `act` and the same config's `autonomyLevel` is not `autonomous`, and
+ says whether it did. A `supervisor` left empty is removed. The fallback
+ agents and the allowances stay: they do nothing without Act, and they
+ are what a person set.
+ - *Alternative: refuse with a clearer message.* Rejected by the owner:
+ applying a configuration is a decision to change the level, and the
+ configuration should apply.
+ - *Alternative: keep Autonomous instead.* Rejected: it would widen what
+ the configuration says, which is the direction ADR 0018 forbids
+ guessing in.
+2. **Where it applies.** `changeTemplateConfigToWrite`, the one function
+ every surface applies a configuration to a change through (extension,
+ standalone settings view, standalone run dialog), and
+ `changeConfigToSave`, which a change's Harness Settings save through.
+3. **It is said.** The extension's applied note and the settings view's
+ applied message add that the supervisor's Act is off, because it acts
+ only under Autonomous and the configuration sets another level. The
+ settings view's note under the supervisor, which said saving would be
+ refused, says Act turns off when the form is saved.
+4. **A hand-written contradiction is refused where it would do something,
+ and opens where it can be put right.** Found in the owner's check: the
+ rule was part of reading a file, so a file written by hand with Act and
+ a lower level could not even open in its Harness Settings, the one
+ place that would fix it. It moves to `actWithoutAutonomousProblem`,
+ checked where a change's file is written and where it resolves for a
+ run (which names both ways out), and not where it is read. The
+ settings view then opens it, says Act turns off on saving, and offers
+ Save with nothing else changed. The product still does not pick for a
+ file that says both: it refuses to run it and to write it.
+
+## Risks / Trade-offs
+
+- **A person may not notice Act went off.** It is said on the surface that
+ applied or saved it, and the change's Harness Settings show the mode.
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/proposal.md b/openspec/changes/applying-a-configuration-turns-act-off/proposal.md
new file mode 100644
index 00000000..18bd04fa
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/proposal.md
@@ -0,0 +1,49 @@
+## Why
+
+Reported by the owner on 2026-10-05: in a workspace whose changes run
+under `supervisor.mode: "act"`, applying any named configuration to a
+change failed with "supervisor.mode "act" needs autonomyLevel
+"autonomous" in the same file". Every named configuration sets
+`autonomyLevel: "semi-autonomous"`, and applying one keeps the rest of the
+change's file, `supervisor` included; the file it produced broke a rule
+the product itself states, so the write was refused. The same happened in
+a change's Harness Settings when the autonomy level was moved away from
+Autonomous while Act was chosen: the view only warned that saving would
+be refused.
+
+The owner's rule: configurations change freely, by rules a person can
+follow, and are refused only where refusing is truly needed.
+
+## What Changes
+
+- Where a person's own edit takes a change's autonomy level below
+ Autonomous - a named configuration applied, or the level changed in the
+ change's Harness Settings - an Act the change had is turned off rather
+ than refused: the supervisor's mode is removed, so the change follows
+ the workspace's (Advise unless the workspace says Off). The fallback
+ agents and the two allowances are kept, so choosing Act again under
+ Autonomous restores what was set.
+- Every surface that does this says so where it says what was applied or
+ saved.
+- A file written by hand that sets Act without Autonomous is refused
+ where it would act - a run - and where it is written, but it opens in
+ its Harness Settings, which put it right on saving: nothing chose
+ between the two, and only a person's save does.
+
+## Capabilities
+
+### Modified Capabilities
+
+- `agentic-harness`: applying a named configuration turns off an Act it
+ leaves without Autonomous.
+- `shared-ui`: a change's Harness Settings turn Act off, and say so, when
+ the autonomy level leaves Autonomous.
+
+## Impact
+
+- `packages/core`: `harness-templates.ts` (the rule as one function, and
+ `changeTemplateConfigToWrite` applying it).
+- `packages/webui`: `harness-settings-parts.tsx` (`changeConfigToSave`, the
+ applied message), `ChangeHarnessSettingsView.tsx` (the note).
+- `packages/extension`: `commands.ts` (the applied note).
+- `HARNESS.md`.
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md b/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md
new file mode 100644
index 00000000..94fd14b7
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md
@@ -0,0 +1,37 @@
+## ADDED Requirements
+
+### Requirement: Applying a configuration turns off an Act it leaves without Autonomous
+
+Applying a named configuration to a change SHALL NOT fail because the
+change's file has `supervisor.mode: "act"` and the configuration sets an
+autonomy level other than `autonomous`. The written file SHALL instead
+carry no `supervisor.mode`, SHALL keep the change's `supervisor.fallback`,
+`allowCostIncrease` and `allowProviderChange`, and the surface that
+applied it SHALL say that Act was turned off and why.
+
+A change's file that sets `act` with another level SHALL be refused where
+it is written and where it resolves for a run, naming how to put it
+right, and SHALL NOT be refused where it is read, so that its Harness
+Settings open.
+
+#### Scenario: A named configuration over a change under Act
+
+- **WHEN** a change's file sets `autonomyLevel: "autonomous"`,
+ `maxStageAttempts: 2` and `supervisor` with `mode: "act"` and a fallback
+ for apply, and Balanced is applied to it
+- **THEN** the file is written with `autonomyLevel: "semi-autonomous"`, a
+ `supervisor` with the fallback and no `mode`, and the applied note says
+ the supervisor's Act is off because Balanced sets Semi-autonomous
+
+#### Scenario: A hand-written contradiction
+
+- **WHEN** a change's `harness.json` with `supervisor.mode: "act"` and
+ `autonomyLevel: "semi-autonomous"` is written through the product
+- **THEN** the write is refused, naming the rule
+
+#### Scenario: The same contradiction, written by hand
+
+- **WHEN** such a file is already on disk
+- **THEN** reading it succeeds, and resolving it for a run fails, saying
+ to save the change's Harness Settings to turn Act off or to set
+ `autonomyLevel: "autonomous"`
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md b/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md
new file mode 100644
index 00000000..7293370b
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: A change's Harness Settings turn Act off when the level leaves Autonomous
+
+When a change's Harness Settings have Act chosen and the autonomy level
+is set to anything but Autonomous, by hand or by a named configuration,
+the view SHALL say that Act turns off when the form is saved, and saving
+SHALL write the change's file without `supervisor.mode`, keeping its
+fallback agents and allowances. Saving SHALL NOT be refused for it.
+
+#### Scenario: The level moved away from Autonomous
+
+- **WHEN** a change under Act has its autonomy level set to Inherit in its
+ Harness Settings and is saved
+- **THEN** the note under the supervisor said Act turns off on saving, and
+ the saved file has no `supervisor.mode` and the same fallback agents
+
+#### Scenario: A file written by hand with Act and another level
+
+- **WHEN** a change's Harness Settings are opened on a file that sets
+ `act` with `semi-autonomous`
+- **THEN** they open, say Act turns off on saving, and offer Save without
+ any other edit; saving writes the file without `supervisor.mode`
diff --git a/openspec/changes/applying-a-configuration-turns-act-off/tasks.md b/openspec/changes/applying-a-configuration-turns-act-off/tasks.md
new file mode 100644
index 00000000..093b31f4
--- /dev/null
+++ b/openspec/changes/applying-a-configuration-turns-act-off/tasks.md
@@ -0,0 +1,76 @@
+Reported by the owner on 2026-10-05: applying any named configuration to
+a change under Act failed. Rule: configurations change freely by rules a
+person can follow, and are refused only where truly needed.
+
+## 1. Core
+
+- [x] 1.1 `harness-config.ts`: `withoutActItCannotUse` (design.md
+ decision 1). Tests in `harness-config.test.ts`: act with another level
+ loses its mode and keeps fallback and allowances; act under autonomous
+ unchanged; a `supervisor` left empty is removed; a hand-written file
+ with act and another level is still refused.
+ Placed in `harness-templates.ts`, beside the function that applies
+ configurations, since `harness-config.ts` is not browser-safe; with
+ `actTurnedOff` and `actTurnedOffNote` for the surfaces. Four cases in
+ `harness-templates.test.ts`; the hand-written refusal in
+ `harness-config.test.ts`. 266 passed across both.
+- [x] 1.2 `harness-templates.ts`: `changeTemplateConfigToWrite` applies it.
+ Test in `harness-templates.test.ts`: every change-scope configuration
+ over a change under Act writes a file the validator accepts, with no
+ mode and the fallback kept.
+ Every change-scope configuration over a change under Act is written
+ and accepted by `writeChangeHarnessConfig` (`harness-config.test.ts`),
+ with no mode and the fallback kept (`harness-templates.test.ts`).
+- [x] 1.3 Found in the owner's check of 4.3: a file written by hand with
+ Act and another level did not open in its Harness Settings ("Load
+ failed"), since the rule was part of reading it. `harness-config.ts`:
+ `actWithoutAutonomousProblem`, checked on write and on resolve, not on
+ read (design.md decision 4); the settings view offers Save on such a
+ file with nothing else changed. Tests: `harness-config.test.ts` (read
+ succeeds, resolve refuses and names the way out), 267 passed with
+ `harness-templates`; `ChangeHarnessSettingsView.test.tsx`, 32 passed.
+
+## 2. Surfaces
+
+- [x] 2.1 `packages/webui`: `changeConfigToSave` applies it; the
+ supervisor's note says Act turns off on saving; the applied message
+ says so after a configuration. Tests in
+ `ChangeHarnessSettingsView.test.tsx`.
+ `changeConfigToSave` and the applied message; the standalone run
+ dialog's applied note too (`applyTemplateToChange` returns whether Act
+ went off). Two cases replace the old "will be refused" one; 31 passed
+ in the file, webui 713.
+- [x] 2.2 `packages/extension`: the applied note says Act was turned off.
+ Test beside the command's existing tests.
+ One case in `commands.test.ts`, 163 passed.
+
+## 3. Documents
+
+- [x] 3.1 `HARNESS.md`, `supervisor`, Act: what turns it off.
+ "An edit that lowers the level turns Act off".
+- [x] 3.2 A changeset: core, webui, server, extension, patch.
+ `.changeset/applying-a-configuration-turns-act-off.md`.
+
+## 4. Checks
+
+- [x] 4.1 `npm run typecheck && npm run lint`, and every test project, each
+ on its own where the root run would exceed a background limit.
+ Typecheck clean; lint 0 errors (3 warnings in lines this change did
+ not touch); seven script tests pass. Core 2101, core-git-subprocess 67,
+ cli 201, webui 713, server 122, extension 503 passed. After 1.3: core
+ 2102, core-git-subprocess 67, cli 201, webui 714, server 122, extension
+ 503 passed; typecheck and lint as before.
+- [x] 4.2 `openspec validate applying-a-configuration-turns-act-off
+ --strict`, and the merge gate with the worktree's absolute path as
+ `--cwd`.
+ Valid; the gate with `--cwd C:/Prog/.worktrees/OpenSpec-UI/applying-a-configuration-turns-act-off`
+ reports only 4.3 open (Human-only).
+- [x] 4.3 **Human-only**: in the owner's workspace, apply a named
+ configuration to a change under Act, and change its autonomy level in
+ its Harness Settings; both apply, and say Act is off.
+ Closed by the owner on 2026-10-06 in an Extension Development Host on a
+ copy of their workspace's changes: a named configuration applied and an
+ autonomy level changed over Act, both saved and said Act is off; Act
+ came back with its fallbacks; the hand-written contradiction opened and
+ was put right on Save. Ticked on the owner's word in chat: everything
+ works, close it.
diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts
index eba9f91a..c7e79e59 100644
--- a/packages/core/src/browser.ts
+++ b/packages/core/src/browser.ts
@@ -139,7 +139,7 @@ export type { ChangeDateEvidence, ChangeDateSource, ChangeDates, DatedFact } fro
// Pure over a resolved config, with only type imports from the Node-side
// modules — safe in the browser bundle, unlike harness-config.js itself.
export { findHarnessConfigLimits, groupHarnessFindings } from "./harness-config-findings.js";
-export { agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope } from "./harness-templates.js";
+export { actTurnedOff, actTurnedOffNote, agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope, withoutActItCannotUse } from "./harness-templates.js";
export { HARNESS_EFFORT_LEVELS, effortLevelCollisions, resolveEffortLevel } from "./harness-effort-level.js";
export type { HarnessEffortLevel, ResolvedEffort } from "./harness-effort-level.js";
export { recommendTemplate } from "./harness-recommendation.js";
diff --git a/packages/core/src/harness-config.test.ts b/packages/core/src/harness-config.test.ts
index 00300497..eb186d42 100644
--- a/packages/core/src/harness-config.test.ts
+++ b/packages/core/src/harness-config.test.ts
@@ -35,7 +35,7 @@ import {
// merged a stage entry by spread and kept `customAgent`, `mergeStepAgent`
// named three fields and dropped it, so the same entry came out of the two
// differently. See a-stage-override-keeps-its-custom-agent.
-import { HARNESS_TEMPLATES, templateConfigToWrite } from "./harness-templates.js";
+import { changeTemplateConfigToWrite, HARNESS_TEMPLATES, templateConfigToWrite, templatesForScope } from "./harness-templates.js";
// suite-survives-a-loaded-machine:
// measured 2026-09-05 for this file alone at 734ms test time (3.91s wall)
@@ -1104,6 +1104,33 @@ describe("supervisor act", () => {
await expect(write({ apply: [] })).rejects.toThrow(/non-empty list/);
});
+ // applying-a-configuration-turns-act-off 1.1, 1.2: what a named
+ // configuration writes over a change under Act is a file that is accepted;
+ // the same contradiction written by hand is still refused.
+ it("accepts what a named configuration writes over Act, and refuses the contradiction by hand", async () => {
+ const root = await temporaryRoot();
+ const own = { autonomyLevel: "autonomous" as const, maxStageAttempts: 2, supervisor: act };
+ await writeChangeHarnessConfig(root, "demo", own);
+ for (const template of templatesForScope("change")) {
+ await writeChangeHarnessConfig(root, "demo", changeTemplateConfigToWrite(template, await resolveHarnessConfig(root), own));
+ }
+ await expect(writeChangeHarnessConfig(root, "demo", { ...own, autonomyLevel: "semi-autonomous" }))
+ .rejects.toThrow(/needs autonomyLevel "autonomous" in the same file/);
+ });
+
+ // applying-a-configuration-turns-act-off: a hand-written contradiction
+ // opens, so it can be put right in its Harness Settings, and nothing runs
+ // on it.
+ it("reads a hand-written Act without Autonomous, and refuses to resolve it for a run", async () => {
+ const root = await temporaryRoot();
+ const file = path.join(root, "openspec", "changes", "demo", "harness.json");
+ await mkdir(path.dirname(file), { recursive: true });
+ await writeFile(file, JSON.stringify({ autonomyLevel: "semi-autonomous", maxStageAttempts: 2, supervisor: act }), "utf8");
+
+ expect((await readChangeHarnessConfig(root, "demo"))?.supervisor?.mode).toBe("act");
+ await expect(resolveHarnessConfig(root, "demo")).rejects.toThrow(/Open the change's Harness Settings and save to turn Act off/);
+ });
+
it("refuses the allowances in the workspace's file", async () => {
const root = await temporaryRoot();
await expect(writeGlobalHarnessConfig(root, { supervisor: { allowCostIncrease: true } } as never))
diff --git a/packages/core/src/harness-config.ts b/packages/core/src/harness-config.ts
index 5c0db640..77199427 100644
--- a/packages/core/src/harness-config.ts
+++ b/packages/core/src/harness-config.ts
@@ -150,6 +150,18 @@ export function resolveSupervisor(value: HarnessSupervisor | undefined): Resolve
/** Why a resolved configuration's `act` cannot act, or `undefined`. Checked
* where the configuration resolves, because the attempts it spends may come
* from the other file (the-supervisor-changes-agents). */
+/** Why a change's own file sets an `act` it cannot have, or `undefined`:
+ * Act needs `autonomyLevel: "autonomous"` in the same file (ADR 0018).
+ * Refused where the file is written and where it resolves for a run, and
+ * not where it is read: a file written by hand that says both has to open
+ * in its Harness Settings to be put right there
+ * (applying-a-configuration-turns-act-off). */
+export function actWithoutAutonomousProblem(own: Partial | undefined): string | undefined {
+ if (own?.supervisor?.mode !== "act" || own.autonomyLevel === "autonomous") return undefined;
+ return 'supervisor.mode "act" needs autonomyLevel "autonomous" in the same file: under a level that asks a person, the person decides.'
+ + " Open the change's Harness Settings and save to turn Act off, or set autonomyLevel \"autonomous\"";
+}
+
export function supervisorActProblem(config: Pick): string | undefined {
if (config.supervisor?.mode !== "act") return undefined;
const attempts = config.maxStageAttempts ?? 1;
@@ -909,7 +921,6 @@ const SUPERVISOR_KEYS = ["mode", "silentAfterSeconds", "waitingAfterSeconds", "f
function assertValidSupervisor(
value: unknown,
isPerChangeFile: boolean,
- autonomyLevel: HarnessAutonomyLevel | undefined,
): asserts value is HarnessSupervisor | undefined {
if (value === undefined) return;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -928,9 +939,6 @@ function assertValidSupervisor(
if (!isPerChangeFile) {
throw new InvalidHarnessConfigError('supervisor.mode "act" is set in a change\'s own harness.json, never for the whole workspace: it may start another agent unattended');
}
- if (autonomyLevel !== "autonomous") {
- throw new InvalidHarnessConfigError('supervisor.mode "act" needs autonomyLevel "autonomous" in the same file: under a level that asks a person, the person decides');
- }
}
for (const key of ["silentAfterSeconds", "waitingAfterSeconds"] as const) {
const seconds = record[key];
@@ -1182,7 +1190,7 @@ function assertValidHarnessConfigInput(
assertValidReviewGate(input.reviewGate, isPerChangeFile);
assertValidCheckpoints(input.checkpoints, isPerChangeFile);
assertValidHints((input as { hints?: unknown }).hints);
- assertValidSupervisor((input as { supervisor?: unknown }).supervisor, isPerChangeFile, input.autonomyLevel);
+ assertValidSupervisor((input as { supervisor?: unknown }).supervisor, isPerChangeFile);
assertValidMaxCost((input as { budget?: { maxCost?: unknown } }).budget?.maxCost);
const allowAgentMessages = (input as { allowAgentMessages?: unknown }).allowAgentMessages;
if (allowAgentMessages !== undefined && typeof allowAgentMessages !== "boolean") {
@@ -1490,6 +1498,12 @@ export async function resolveHarnessConfig(workspaceRoot: string, changeName?: s
}
});
+ // Act without Autonomous in the change's own file is read, so its
+ // settings can open, and refused here, so nothing runs on it
+ // (applying-a-configuration-turns-act-off).
+ const contradiction = actWithoutAutonomousProblem(override);
+ if (contradiction !== undefined) throw new InvalidHarnessConfigError(contradiction);
+
// `act` spends attempts, which may be set in either file
// (the-supervisor-changes-agents).
const actProblem = supervisorActProblem(merged);
@@ -1525,5 +1539,7 @@ export async function writeChangeHarnessConfig(
): Promise {
const migrated = migrateLegacyDispatchInConfig(config);
assertValidHarnessConfigInput(migrated.value, true);
+ const contradiction = actWithoutAutonomousProblem(migrated.value as Partial);
+ if (contradiction !== undefined) throw new InvalidHarnessConfigError(contradiction);
await writeJsonFile(changeHarnessConfigPath(workspaceRoot, changeName), migrated.value);
}
diff --git a/packages/core/src/harness-templates.test.ts b/packages/core/src/harness-templates.test.ts
index cdbed848..550e799d 100644
--- a/packages/core/src/harness-templates.test.ts
+++ b/packages/core/src/harness-templates.test.ts
@@ -4,7 +4,16 @@ import { DEFAULT_HARNESS_CONFIG, type HarnessConfig } from "./harness-config.js"
import { HARNESS_AGENT_CAPABILITIES, normalizeStepAgent } from "./harness-step-agent.js";
import { HARNESS_EFFORT_LEVELS, resolveEffortLevel } from "./harness-effort-level.js";
import type { HarnessStepAgent, HarnessStepAgents } from "./harness-step-agent.js";
-import { agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope } from "./harness-templates.js";
+import {
+ actTurnedOff,
+ agentForEveryStageToWrite,
+ changeTemplateConfigToWrite,
+ HARNESS_TEMPLATES,
+ stepAgentsForTemplate,
+ templateConfigToWrite,
+ templatesForScope,
+ withoutActItCannotUse,
+} from "./harness-templates.js";
// settings-templates:
// pure over in-memory data — no files, no processes. Measured 2026-09-08
@@ -459,3 +468,56 @@ describe("agentForEveryStageToWrite — a recommendation from runs, applied", ()
expect(written.maxStageAttempts).toBe(3);
});
});
+
+// applying-a-configuration-turns-act-off 1.1, 1.2
+describe("withoutActItCannotUse — an edit that lowers the level turns Act off", () => {
+ const underAct: Partial = {
+ autonomyLevel: "autonomous",
+ maxStageAttempts: 2,
+ supervisor: {
+ mode: "act",
+ fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] },
+ allowCostIncrease: true,
+ allowProviderChange: true,
+ },
+ };
+
+ it("removes the mode under another level, and keeps the fallback and the allowances", () => {
+ expect(withoutActItCannotUse({ ...underAct, autonomyLevel: "semi-autonomous" })).toEqual({
+ autonomyLevel: "semi-autonomous",
+ maxStageAttempts: 2,
+ supervisor: {
+ fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] },
+ allowCostIncrease: true,
+ allowProviderChange: true,
+ },
+ });
+ const { autonomyLevel: _inherited, ...inheriting } = underAct;
+ expect(withoutActItCannotUse(inheriting).supervisor).not.toHaveProperty("mode");
+ });
+
+ it("leaves Act under autonomous, and every other mode, as it was", () => {
+ expect(withoutActItCannotUse(underAct)).toBe(underAct);
+ const advising: Partial = { autonomyLevel: "assisted", supervisor: { mode: "off" } };
+ expect(withoutActItCannotUse(advising)).toBe(advising);
+ });
+
+ it("removes a supervisor left with nothing in it", () => {
+ expect(withoutActItCannotUse({ autonomyLevel: "semi-autonomous", supervisor: { mode: "act" } })).toEqual({ autonomyLevel: "semi-autonomous" });
+ });
+
+ it("is applied by every named configuration a change may be given, and says so", () => {
+ for (const template of templatesForScope("change")) {
+ const written = changeTemplateConfigToWrite(template, DEFAULT_HARNESS_CONFIG, underAct);
+ expect(written.autonomyLevel, template.id).not.toBe("autonomous");
+ expect(written.supervisor, template.id).toEqual({
+ fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] },
+ allowCostIncrease: true,
+ allowProviderChange: true,
+ });
+ expect(actTurnedOff(underAct, written), template.id).toBe(true);
+ }
+ expect(actTurnedOff(underAct, underAct)).toBe(false);
+ expect(actTurnedOff(undefined, {})).toBe(false);
+ });
+});
diff --git a/packages/core/src/harness-templates.ts b/packages/core/src/harness-templates.ts
index 1f268ca3..4869afb5 100644
--- a/packages/core/src/harness-templates.ts
+++ b/packages/core/src/harness-templates.ts
@@ -260,11 +260,47 @@ export function changeTemplateConfigToWrite(
base: HarnessConfig,
override: Partial | undefined,
): Partial {
- return templateConfigToWrite(
+ // Every named configuration sets a level below autonomous, and an Act the
+ // change kept would make the file one the validator refuses
+ // (applying-a-configuration-turns-act-off).
+ return withoutActItCannotUse(templateConfigToWrite(
template,
mergeStepAgents(base.stepAgents, override?.stepAgents),
override ?? {},
- );
+ ));
+}
+
+/** A change's file with an Act it can no longer use turned off: where the
+ * supervisor's mode is `act` and the same file's autonomy level is not
+ * `autonomous`, the mode is removed, so the change follows the
+ * workspace's. The fallback agents and allowances stay, so choosing Act
+ * again under Autonomous restores what was set; a `supervisor` left empty
+ * is removed. Applied where a person's own edit lowered the level - a named
+ * configuration, the change's Harness Settings - and never to a file read
+ * as written, whose contradiction is refused rather than guessed at
+ * (applying-a-configuration-turns-act-off). */
+export function withoutActItCannotUse>(config: T): T {
+ if (config.supervisor?.mode !== "act" || config.autonomyLevel === "autonomous") return config;
+ const { mode: _off, ...rest } = config.supervisor;
+ const result = { ...config };
+ if (Object.keys(rest).length === 0) delete result.supervisor;
+ else result.supervisor = rest;
+ return result;
+}
+
+/** What a surface says where applying `template` turned a change's Act off:
+ * the same words in every host. */
+export function actTurnedOffNote(template: HarnessTemplate): string {
+ const level = template.config.autonomyLevel;
+ const named = level === undefined ? "another level" : `${level[0]?.toUpperCase() ?? ""}${level.slice(1)}`;
+ return `The supervisor's Act is off: it acts only under Autonomous, and "${template.title}" sets ${named}.`
+ + " The fallback agents and allowances are kept.";
+}
+
+/** Whether an edit turned the change's Act off, for the surface that made
+ * it to say so. */
+export function actTurnedOff(before: Partial | null | undefined, after: Partial): boolean {
+ return before?.supervisor?.mode === "act" && after.supervisor?.mode !== "act";
}
/** The override to write when one agent is put on every stage of a
diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts
index 0b9374ae..244efbf5 100644
--- a/packages/extension/src/commands.test.ts
+++ b/packages/extension/src/commands.test.ts
@@ -2752,6 +2752,26 @@ describe("createRunChoiceHandler", () => {
expect(deps.implementationSessions.start).not.toHaveBeenCalled();
});
+ // applying-a-configuration-turns-act-off 2.2: a change's Act that the
+ // written level does not allow is turned off, kept fallback and all, and
+ // the note beside the Apply button says so.
+ it("turns off an Act the written level does not allow, and says so", async () => {
+ readGlobalHarnessConfigMock.mockResolvedValue({ stepAgents: {}, autonomyLevel: "assisted", reviewGate: { mode: "human-required" } });
+ resolveHarnessConfigMock.mockResolvedValue({ stepAgents: {}, autonomyLevel: "assisted", reviewGate: { mode: "human-required" } });
+ readChangeHarnessConfigMock.mockResolvedValue({ supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] } } });
+ buildRunPlanMock.mockReturnValue({ resolved: "single-stage", because: "x", stageAgents: [], offered: [], findings: [] });
+ const deps = makeDeps();
+
+ await createRunChoiceHandler(deps)({ kind: "apply-template", templateId: "balanced" }, context);
+
+ expect(writeChangeHarnessConfigMock).toHaveBeenCalledWith("/workspace/repo", "demo-change", expect.objectContaining({
+ supervisor: { fallback: { apply: ["local-llm-acp"] } },
+ }));
+ expect(deps.revealAiPanel).toHaveBeenCalledWith(expect.objectContaining({
+ appliedNote: expect.stringContaining("The supervisor's Act is off: it acts only under Autonomous"),
+ }));
+ });
+
it("writes nothing for a configuration id it does not have", async () => {
// The two sides disagreeing about what exists is worth a line in the
// log; it is not worth writing something.
diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts
index c9df4e4f..3322f948 100644
--- a/packages/extension/src/commands.ts
+++ b/packages/extension/src/commands.ts
@@ -26,6 +26,8 @@ import {
describeChangeState,
readChangeStandings,
type DescribedChangeState,
+ actTurnedOff,
+ actTurnedOffNote,
changeTemplateConfigToWrite,
templatesForScope,
type RecommendationInput, type RunStartFacts,
@@ -678,18 +680,18 @@ export function createRunChoiceHandler(deps: CommandsDeps) {
// and a-stage-override-keeps-its-custom-agent.
const global = await readGlobalHarnessConfig(workspaceRoot);
const existing = await readChangeHarnessConfig(workspaceRoot, changeName);
- await writeChangeHarnessConfig(
- workspaceRoot,
- changeName,
- changeTemplateConfigToWrite(template, global, existing),
- );
+ const written = changeTemplateConfigToWrite(template, global, existing);
+ await writeChangeHarnessConfig(workspaceRoot, changeName, written);
// Re-read rather than re-render what was on screen: the dialog
// should show what the file now resolves to, which is the same
// thing the standalone shell does after applying one.
await revealResolved({
appliedNote: `Applied "${template.title}" to openspec/changes/${changeName}/harness.json. `
- + "The dialog now shows what the change resolves to.",
+ + "The dialog now shows what the change resolves to."
+ // An Act the configuration's level does not allow was turned off
+ // (applying-a-configuration-turns-act-off).
+ + (actTurnedOff(existing, written) ? ` ${actTurnedOffNote(template)}` : ""),
});
} catch (error) {
await showCommandError("apply the named configuration", error);
diff --git a/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx b/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx
index 408cdb01..5f004f28 100644
--- a/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx
+++ b/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx
@@ -347,16 +347,72 @@ describe("ChangeHarnessSettingsView — saving", () => {
await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalledWith("demo", expect.objectContaining({ supervisor: { mode: "act" } })));
});
- it("says Act will be refused once the change is no longer autonomous", async () => {
+ // applying-a-configuration-turns-act-off 2.1
+ it("turns Act off on saving once the change is no longer autonomous, and says so first", async () => {
const api = createApi({
- readChangeOverride: vi.fn().mockResolvedValue({ stepAgents: {}, autonomyLevel: "autonomous", maxStageAttempts: 3, supervisor: { mode: "act" } }),
+ readChangeOverride: vi.fn().mockResolvedValue({
+ stepAgents: {},
+ autonomyLevel: "autonomous",
+ maxStageAttempts: 3,
+ supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] }, allowCostIncrease: true },
+ }),
});
await renderLoaded(api);
expect(chosen("Change supervisor")).toBe("act");
- expect(screen.queryByTestId("change-supervisor-act-refused")).toBeNull();
+ expect(screen.queryByTestId("change-supervisor-act-off")).toBeNull();
choose("Change autonomy level", "");
- expect(screen.getByTestId("change-supervisor-act-refused").textContent).toContain("saving will be refused");
+ expect(screen.getByTestId("change-supervisor-act-off").textContent).toContain("Act turns off when this is saved");
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled());
+ const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record;
+ expect(saved.autonomyLevel).toBeUndefined();
+ expect(saved.supervisor).toEqual({ fallback: { apply: ["local-llm-acp"] }, allowCostIncrease: true });
+ });
+
+ it("opens a file written by hand with Act and another level, and saving puts it right", async () => {
+ const api = createApi({
+ readChangeOverride: vi.fn().mockResolvedValue({
+ stepAgents: {},
+ autonomyLevel: "semi-autonomous",
+ maxStageAttempts: 2,
+ supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] } },
+ }),
+ });
+ await renderLoaded(api);
+
+ expect(chosen("Change supervisor")).toBe("act");
+ expect(screen.getByTestId("change-supervisor-act-off").textContent).toContain("Act turns off when this is saved");
+ // Nothing else to change: saving it as it stands is what puts it right.
+ expect(saveButton()).not.toBeDisabled();
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled());
+ const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record;
+ expect(saved.supervisor).toEqual({ fallback: { apply: ["local-llm-acp"] } });
+ });
+
+ it("applies a named configuration over Act, and says Act is off", async () => {
+ const api = createApi({
+ readChangeOverride: vi.fn().mockResolvedValue({
+ stepAgents: { apply: "local-llm-acp" },
+ autonomyLevel: "autonomous",
+ maxStageAttempts: 2,
+ supervisor: { mode: "act", fallback: { apply: ["deepseek-cli-acp"] }, allowProviderChange: true },
+ }),
+ });
+ await renderLoaded(api);
+
+ choose("Named configuration", "balanced");
+ fireEvent.click(screen.getByRole("button", { name: "Apply to the form" }));
+ expect(screen.getByText(/The supervisor's Act is off: it acts only under Autonomous, and "Balanced" sets Semi-autonomous/)).toBeTruthy();
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled());
+ const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record;
+ expect(saved.autonomyLevel).toBe("semi-autonomous");
+ expect(saved.supervisor).toEqual({ fallback: { apply: ["deepseek-cli-acp"] }, allowProviderChange: true });
});
it("still removes autonomyLevel when it is set back to inherit", async () => {
diff --git a/packages/webui/src/components/ChangeHarnessSettingsView.tsx b/packages/webui/src/components/ChangeHarnessSettingsView.tsx
index 7e9ec231..28548091 100644
--- a/packages/webui/src/components/ChangeHarnessSettingsView.tsx
+++ b/packages/webui/src/components/ChangeHarnessSettingsView.tsx
@@ -1,5 +1,6 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
+ actTurnedOff,
changeTemplateConfigToWrite,
findHarnessConfigLimits,
isHarnessStepAgentStage,
@@ -203,8 +204,12 @@ export function ChangeHarnessSettingsView(
return findHarnessConfigLimits({ ...global, ...ownRest, stepAgents: resolvedStepAgents });
}, [global, override, resolvedStepAgents]);
+ // A saved file that says Act under another level - written by hand - is
+ // put right by saving it as it stands, so there is something to save
+ // (applying-a-configuration-turns-act-off).
+ const actToTurnOff = override?.supervisor?.mode === "act" && autonomyLevel !== "autonomous";
const dirty = savedSnapshot !== null && override !== undefined
- && snapshotOf(override, forms, autonomyLevel, reviewGateMode, runBudget) !== savedSnapshot;
+ && (actToTurnOff || snapshotOf(override, forms, autonomyLevel, reviewGateMode, runBudget) !== savedSnapshot);
/** Applies a named configuration against what the change resolves to,
* through the one core function every surface applies one through, and
@@ -222,7 +227,7 @@ export function ChangeHarnessSettingsView(
// The ceilings ride here, not in the form, and the save lays the form
// over this rather than replacing it.
setOverride(written);
- setApplyStatus(changeTemplateAppliedMessage(template, writtenForms.effort, resolvedAgents, reset));
+ setApplyStatus(changeTemplateAppliedMessage(template, writtenForms.effort, resolvedAgents, reset, actTurnedOff(override, written)));
};
async function save() {
@@ -417,8 +422,8 @@ export function ChangeHarnessSettingsView(
: ""}
{supervisorMode === "act" && !ownAutonomous ? (
-
- Act needs this change's own autonomy level to be Autonomous: saving will be refused until it is, or until another mode is chosen.
+
+ Act turns off when this is saved: it acts only where this change's own autonomy level is Autonomous. The fallback agents and allowances are kept.
) : null}
{supervisorMode === "act" ? (
diff --git a/packages/webui/src/components/harness-settings-parts.tsx b/packages/webui/src/components/harness-settings-parts.tsx
index d8259d13..618cb56c 100644
--- a/packages/webui/src/components/harness-settings-parts.tsx
+++ b/packages/webui/src/components/harness-settings-parts.tsx
@@ -10,6 +10,8 @@ import {
resolveEffortLevel,
STAGE_PURPOSES,
VSCODE_CHAT_STEP_AGENT_ID,
+ actTurnedOffNote,
+ withoutActItCannotUse,
type HarnessAutonomyLevel,
type HarnessConfig,
type HarnessEffort,
@@ -266,12 +268,14 @@ export function changeTemplateAppliedMessage(
effort: StepEffortForm,
agents: StepAgentsForm,
reset: readonly HarnessStepAgentStage[],
+ actOff = false,
): string {
+ const actNote = actOff ? ` ${actTurnedOffNote(template)}` : "";
const agentNote = reset.length === 0
? " The agents and models on screen are unchanged."
: ` ${reset.join(", ")} now name${reset.length === 1 ? "s" : ""} the agent the change resolves to,`
+ " because an effort without its agent means nothing.";
- return `Filled from "${template.title}". ${templateEffortNote(template, effort, agents)}${agentNote}`
+ return `Filled from "${template.title}". ${templateEffortNote(template, effort, agents)}${agentNote}${actNote}`
+ " Nothing is saved until you save.";
}
@@ -349,7 +353,9 @@ export function changeConfigToSave(
else delete config.autonomyLevel;
if (reviewGateMode !== INHERIT) config.reviewGate = { mode: reviewGateMode };
else delete config.reviewGate;
- return withRunBudget(config, runBudget);
+ // An Act the level no longer allows is turned off rather than refused
+ // (applying-a-configuration-turns-act-off).
+ return withoutActItCannotUse(withRunBudget(config, runBudget));
}
/** The run budget as the field shows it: the chain's cost ceiling, or empty. */
diff --git a/packages/webui/src/run-with-harness-dispatch.ts b/packages/webui/src/run-with-harness-dispatch.ts
index 4732dcaf..5e8d1af4 100644
--- a/packages/webui/src/run-with-harness-dispatch.ts
+++ b/packages/webui/src/run-with-harness-dispatch.ts
@@ -7,7 +7,7 @@
// code from entry-point wiring (`harness-config-client.ts`,
// `change-editor-client.ts`).
-import { agentForEveryStageToWrite, buildRunPlan, changeTemplateConfigToWrite, openTaskCount, resolveRunWithHarnessTarget, runStartFactsFrom, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser";
+import { actTurnedOff, agentForEveryStageToWrite, buildRunPlan, changeTemplateConfigToWrite, openTaskCount, resolveRunWithHarnessTarget, runStartFactsFrom, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser";
import { readChangeHarnessOverride, resolveHarnessConfig, writeHarnessConfig } from "./harness-config-client.js";
import { loadChangeTimeline } from "./change-timeline-client.js";
import type { ChangeEditorRequest } from "./change-editor-client.js";
@@ -118,7 +118,7 @@ export async function applyTemplateToChange(
cwd: string,
changeName: string,
template: HarnessTemplate,
-): Promise {
+): Promise<{ actTurnedOff: boolean }> {
const [existing, global] = await Promise.all([
readChangeHarnessOverride(request, cwd, changeName),
// The global file, not the change's own override: the effort has to
@@ -129,6 +129,9 @@ export async function applyTemplateToChange(
]);
const config = changeTemplateConfigToWrite(template, global, existing ?? undefined);
await writeHarnessConfig(request, cwd, config, changeName);
+ // Said by the caller beside what it applied
+ // (applying-a-configuration-turns-act-off).
+ return { actTurnedOff: actTurnedOff(existing, config) };
}
/** Puts one agent on every stage of a change, keeping what the change's
diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx
index 753651a4..bb64b321 100644
--- a/packages/webui/src/standalone-entry.tsx
+++ b/packages/webui/src/standalone-entry.tsx
@@ -116,6 +116,7 @@ import {
} from "./run-with-harness-dispatch.js";
import { fireDueSchedule, type ScheduleFiringHost } from "./scheduled-run-firing.js";
import {
+ actTurnedOffNote,
comparisonRows,
comparisonWindow,
DEFAULT_COMPARISON_PERIOD,
@@ -928,14 +929,15 @@ function StandaloneApp() {
if (!runDispatch) return;
setRunHarnessLoading(true);
try {
- await applyTemplateToChangeApi(apiFetch, cwd, runChangeName, template);
+ const applied = await applyTemplateToChangeApi(apiFetch, cwd, runChangeName, template);
setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, cwd, runChangeName));
// Beside the Apply button, in the dialog. It used to be set above
// the dialog, where the person who pressed Apply could not see it
// (a-change-is-configured-from-the-change).
setRunAppliedNote(
`Applied "${template.title}" to openspec/changes/${runChangeName}/harness.json. `
- + "The dialog now shows what the change resolves to.",
+ + "The dialog now shows what the change resolves to."
+ + (applied.actTurnedOff ? ` ${actTurnedOffNote(template)}` : ""),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);